commit bc41dfcf7d8b73011b50119c317f66ffdb79fb84 Author: AskaEth Date: Sat Oct 31 21:54:10 2026 +0800 Unlucky Launcher v0.0.1-alpha Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent), MCP server 接入外部 Agent 工具,GUI 开发中。 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f3f1a1b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,34 @@ +# 换行策略:仓库内一律 LF(rustfmt 设 newline_style = "Unix",CI 在 Linux 上跑 fmt --check)。 +# eol=lf 对工作区同样生效 —— Windows 克隆也不会被转换成 CRLF,避免「本地 fmt 过、CI fmt 挂」。 +* text=auto eol=lf + +# 明确为文本(显式列出,防止被误判成二进制) +*.rs text eol=lf +*.toml text eol=lf +*.md text eol=lf +*.slint text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +Cargo.lock text eol=lf + +# 二进制:不做换行转换、不做文本 diff +*.png binary +*.ico binary +*.icns binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ttf binary +*.otf binary +*.woff binary +*.woff2 binary +*.zip binary +*.7z binary +*.gz binary +*.tar binary +*.exe binary +*.dll binary +*.lib binary +*.pdb binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f472f4c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,92 @@ +name: CI + +# 主干门禁:fmt / clippy / test / build 双平台矩阵。 +# 架构守门(core 零 UI、文件 ≤400 行、依赖许可)见 guards.yml。 +# 平台基线 Win10+(标准工具链产物),平台矩阵见 AGENTS.md §7。 + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + fmt: + name: fmt (rustfmt --check) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all --check + + clippy: + name: clippy (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Linux 图形栈构建依赖(winit / femtovg 编译期需要) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \ + libwayland-dev libxcb1-dev + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --workspace --all-targets -- -D warnings + + test: + name: test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Linux 图形栈构建依赖 + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \ + libwayland-dev libxcb1-dev + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --workspace --all-targets + + build: + name: build release (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Linux 图形栈构建依赖 + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \ + libwayland-dev libxcb1-dev + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo build --workspace --release diff --git a/.github/workflows/guards.yml b/.github/workflows/guards.yml new file mode 100644 index 0000000..4d3d580 --- /dev/null +++ b/.github/workflows/guards.yml @@ -0,0 +1,81 @@ +name: Guards + +# 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error): +# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查 +# 2. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束) +# 3. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验 + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + core-no-ui: + name: core 零 UI 守门 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: 依赖表直查(crates/core/Cargo.toml 不得出现 UI 框架) + run: | + set -uo pipefail + forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm' + # 只看非注释行:manifest 里说明「不许出现 slint」的注释本身不该触发守门 + if grep -vE '^[[:space:]]*#' crates/core/Cargo.toml | grep -nEi "$forbidden"; then + echo "::error file=crates/core/Cargo.toml::core 不得依赖 UI 框架(见 AGENTS.md 硬约束)" + exit 1 + fi + echo "OK: crates/core/Cargo.toml 无 UI 依赖" + - name: 依赖树泄漏检查(cargo tree -p unlucky-core,排除自身行) + run: | + set -uo pipefail + forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm' + tree=$(cargo tree -p unlucky-core --edges normal --prefix none) + echo "$tree" + if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then + echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许" + exit 1 + fi + echo "OK: unlucky-core 依赖树零 UI" + + file-size: + name: 文件行数 ≤400 守门 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: 检查单文件行数 + run: | + set -uo pipefail + LIMIT=400 + failed=0 + while IFS= read -r f; do + case "$f" in + LICENSE|*/LICENSE|LICENSE-*|*/LICENSE-*) continue ;; + CHANGELOG*|*/CHANGELOG*) continue ;; + esac + n=$(wc -l < "$f") + if [ "$n" -gt "$LIMIT" ]; then + echo "::error file=$f::$f 有 $n 行,超过 $LIMIT 行上限,请按模块拆分" + failed=$((failed + 1)) + fi + done < <(git ls-files '*.rs' '*.md' '*.slint' '*.yml' '*.yaml' '*.toml' '*.json') + if [ "$failed" -ne 0 ]; then + echo "共 $failed 个文件超限" + exit 1 + fi + echo "OK: 全部受检文件 ≤$LIMIT 行" + + licenses: + name: 依赖许可与来源(cargo-deny) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + # 只跑许可/禁用/来源三类,advisories 由后续安全轮次单独接管 + command: check licenses bans sources diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f670c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Reference source repositories (local-only, git-ignored) +/HMCL/ +/PCL/ + +# Local development docs +/DevDocs/ + +# Agent workspace (local-only) +/.tower/ + +# Build outputs +build/ +dist/ +out/ +target/ + +# IDE +.idea/ +.vscode/ +*.iml + +# OS +Thumbs.db +.DS_Store diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..bdb5303 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,5 @@ +edition = "2021" +max_width = 100 +newline_style = "Unix" +use_field_init_shorthand = true +use_try_shorthand = true diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..09ae405 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7413 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_ios" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "750c4e9f6ce888dfe8a10c0f1b5ceb646a7854fd46579d74919219d1bb314083" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5b41e63a69f36d9f1f41e70464c7e5f72eee485ef26aab19f0b4f86e6c0a84c" +dependencies = [ + "accesskit", + "accesskit_ios", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "annotate-snippets" +version = "0.12.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a5b0a5ab8561c603f16c3b78c24a386b1f70e7fc1b1b1e8c15738828e4a176" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "arboard" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" +dependencies = [ + "clipboard-win", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.61.2", + "wl-clipboard-rs", + "x11rb", +] + +[[package]] +name = "arc-swap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.6", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.6", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.6", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "serde", + "unty", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.6", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clipboard-win" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" +dependencies = [ + "error-code", +] + +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-field-offset" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d535c69e75b4e3c69e17205b5b37a91936c54086cecd339b70071500786acb9c" +dependencies = [ + "const-field-offset-macro", + "field-offset", +] + +[[package]] +name = "const-field-offset-macro" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d5848843a221ac268cfdd6bfd14e68d5d3dfa79d52c880aafab93f9517fb74" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "dtor", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "drm" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "libc", + "rustix 0.38.44", +] + +[[package]] +name = "drm-ffi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" +dependencies = [ + "drm-sys", + "rustix 1.1.6", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" +dependencies = [ + "libc", + "linux-raw-sys 0.9.4", +] + +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "femtovg" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31eebf3ab1b76359ccd5f850c07a7c6b2722c64d9127c40e02358e83f3ff9b1a" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "fnv", + "glow", + "imgref", + "itertools 0.15.0", + "log", + "rgb", + "slotmap", + "swash", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" +dependencies = [ + "log", + "slotmap", + "tinyvec", +] + +[[package]] +name = "fontique" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a" +dependencies = [ + "hashbrown 0.17.1", + "linebender_resource_handle", + "memmap2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "parlance", + "read-fonts", + "roxmltree", + "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" +dependencies = [ + "gloo-timers", + "send_wrapper", +] + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gbm" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a" +dependencies = [ + "bitflags 2.13.2", + "drm", + "drm-fourcc", + "gbm-sys", + "libc", +] + +[[package]] +name = "gbm-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13a5f2acc785d8fb6bf6b7ab6bfb0ef5dad4f4d97e8e70bb8e470722312f76f" +dependencies = [ + "libc", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.6", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "glow" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "263218b0ba2b0715c3370fb04674f5f8aa331594b521c94ff0a8e4a8472d1386" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "htmlparser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48ce8546b993eaf241d69ded33b1be6d205dd9857ec879d9d18bd05d3676e144" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.11", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "i-slint-backend-linuxkms" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f519a8611a4982921e46e391015e5e40ceac5cc63813333ef91ada77df180d8" +dependencies = [ + "bytemuck", + "calloop 0.14.5", + "cfg_aliases", + "drm", + "gbm", + "glutin", + "i-slint-common", + "i-slint-core", + "i-slint-renderer-femtovg", + "i-slint-renderer-software", + "input", + "libseat", + "memmap2", + "nix", + "raw-window-handle", + "xkbcommon", +] + +[[package]] +name = "i-slint-backend-selector" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb974705d61ba56034f6129ff61f3e6282a2c0c07752f25e6cb417d81c0f0958" +dependencies = [ + "cfg-if", + "cfg_aliases", + "i-slint-backend-linuxkms", + "i-slint-backend-testing", + "i-slint-backend-winit", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "i-slint-renderer-femtovg", +] + +[[package]] +name = "i-slint-backend-testing" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01943d6c5ea5bc913d497bdaa423c309e7627afff47a6794bfd13be5c564209f" +dependencies = [ + "cfg_aliases", + "i-slint-common", + "i-slint-core", + "i-slint-renderer-software", + "vtable", +] + +[[package]] +name = "i-slint-backend-winit" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae4fcf1b19e9cb109984d2af87d2b9485388c619265bff71dd523133888bee2" +dependencies = [ + "accesskit", + "accesskit_winit", + "arboard", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "futures", + "glutin", + "glutin-winit", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "i-slint-renderer-femtovg", + "i-slint-renderer-skia", + "i-slint-renderer-software", + "imgref", + "lyon_path", + "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "objc2-ui-kit 0.3.2", + "pin-weak", + "raw-window-handle", + "rgb", + "scoped-tls-hkt", + "scopeguard", + "softbuffer", + "strum", + "vtable", + "wasm-bindgen", + "web-sys", + "webbrowser", + "windows", + "winit", + "zbus", +] + +[[package]] +name = "i-slint-common" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68a578f3e4b681b2b9cbd326d3be19716e2fac51d00a8a5b00c0ef4ae923b5e6" +dependencies = [ + "derive_more", + "fontique", + "htmlparser", + "icu_decimal", + "icu_locale_core", + "icu_provider", + "pulldown-cmark", + "resvg", + "skrifa", +] + +[[package]] +name = "i-slint-compiler" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55d48ebef05e2bf09ba5a1fc67bcb11033b9e12a2a50ed3105c7d25e2b539d0" +dependencies = [ + "annotate-snippets", + "by_address", + "data-url", + "derive_more", + "i-slint-common", + "icu_normalizer", + "icu_properties", + "image", + "indexmap", + "itertools 0.15.0", + "lyon_extra", + "lyon_path", + "num_enum", + "proc-macro2", + "quote", + "rayon", + "resvg", + "rowan", + "rspolib", + "skrifa", + "smol_str 0.3.7", + "strum", + "swash", + "typed-index-collections", + "unicode-segmentation", + "url", +] + +[[package]] +name = "i-slint-core" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2ea15059d70bc31b1a8b1eb4c34f2c5df04cb7e53f36179b43c0261231d646" +dependencies = [ + "accesskit", + "async-channel", + "bitflags 2.13.2", + "cfg-if", + "chrono", + "clru", + "const-field-offset", + "derive_more", + "euclid", + "i-slint-common", + "i-slint-core-macros", + "icu_normalizer", + "image", + "js-sys", + "ksni", + "lyon_algorithms", + "lyon_extra", + "lyon_geom", + "lyon_path", + "num-traits", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "once_cell", + "parley", + "pin-project", + "pin-weak", + "portable-atomic", + "raw-window-handle", + "resvg", + "rgb", + "scopeguard", + "skrifa", + "slab", + "strum", + "swash", + "sys-locale", + "taffy", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", + "vtable", + "wasm-bindgen", + "web-sys", + "web-time", + "wgpu", + "windows", +] + +[[package]] +name = "i-slint-core-macros" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f5a8a076de52f53be80caa826599ed2107b54c9d4a421e66dc06cf893a984c" +dependencies = [ + "quote", + "serde_json", + "syn 3.0.9", +] + +[[package]] +name = "i-slint-renderer-femtovg" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb0de504d57cdadf80c2abf7bcb8b7135a3716aee1f41b6ebb90ea93e3649d7c" +dependencies = [ + "cfg-if", + "const-field-offset", + "femtovg", + "glow", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "imgref", + "lyon_path", + "pin-weak", + "rgb", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "i-slint-renderer-skia" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc9c1cf02385f1d26fc1f423544e52390a8e5c2d89420a8042aa94b4fe109d1f" +dependencies = [ + "bytemuck", + "cfg-if", + "cfg_aliases", + "clru", + "const-field-offset", + "glow", + "glutin", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "lyon_path", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "pin-weak", + "raw-window-handle", + "read-fonts", + "scoped-tls-hkt", + "skia-safe", + "softbuffer", + "spin_on", + "unicode-segmentation", + "vtable", + "wgpu", + "wgpu-hal", + "windows", + "windows-core", + "write-fonts", +] + +[[package]] +name = "i-slint-renderer-software" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f8c641e138a2cc96537cac4481f5c7c8bb8dcc66d09acaed269329c8738628a" +dependencies = [ + "bytemuck", + "clru", + "derive_more", + "euclid", + "i-slint-common", + "i-slint-core", + "lyon_path", + "num-traits", + "skrifa", + "swash", + "zeno", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_decimal" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1150ad5e272740670464934ee76facda43ef2e11f85c27b7ba76ccb33edf2d05" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_decimal_data", + "icu_locale_core", + "icu_locale_fallback", + "icu_plurals", + "icu_provider", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_decimal_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5595ab78370d48abd24a062f3db115d5108dd0b65634352036cae532aebb0df8" + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c" + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_plurals" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef046647b445acf95ce0f8083fa292467d3df649959fa9a20f3f8abfd957c5d" +dependencies = [ + "fixed_decimal", + "icu_locale_fallback", + "icu_plurals_data", + "icu_provider", + "zerovec", +] + +[[package]] +name = "icu_plurals_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43ff7df2c1a1d41588a6ddcba354d513d4a3e10eec22c06fe56d13c12a848ea" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61b195dc605f81aa181bec9b3bd14e26cc71b903e4644f6af578ca9772c7a710" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913a7d294ad056474749152107309fdee3ec17d2482d1ab70fdc61a81c64b9a3" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + +[[package]] +name = "imgref" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "input" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9793345a65d71317763a33066b5d8351f8760dde8d4930fe9e39b5f14a7959d" +dependencies = [ + "bitflags 2.13.2", + "input-sys", + "libc", + "log", + "udev", +] + +[[package]] +name = "input-sys" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eee07d8e02bd95bf52b2e642cf13d33701b94c6e4b04fbf1d1fb07e9cb19e7" + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.2", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "ksni" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bcc1cb43797140f74b711f1c4a49f48a7a7429f211c5fb78264e7bcd1e99caf" +dependencies = [ + "arc-swap", + "async-executor", + "async-io", + "async-lock", + "crossbeam-queue", + "futures-channel", + "futures-lite", + "futures-util", + "pastey", + "serde", + "task-local", + "zbus", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.5", +] + +[[package]] +name = "libseat" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6656c69b6e0366ffb83faa232f3e4fda5fc91161722d140f2c95ce2e90b1ef31" +dependencies = [ + "errno", + "libseat-sys", + "log", +] + +[[package]] +name = "libseat-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a484fa48be5f62a8d3ffed767779d0ab808cfead91de98ef4362d469097dfb" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "lyon_algorithms" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a6b9d4e8b48acd531fbf1241ad9b270df6e60b6b92bdea1469f4f40899d805" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_extra" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7755f08423275157ad1680aaecc9ccb7e0cc633da3240fea2d1522935cc15c72" +dependencies = [ + "lyon_path", + "thiserror 2.0.21", +] + +[[package]] +name = "lyon_geom" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d82dd26aa4dc0b5b70b9115de59824842bcb43cf4a28e1b1e6d0ad148c93f93e" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + +[[package]] +name = "natord" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.3", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parlance" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e" + +[[package]] +name = "parley" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22d2ff88bd3f7d68d1d9b09c7e6209f9a8e8c05088295140a2bcf2e9b17038c5" +dependencies = [ + "accesskit", + "fontique", + "harfrust", + "hashbrown 0.17.1", + "icu_normalizer", + "icu_properties", + "icu_segmenter", + "linebender_resource_handle", + "parlance", + "parley_data", + "skrifa", +] + +[[package]] +name = "parley_data" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1567535334d6ba2d3cde19221ba9a7bd0fabb3cbd99046ddfb10ae061cfcc889" +dependencies = [ + "icu_properties", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" + +[[package]] +name = "pin-weak" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b330c9d1b92dfe68442ca20b009c717d5f0b1e3cf4965e62f704c3c6e95a1305" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.3", + "pin-project-lite", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +dependencies = [ + "critical-section", +] + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pulldown-cmark" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "278e9268c93b9e498a2da46c34d90ba93a3255eb99009646398f34bc73ab36eb" +dependencies = [ + "bitflags 2.13.2", + "memchr", + "unicase", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots 1.0.11", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "resvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" +dependencies = [ + "bytemuck", + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia 0.12.0", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "rig-agent" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "856f21ab95b8b887df997fb34356aeb1ad5677504461f009c58cd7d48274ef74" +dependencies = [ + "async-stream", + "futures", + "http", + "indexmap", + "rig-core", + "rig-derive", + "rig-memory", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.21", + "tracing", + "tracing-futures", +] + +[[package]] +name = "rig-core" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d07ae8e85415d83f620eb4f519d03126227178ac67971b08368c087ce8f9052" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bytes", + "fastrand", + "futures", + "glob", + "http", + "httpdate", + "indexmap", + "mime", + "mime_guess", + "ordered-float", + "rig-derive", + "rig-http", + "rig-reqwest", + "rig-tungstenite", + "schemars", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 2.0.21", + "tracing", + "url", + "uuid", + "web-time", +] + +[[package]] +name = "rig-derive" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840ef7200097f7e7b7f757f09a80b2ffb494438fca12ad6c84a15ddffc9647d3" +dependencies = [ + "convert_case 0.11.0", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "rig-http" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "817c761f9949151fd099ec78e2fbacf7174f24c066ced84c9d28807a6b9d4cee" +dependencies = [ + "bytes", + "futures", + "futures-timer", + "http", + "mime", + "thiserror 2.0.21", + "tokio", + "url", +] + +[[package]] +name = "rig-memory" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25fcf3f3a478d3ac1c0329aca68fdcb94c87cc32e6f2161463fae65270d58ec" +dependencies = [ + "base64 0.22.1", + "memchr", + "rig-core", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.21", +] + +[[package]] +name = "rig-reqwest" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9f13e51c8cbe597ab4ed79f4a1211dab83ac3d9ceaa3826d69b5c770274b9fa" +dependencies = [ + "bytes", + "futures", + "http", + "reqwest 0.13.5", + "rig-http", + "thiserror 2.0.21", + "tracing", + "url", + "webpki-root-certs", +] + +[[package]] +name = "rig-tungstenite" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba7906f696aee0da3d24825330bb5ff2cf2af86d8e308120f81d9cf581d1e30" +dependencies = [ + "futures", + "rig-http", + "thiserror 2.0.21", + "tokio", + "tokio-tungstenite", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmcp" +version = "3.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8be15815186873ea063bf005011d16c356804973489e40c39116ae3a2878d1f" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "indexmap", + "libc", + "pastey", + "pin-project-lite", + "rand 0.10.3", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.21", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b0748e054a5f5598b3213cf72099ae6b2745ba4d996c59ccf455150a7dbb839" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.9", +] + +[[package]] +name = "rowan" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b574c58582fa59fa43a2feb6608b8744184659f08a2e0117e4b8224d95ed61" +dependencies = [ + "countme", + "hashbrown 0.14.5", + "memoffset", + "rustc-hash 1.1.0", + "text-size", +] + +[[package]] +name = "roxmltree" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" +dependencies = [ + "memchr", +] + +[[package]] +name = "rspolib" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fda9a7796aff63a7b1b39ccc93fffaaf65e20042984b4843041a49ca4677535" +dependencies = [ + "lazy_static", + "natord", + "snafu", + "unicode-linebreak", + "unicode-width", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.9", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scoped-tls-hkt" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9603871ffe5df3ac39cb624790c296dbd47a400d202f56bf3e414045099524d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia 0.11.4", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash 0.2.0", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "skia-bindings" +version = "0.153.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb325787c91b3f6c34a7c6f2b8a2297b20292d733ca53a08c233e5028aec49af" +dependencies = [ + "bindgen", + "cc", + "flate2", + "heck", + "pkg-config", + "regex", + "serde_json", + "tar", + "toml", +] + +[[package]] +name = "skia-safe" +version = "0.153.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61cb643dac43067f3eb76c866635a74377ac0abca0ff2590674e1e295514c3d" +dependencies = [ + "bitflags 2.13.2", + "skia-bindings", + "windows", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slint" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e477d5ff6fec20909100ff8f7062432ebfbda7a62a0f37d0252aa126f38c83" +dependencies = [ + "const-field-offset", + "i-slint-backend-linuxkms", + "i-slint-backend-selector", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "i-slint-renderer-femtovg", + "i-slint-renderer-software", + "num-traits", + "once_cell", + "pin-weak", + "slint-macros", + "unicode-segmentation", + "vtable", +] + +[[package]] +name = "slint-build" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ea28d4e1ad47d829b91e227f17037fac07a8001668a93fee47ac04f88718ebc" +dependencies = [ + "derive_more", + "fontique", + "i-slint-compiler", + "spin_on", + "toml_edit", +] + +[[package]] +name = "slint-macros" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e74b13d20884dc480ce37d0fabbf02cb4bc0df2a595434feac96ca7c2a0f15" +dependencies = [ + "i-slint-compiler", + "proc-macro2", + "quote", + "spin_on", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "smol_str" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb7109d23f319619d4cc0de5464613c8b7b1a09db2774a58101459cf6be2f10" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "fastrand", + "js-sys", + "memmap2", + "ndk", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 1.1.6", + "tiny-xlib", + "tracing", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "spin_on" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076e103ed41b9864aa838287efe5f4e3a7a0362dd00671ae62a212e5e4612da2" +dependencies = [ + "pin-utils", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "taffy" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aea22054047c16c3f34d3ac473a2170be1424b1115b2a3adcf28cfb067c88859" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "task-local" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2972044a9e5e448a506a7ff6f0d03b566d8ef4cd6918a58fc59835a0f8666626" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path 0.11.4", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path 0.12.0", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +dependencies = [ + "as-raw-xcb-connection", + "ctor", + "libloading", + "pkg-config", + "tracing", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "futures", + "futures-task", + "pin-project", + "tracing", +] + +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.21", +] + +[[package]] +name = "typed-index-collections" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd" +dependencies = [ + "bincode", + "serde", +] + +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "udev" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f" +dependencies = [ + "io-lifetimes", + "libc", + "libudev-sys", + "pkg-config", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unlucky-agent" +version = "0.1.0" +dependencies = [ + "axum", + "futures-util", + "rig-agent", + "rig-core", + "rmcp", + "serde", + "serde_json", + "tokio", + "unlucky-core", +] + +[[package]] +name = "unlucky-app" +version = "0.1.0" +dependencies = [ + "slint", + "slint-build", + "unlucky-agent", + "unlucky-core", +] + +[[package]] +name = "unlucky-cli" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tokio", + "unlucky-agent", + "unlucky-core", +] + +[[package]] +name = "unlucky-core" +version = "0.1.0" +dependencies = [ + "flate2", + "futures-util", + "md-5", + "regex", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_yaml_ng", + "sha1", + "sha2", + "tar", + "tokio", + "zip", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "usvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" +dependencies = [ + "base64 0.23.1", + "data-url", + "flate2", + "fontdb", + "harfrust", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "simplecss", + "siphasher", + "skrifa", + "strict-num", + "svgtypes", + "tiny-skia-path 0.12.0", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtable" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "515a4b5b1399cc46fb0dc4081436cb40a3852a2d3e94fd1fa50bb68a334896eb" +dependencies = [ + "const-field-offset", + "portable-atomic", + "stable_deref_trait", + "vtable-macro", +] + +[[package]] +name = "vtable-macro" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8191d73ececf73fc94e65809bf0e667525a0bcff9aeeb8d042a4173a508332a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.6", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.6", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.6", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c8ddfbbf66e501434657400268cefe51b95140a0266236415cda7627732df7" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.11", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "log", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "gpu-allocator", + "hashbrown 0.17.1", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str 0.2.2", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wl-clipboard-rs" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "959c7ac3b8afde864f7a7448ac16f29b2b83350b7eccbf77fb781c3999aa6848" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.6", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "write-fonts" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cae90d1d9f6d1d36ef116b0b1e651a6d7c12ba925439d4d06ff9adc94662a3d8" +dependencies = [ + "font-types", + "indexmap", + "kurbo", + "log", + "read-fonts", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.6", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xattr" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08" +dependencies = [ + "libc", + "rustix 1.1.6", +] + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a974f48060a14e95705c01f24ad9c3345022f4d97441b8a36beb7ed5c4a02d" +dependencies = [ + "libc", + "memmap2", + "xkeysym", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.6", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zip" +version = "9.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3b434e4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,88 @@ +[workspace] +resolver = "2" +members = ["crates/core", "crates/agent", "crates/app", "crates/cli"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +authors = ["AskaEth "] +repository = "https://github.com/AskaEth/Unlucky-Launcher" +rust-version = "1.92" +publish = false + +[workspace.dependencies] +# 共享依赖(版本在此集中,各 crate 用 `xxx.workspace = true` 引用) +# M15(core::diagnose)首批使用者:规则正则 / 规则 YAML 解析 / 诊断结果序列化。 +regex = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# serde_yaml 上游已停维护(RUSTSEC-2024-0320),改用维护中的 API 兼容分支(tower 定案) +serde_yaml_ng = "0.10" + +# M13(core::download):HTTP + 异步运行时 + 流 + 校验哈希。 +# rustls(ring)而非 native-tls —— 免 OpenSSL/系统证书栈依赖,MinGW 与 Win7 都省事。 +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } +tokio = { version = "1", features = [ + "rt-multi-thread", + "fs", + "io-util", + "time", + "sync", + "net", + "macros", +] } +futures-util = "0.3" +sha1 = "0.10" +sha2 = "0.10" +# M31(core::auth):离线账户 UUID 必须与 Minecraft 服务端/HMCL 的 +# `UUID.nameUUIDFromBytes` 逐字节一致 = MD5 + v3 位改写,因此需要 MD5。 +# 与 sha1/sha2 同属 RustCrypto(Apache-2.0 OR MIT),版本对齐 digest 0.10。 +md-5 = "0.10" + +# M29(core::launch::natives):读 natives jar(zip 容器)解压原生库。 +# 只开 inflate(`deflate-flate2-zlib-rs`,纯 Rust zlib)——解压方向不需要 zopfli 压缩器。 +zip = { version = "9", default-features = false, features = ["deflate-flate2-zlib-rs"] } + +# M33(core::game::java):Temurin 的 Linux/macOS 产物只发 `.tar.gz`(Windows 是 `.zip`, +# 复用上面的 zip)。flate2/tar 都是纯 Rust + MIT OR Apache-2.0,白名单内。 +flate2 = "1" +tar = "0.4" + +# UI 层唯一入口(D2:Slint,RF 2.0 轨道)。 +# 默认特性已含 backend-default + renderer-femtovg(GL)+ renderer-software(CPU 软渲染兜底, +# Win7 老机无可用 GPU 时的降级路径,见 DevDocs/PlanDoc/01c-native-gui-options.md §6)。 +slint = "1.18" +# app 的 build script 用(把 ui/main.slint 编译成 Rust 代码),版本必须与 slint 主 crate 同步。 +slint-build = "1.18" + +# agent 层依赖(rig / rmcp)按 D4 在 M16 引入,脚手架期不引入以免版本冲突。 +# 版本锁定策略见 DevDocs/PlanDoc/02-ai-agent-design.md §0:rig 锁 =0.45.x,rmcp 3.8.x。 +# +# M39 的 MCP server(02 §0「传输定论」):rmcp 官方 SDK(Apache-2.0),**锁精确版本**—— +# pre-1.0 且 ~6 天一个版、3.x 内已多次改 server API。只开需要的传输 feature: +# - `server`(default 带)+ `transport-io`(stdio,给 llama.cpp 内置 MCP client) +# - `transport-streamable-http-server`(主力:127.0.0.1 上的 Streamable HTTP) +# `macros` 一并留在 default 里(`#[tool]` 宏不用,但 default 拆开易漏 base64 等隐式依赖)。 +rmcp = { version = "=3.8.0", features = [ + "transport-io", + "transport-streamable-http-server", +] } +# rmcp 的 Streamable HTTP server 是个 tower Service,官方示例用 axum 挂监听(MIT)。 +# 只吃 `nest_service` + `serve`,不引入任何 TLS/模板/DB 生态。 +axum = "0.8" + +[workspace.lints.rust] +unsafe_code = "deny" +missing_docs = "warn" +rust_2018_idioms = { level = "warn", priority = -1 } + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } + +[profile.release] +opt-level = "z" +lto = "thin" +codegen-units = 1 +panic = "abort" +strip = "symbols" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..97bdc46 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 AskaEth (YeiJ) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..23e5380 --- /dev/null +++ b/NOTICE @@ -0,0 +1,30 @@ +Unlucky-Launcher (ULL) +Copyright 2026 AskaEth (YeiJ) + +本产品采用 Apache License 2.0(见 LICENSE)。 + +-------------------------------------------------------------------------- +第三方组件声明 +-------------------------------------------------------------------------- + +1. Slint(https://slint.dev) + + 本产品的 GUI 层使用 Slint。Slint 以 Royalty-Free 2.0 许可(LicenseRef-Slint-Royalty-free-2.0) + 使用,未修改 Slint 本体源码。 + + Slint Royalty-Free 2.0 的唯一义务:应用「关于」页显示官方 AboutSlint 部件, + 或在公开下载页放置「Made with Slint」徽章。本项目的落地位置见 crates/app 的关于页(P2 排期) + 与 GitHub Release 说明页;在此之前由本文件的声明承担告知义务。 + + Slint 的其余许可分支(GPL-3.0-only / Slint Software License 3.0)本项目不适用。 + +2. Rust crates 生态 + + cargo-deny 白名单见 deny.toml:Apache-2.0 / MIT / BSD-2-Clause / BSD-3-Clause / Zlib / BSL-1.0 / ISC / + CC0-1.0 / MIT-0 / Unlicense / Unicode-3.0 / CDLA-Permissive-2.0 / Apache-2.0 WITH LLVM-exception。 + GPL 系(含 AGPL、LGPL 静态链接、SSPL)依赖不得进入主干。 + +3. 参考实现致谢 + + 本项目设计参考自 HMCL(https://github.com/HMCL-dev/HMCL)与 PCL2 + (https://github.com/Meloong-Git/PCL),两者均为 GPL-3.0,特此致谢。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..937b23e --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# Unlucky Launcher (ULL) + +**English** · [简体中文](README.zh-CN.md) + +Unlucky Launcher (ULL) is a free, open-source Minecraft: Java Edition launcher written in Rust +(Apache-2.0, non-commercial), with a Slint + Metro UI. It embeds an AI agent client (chat / +configuration / crash diagnosis) and integrates with the broader agent tooling ecosystem via MCP. +Minimum baseline: Windows 10. + +- Runtime: a single native Rust binary — no JVM, no WebView, no external runtime +- UI: Slint (Royalty-Free 2.0; GL preferred, CPU software-rendering fallback so older machines + without a usable GPU still get the full UI) +- License: Apache-2.0 © AskaEth (YeiJ) + +## Compliance statement + +Authentication uses only official Microsoft/Mojang endpoints — Microsoft OAuth 2.0 device code +flow, Xbox Live, XSTS, and api.minecraftservices.com — so players sign in with their own Microsoft +account and launch the game they already own. Account tokens are stored only on the user's local +machine and are never sent to any server operated by the project. ULL does not distribute, mirror, +proxy or modify any Minecraft game files. Online play always requires a Microsoft account with +valid entitlements. Local offline profiles never contact Microsoft or Mojang services and cannot +join online-mode servers. Not affiliated with Mojang or Microsoft. + +## Status + +The core engine is complete (workspace: 983+ tests passing): + +- **Microsoft sign-in**: OAuth 2.0 device code flow → Xbox Live → XSTS → Minecraft Services, with + entitlement verification +- **Account credentials are stored only on the local machine** — never uploaded, and inaccessible + to the AI features +- Mod loaders: Forge / NeoForge / Fabric / Quilt, verified on real machines +- Java management: Temurin / Oracle / Microsoft / Zulu with a regional fallback chain +- Multi-source download engine; YAML rule-based crash diagnosis +- Usable as an MCP server for external agent tooling (`ull agent serve`) +- Local offline profiles: for local / isolated-network testing only; they perform no Microsoft + authentication and cannot join servers with online-mode enabled + +**CLI (`ull`) is fully usable**: `install / launch / accounts / java / diagnose / agent`. + +**GUI in development**: `crates/app` is currently a Slint + Metro skeleton; the theme system and +the full interface ship with subsequent versions — use the CLI until then. + +## Module map + +``` +Unlucky-Launcher (cargo workspace) +├── crates/core # domain library, zero UI dependencies, eight modules +├── crates/agent # AI agent: ToolSpec registry + built-in client + MCP server (one shared core) +├── crates/app # Slint UI: the only crate allowed to depend on a UI framework +├── crates/cli # CLI (bin `ull`): headless, zero UI, depends on core + agent +└── packaging # single-file artifact matrix and packaging assets (planned) +``` + +The eight modules of `crates/core`: + +| Module | Responsibility | +|--------|----------------| +| `setting` | three data-directory modes, schema-based config, atomic writes, migrations | +| `download` | multi-source health scoring, mirrors, chunked downloads, hash-while-downloading | +| `game` | version / instance repository, snapshots and inheritance resolution | +| `launch` | 10-stage launch pipeline, processes, JSONL event stream | +| `auth` | Microsoft sign-in / local offline profiles | +| `addon` | mod loader installation, modpacks, CurseForge / Modrinth | +| `diagnose` | five signal collectors, YAML rule engine, hs_err parsing | +| `platform` | OS abstraction: paths, processes, file locks, capability probing, runtime OS detection | + +## Building + +Prerequisites: + +- Rust stable (`rust-toolchain.toml` selects the toolchain automatically; components `rustfmt` / + `clippy`) +- Windows: MSVC toolchain (used by CI) or MinGW-w64 (`x86_64-pc-windows-gnu`) +- Linux: `libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev` + (Slint winit backend + femtovg build dependencies) + +```bash +cargo fmt --all --check # formatting +cargo clippy --workspace --all-targets -- -D warnings # lints (zero-warning gate) +cargo test --workspace # tests +cargo build --release # artifact: target/release/unlucky-launcher[.exe] +``` + +Software-rendering switch (machines without a usable GPU / with broken drivers): +`unlucky-launcher --software-renderer` (equivalent to `SLINT_BACKEND=winit-software`). + +Dependency license check (optional locally, required in CI): +`cargo deny check licenses bans sources`. + +## CI gates + +| Workflow | Content | +|----------|---------| +| `.github/workflows/ci.yml` | fmt + clippy + test + build, Windows / Linux matrix | +| `.github/workflows/guards.yml` | zero-UI guard for core, 400-line-per-file guard, cargo-deny license guard | + +## Platform baseline + +Minimum baseline: **Windows 10**. Windows 8.1 / 8 / 7 are not supported: + +- Windows 10/11 builds use the **standard toolchain** (MSVC / GNU) — no custom target specs, no + `-Zbuild-std`, no import-table allowlists +- Other platforms: macOS 12+ first class; Linux x64 with X11 preferred, Wayland as the long-term + direction; Android Termux experimental, not committed +- UI fallback: Slint software rendering (fully usable on machines without a GPU or with broken + drivers) +- Platform independence is expressed through **separate test matrices**, not separate artifact + pipelines; capability differences use feature gates with runtime probing + +## License and acknowledgements + +- This project: Apache License 2.0, see [LICENSE](LICENSE); third-party notices in [NOTICE](NOTICE) +- The GUI uses [Slint](https://slint.dev) (Royalty-Free 2.0 track). Per the RF 2.0 obligations an + `AboutSlint` widget will be placed on the about page (P2); until then NOTICE and this section + serve the attribution duty. + +Thanks to [HMCL](https://github.com/HMCL-dev/HMCL) and +[PCL2](https://github.com/Meloong-Git/PCL) (both GPL-3.0) — these two launchers have accompanied +the vast majority of Minecraft players for many years. ULL learned a great deal about design and +implementation from them, and this project is dedicated to those two pioneers. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..0d97afd --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,97 @@ +# Unlucky Launcher (ULL) + +[English](README.md) · **简体中文** + +Rust 原生单文件 Minecraft 启动器:**Slint + Metro 视觉**,内置 **AI Agent 客户端**(对话 / 改配置 / 崩溃诊断), +融合主流 Agent 工具(**MCP**),**最低基线 Win10+**。 + +- 运行时:纯 Rust 原生二进制——无 JVM、无 WebView、无外部运行时 +- UI:Slint(Royalty-Free 2.0;GL 优先,CPU 软渲染兜底,无可用 GPU 的老机也有完整 UI) +- 许可:Apache-2.0 © AskaEth (YeiJ) + +## 当前状态 + +**核心引擎已完成**(workspace 983+ 测试全绿): + +- **正版登录**:Microsoft OAuth 2.0 device code flow → Xbox Live → XSTS → Minecraft Services,校验游戏拥有状态 +- **账户凭据仅保存在本机**,不上传任何服务器,AI 功能无法访问 +- 加载器:Forge / NeoForge / Fabric / Quilt 四家真机验证 +- Java 管理:Temurin / Oracle / Microsoft / Zulu 四发行版回退链 +- 多源下载引擎、崩溃诊断规则引擎 +- 可作为 MCP server 接入外部 Agent 工具(`ull agent serve`) +- 本地离线档案:仅用于本地/隔离网络环境的测试,不经过 Microsoft 账户认证,无法连接启用正版验证的服务器 + +**CLI(`ull`)完整可用**:`install / launch / accounts / java / diagnose / agent` 等全量命令。 + +**图形界面(GUI)开发中**:`crates/app` 当前为 Slint + Metro 风格骨架,主题系统与完整界面 +随后续版本交付——在此之前请使用 CLI。 + +## 模块地图 + +``` +Unlucky-Launcher (cargo workspace) +├── crates/core # 领域库:零 UI 依赖,八模块 +├── crates/agent # AI Agent:ToolSpec 注册表 + 内置客户端 + MCP server(双形态共用一个内核) +├── crates/app # Slint UI:唯一允许依赖 UI 框架的 crate +├── crates/cli # CLI(bin `ull`):headless,零 UI,只依赖 core + agent +└── packaging # 单文件产物矩阵与打包资源(规划中) +``` + +`crates/core` 的八个模块: + +| 模块 | 职责 | +|------|------| +| `setting` | 三模式数据目录、schema 配置、原子写、迁移 | +| `download` | 多源健康度、镜像、分块下载、边下边算 | +| `game` | 版本/实例仓库、快照与继承解析 | +| `launch` | 10 阶段启动管线、进程、JSONL 事件流 | +| `auth` | Microsoft 正版登录 / 本地离线档案 | +| `addon` | 加载器安装、整合包、CurseForge / Modrinth | +| `diagnose` | 五类信号采集、YAML 规则引擎、hs_err 解析 | +| `platform` | OS 抽象:路径、进程、文件锁、能力探测、运行期 OS 检测 | + +## 构建 + +前置: + +- Rust stable(`rust-toolchain.toml` 会自动选择工具链;组件 `rustfmt` / `clippy`) +- Windows:MSVC 工具链(CI 用)或 MinGW-w64(`x86_64-pc-windows-gnu`) +- Linux:`libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev` + (Slint winit 后端 + femtovg 编译依赖) + +```bash +cargo fmt --all --check # 格式 +cargo clippy --workspace --all-targets -- -D warnings # 静态检查(零 warning 门禁) +cargo test --workspace # 测试 +cargo build --release # 产物:target/release/unlucky-launcher[.exe] +``` + +软渲染开关(无可用 GPU / 烂驱动的老机):`unlucky-launcher --software-renderer` +(等价于 `SLINT_BACKEND=winit-software`)。 + +依赖许可检查(本地可选,CI 必跑):`cargo deny check licenses bans sources`。 + +## CI 门禁 + +| Workflow | 内容 | +|----------|------| +| `.github/workflows/ci.yml` | fmt + clippy + test + build,Windows / Linux 矩阵 | +| `.github/workflows/guards.yml` | core 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可守门 | + +## 平台基线 + +最低基线 **Windows 10**。Windows 8.1 / 8 / 7 不在支持范围内: + +- Windows 10/11 走**标准工具链**产物(MSVC / GNU),无自定义 target spec、无 `-Zbuild-std`、无导入表白名单 +- 其他平台:macOS 12+ 一等;Linux x64 X11 优先、Wayland 为**大方向**;Android Termux 试验级不承诺 +- UI 兜底:Slint 软渲染(无 GPU / 烂驱动的老机可完整使用) +- 平台独立性体现在**独立测试矩阵**,而非独立产物链;能力差异用 feature gate + 运行期探测表达 + +## 许可证与致谢 + +- 本项目:Apache License 2.0,见 [LICENSE](LICENSE);第三方声明见 [NOTICE](NOTICE) +- GUI 使用 [Slint](https://slint.dev)(Royalty-Free 2.0 轨道)。按 RF 2.0 义务,关于页将放置 + `AboutSlint` 部件(P2),在此之前由 NOTICE 与本节承担告知义务 + +感谢 [HMCL](https://github.com/HMCL-dev/HMCL) 和 [PCL2](https://github.com/Meloong-Git/PCL)(均 GPL-3.0)这两个启动器陪伴了绝大多数 MC 玩家好多年。 +ULL 的设计与实现从它们身上学到了太多,谨以此项目向两位前辈致敬。 diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..aca5e9c --- /dev/null +++ b/clippy.toml @@ -0,0 +1,7 @@ +# 与 Cargo.toml 的 rust-version 同步;改 MSRV 时两处一起改。 +msrv = "1.92" + +# core 的模块边界很薄,参数多于 8 个基本说明该拆结构体了。 +too-many-arguments-threshold = 8 +# 领域类型(版本号、哈希、URL)普遍是短名,不做最小名长惩罚。 +min-ident-chars-threshold = 2 diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml new file mode 100644 index 0000000..6d75121 --- /dev/null +++ b/crates/agent/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "unlucky-agent" +description = "Unlucky-Launcher AI Agent:工具注册表 + 内置客户端 + MCP server(双形态共用一个内核)" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +publish.workspace = true + +# 依赖方向:agent 依赖 core 的公共 API,core 不知道 agent 的存在(AGENTS.md §2)。 +# 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现 +# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。 +[dependencies] +unlucky-core = { path = "../core", version = "0.1" } + +# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars), +# 工具结果 / 审计记录 / 事件流都用 serde derive。 +serde.workspace = true +serde_json.workspace = true + +# LLM Provider + agent 循环。**锁精确版本**:rig 12 周 5 个 minor、CHANGELOG 满是 breaking +# (DevDocs/PlanDoc/02-ai-agent-design.md §0 + research/02 §7 R1)。升级必须单独提交并跑回放回归。 +# 依赖面收口:整个 crate 只有 src/brain/rig.rs 允许 `use rig_*`。 +# feature `reqwest`:rig-core 0.45 的 default 只有 derive + rustls,HTTP 客户端要显式开;TLS 走 rustls +# (免 OpenSSL,对 Win7/MinGW 友好,见 research/02 §7 R3)。 +rig-core = { version = "=0.45.0", features = ["reqwest"] } +# TODO(M16-P1): 目前**声明但未使用**——对话循环/工具编排是自研的(要在中间插确认与审计)。 +# 保留是为了把版本按 02 §0 钉死;P1 接 rig 的 agent 运行时(工具服务句柄 / rig-cassette 回放 / +# rig-rmcp 让内置 agent 吃 MCP)时再真正消费。若 P1 决定彻底不用,删这一行即可。 +rig-agent = "=0.45.0" + +# 异步运行时(rig 的 HTTP/流式需要)与流工具(消费 rig 的 Stream)。 +tokio.workspace = true +futures-util.workspace = true + +# MCP server(M39):rmcp 3.8 投影 + stdio / Streamable HTTP 传输。 +# 依赖面收口纪律与 rig 相同——整个 crate 只有 src/mcp/ 允许 `use rmcp::`。 +rmcp.workspace = true +axum.workspace = true + +[lints] +workspace = true diff --git a/crates/agent/src/audit.rs b/crates/agent/src/audit.rs new file mode 100644 index 0000000..de0f7ec --- /dev/null +++ b/crates/agent/src/audit.rs @@ -0,0 +1,283 @@ +//! 审计日志:**每一次工具调用**都留痕(02 文档 §3.1 末条 / mission M16 任务 4)。 +//! +//! 落盘位置由装配处给(`/agent/audit/audit-<日期>.jsonl`,按天分文件, +//! 见 `PlanDoc/04-data-and-config.md` §2)。一行一条 JSON,只追加不修改。 +//! +//! 三条纪律: +//! 1. **参数先脱敏再落盘**([`crate::redact::redact_args`])——`set_config` 能改 AI 端点, +//! 参数里会有 api_key; +//! 2. 写失败**不打断**工具执行(审计是旁路),但要把失败打到 stderr 留痕; +//! 3. YOLO 与「是否经确认」分别记录(`yolo=true, confirmed=false` 与 +//! `yolo=false, confirmed=true` 是两件事,事故复盘的差别就在这)。 + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::clock::{date_of_unix_ms, format_unix_ms, SharedClock}; +use crate::redact::redact_args; + +/// 一次工具调用的结局(审计用)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditOutcome { + /// 成功返回。 + Ok, + /// 工具返回错误(含端口失败、未找到会话等)。 + ToolError, + /// 参数没过 schema 校验(模型给错了参数)。 + InvalidArgs, + /// 用户/策略拒绝。 + Denied, + /// 只发出了确认请求,尚未执行(编排层在把请求交给闸门之前先记一条, + /// 这样「AI 想改 X 但被拒」和「用户点了允许」在审计里是两条独立的证据)。 + AwaitingConfirmation, + /// 模型调了不存在的工具。 + UnknownTool, +} + +/// 一条审计记录。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AuditRecord { + /// RFC3339 UTC。 + pub ts: String, + /// 会话 id(agent 对话框的会话,不是游戏会话)。 + pub agent_session_id: String, + /// 工具名。 + pub tool: String, + /// 参数(已脱敏)。 + pub args: Value, + /// 结局。 + pub outcome: AuditOutcome, + /// 一句话结果摘要。 + pub summary: String, + /// 是否运行在 YOLO 模式。 + pub yolo: bool, + /// 是否经过用户确认(YOLO 下为 false——自动放行不是"确认")。 + pub confirmed: bool, + /// 工具执行耗时(毫秒)。 + pub duration_ms: u64, +} + +/// 审计出口。 +pub trait AuditSink: Send + Sync { + /// 记录一条(实现方不应阻塞工具执行链;失败自行吞掉并留痕)。 + fn record(&self, record: &AuditRecord); +} + +/// 按天滚动的 JSONL 审计(生产实现)。 +pub struct JsonlAudit { + dir: PathBuf, + clock: SharedClock, + guard: Mutex<()>, +} + +impl JsonlAudit { + /// `dir` 通常是 `/agent/audit`;目录按需创建。 + #[must_use] + pub fn new(dir: impl Into, clock: SharedClock) -> Self { + Self { + dir: dir.into(), + clock, + guard: Mutex::new(()), + } + } + + /// 给定时刻对应的文件路径(测试与 UI「查看审计」共用)。 + #[must_use] + pub fn path_for(&self, unix_ms: u64) -> PathBuf { + self.dir + .join(format!("audit-{}.jsonl", date_of_unix_ms(unix_ms))) + } + + /// 追加一行;失败返回 `std::io::Error`(由 [`AuditSink::record`] 吞掉并打日志)。 + fn append(&self, record: &AuditRecord) -> std::io::Result<()> { + let path = self.path_for(self.clock.now_unix_ms()); + let line = serde_json::to_string(record) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let _guard = self.guard.lock(); + let mut file = OpenOptions::new().create(true).append(true).open(&path)?; + file.write_all(line.as_bytes())?; + file.write_all(b"\n") + } +} + +impl AuditSink for JsonlAudit { + fn record(&self, record: &AuditRecord) { + if let Err(err) = self.append(record) { + // 审计是旁路:写不进去也不能让工具调用失败,但要留下痕迹。 + eprintln!("[unlucky-agent] 审计写入失败: {err}"); + } + } +} + +/// 内存审计(测试 + 「P0 不落盘」的降级路径)。 +#[derive(Debug, Default)] +pub struct MemoryAudit { + records: Mutex>, +} + +impl MemoryAudit { + /// 空审计。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 已记录条数。 + #[must_use] + pub fn len(&self) -> usize { + self.records.lock().map(|r| r.len()).unwrap_or(0) + } + + /// 是否一条都没有。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// 取快照。 + #[must_use] + pub fn records(&self) -> Vec { + self.records.lock().map(|r| r.clone()).unwrap_or_default() + } + + /// 按工具名筛出记录(测试断言用)。 + #[must_use] + pub fn records_for(&self, tool: &str) -> Vec { + self.records() + .into_iter() + .filter(|r| r.tool == tool) + .collect() + } +} + +impl AuditSink for MemoryAudit { + fn record(&self, record: &AuditRecord) { + if let Ok(mut records) = self.records.lock() { + records.push(record.clone()); + } + } +} + +/// 丢弃审计(不推荐,只给"连内存都不想留"的极端场景/基准测试)。 +#[derive(Debug, Default, Clone, Copy)] +pub struct NullAudit; + +impl AuditSink for NullAudit { + fn record(&self, _record: &AuditRecord) {} +} + +/// 组装一条记录时的"其余字段"(时间戳从时钟取,参数先脱敏)。 +#[derive(Debug, Clone, Copy)] +pub struct AuditDraft<'a> { + /// 工具名。 + pub tool: &'a str, + /// 原始参数(内部会脱敏)。 + pub args: &'a Value, + /// 结局。 + pub outcome: AuditOutcome, + /// 一句话结果摘要。 + pub summary: &'a str, + /// 是否 YOLO。 + pub yolo: bool, + /// 是否经用户确认。 + pub confirmed: bool, + /// 耗时(毫秒)。 + pub duration_ms: u64, +} + +/// 组装一条审计记录(时间戳从时钟取、参数脱敏,调用方不用自己拼)。 +#[must_use] +pub fn make_record( + clock: &dyn crate::clock::Clock, + agent_session_id: &str, + draft: AuditDraft<'_>, +) -> AuditRecord { + AuditRecord { + ts: format_unix_ms(clock.now_unix_ms()), + agent_session_id: agent_session_id.to_string(), + tool: draft.tool.to_string(), + args: redact_args(draft.args), + outcome: draft.outcome, + summary: draft.summary.to_string(), + yolo: draft.yolo, + confirmed: draft.confirmed, + duration_ms: draft.duration_ms, + } +} + +/// 便捷:给出数据根下的审计目录(`/agent/audit`)。 +#[must_use] +pub fn audit_dir(data_root: &Path) -> PathBuf { + data_root.join("agent").join("audit") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::clock::FixedClock; + use serde_json::json; + use std::sync::Arc; + + #[test] + fn record_redacts_args_before_store() { + let audit = MemoryAudit::new(); + let clock = FixedClock::new(1_793_304_753_123); + let args = json!({ "path": "ai.api_key", "value": "sk-x" }); + let rec = make_record( + &clock, + "s1", + AuditDraft { + tool: "set_config", + args: &args, + outcome: AuditOutcome::Ok, + summary: "ok", + yolo: false, + confirmed: true, + duration_ms: 3, + }, + ); + audit.record(&rec); + let stored = &audit.records()[0]; + assert_eq!(stored.args["value"], json!("***"), "敏感路径的值必须掩码"); + assert_eq!(stored.ts, "2026-10-29T20:12:33.123Z"); + } + + #[test] + fn jsonl_audit_writes_one_line_per_record_per_day() { + let dir = std::env::temp_dir().join(format!("unlucky-agent-audit-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123)); + let audit = JsonlAudit::new(&dir, Arc::clone(&clock)); + let args = json!({}); + let rec = make_record( + clock.as_ref(), + "s1", + AuditDraft { + tool: "get_config", + args: &args, + outcome: AuditOutcome::Ok, + summary: "ok", + yolo: false, + confirmed: false, + duration_ms: 1, + }, + ); + audit.record(&rec); + audit.record(&rec); + let path = dir.join("audit-2026-10-29.jsonl"); + let content = std::fs::read_to_string(&path).expect("审计文件应存在"); + assert_eq!(content.lines().count(), 2); + let first: AuditRecord = serde_json::from_str(content.lines().next().unwrap()).unwrap(); + assert_eq!(first.tool, "get_config"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/agent/src/brain/chat.rs b/crates/agent/src/brain/chat.rs new file mode 100644 index 0000000..d3f0cf5 --- /dev/null +++ b/crates/agent/src/brain/chat.rs @@ -0,0 +1,267 @@ +//! 对话协议类型 + [`ChatProvider`] trait(四态端点配置在 [`super::provider`])。 +//! +//! 这里**没有协议方言**:OpenAI/Ollama/Anthropic 的报文差异全部由实现方 +//! (阶段 B 的 [`super::rig`])吃掉。本模块只定义循环需要的三样东西: +//! 消息、请求/回复、流式增量回调。 +//! +//! 一次工具调用的完整往返: +//! `Assistant { tool_calls }` → 编排层执行 → `Tool { call_id, name, content }` → 再次补全。 + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::provider::{ProviderConfig, ProviderError}; +use crate::event::{AgentEvent, AgentEventSink}; +use crate::registry::ToolDefinition; + +/// 一条工具调用请求(模型产出)。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ToolCall { + /// 调用 id(OpenAI 方言的 `tool_call_id`;Ollama 原生没有 id,由适配层生成)。 + pub id: String, + /// 工具名。 + pub name: String, + /// 参数(JSON 对象)。 + pub arguments: Value, +} + +/// 对话消息。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "role", rename_all = "snake_case")] +pub enum ChatMessage { + /// 系统提示词(每轮由 [`super::BrainConfig::system_prompt`] 重新注入,不进历史)。 + System { + /// 提示词正文。 + content: String, + }, + /// 用户输入。 + User { + /// 正文。 + content: String, + }, + /// 模型回复(可能带工具调用)。 + Assistant { + /// 文本正文(工具调用轮可能为空)。 + text: String, + /// 工具调用。 + tool_calls: Vec, + }, + /// 工具结果回灌。 + Tool { + /// 对应调用 id。 + call_id: String, + /// 工具名(Ollama 原生靠 `tool_name` 配对,不是 id)。 + name: String, + /// 结果 JSON 字符串。 + content: String, + }, +} + +impl ChatMessage { + /// 便捷:系统消息。 + #[must_use] + pub fn system(content: impl Into) -> Self { + Self::System { + content: content.into(), + } + } + + /// 便捷:用户消息。 + #[must_use] + pub fn user(content: impl Into) -> Self { + Self::User { + content: content.into(), + } + } + + /// 便捷:工具结果消息。 + #[must_use] + pub fn tool( + call_id: impl Into, + name: impl Into, + content: impl Into, + ) -> Self { + Self::Tool { + call_id: call_id.into(), + name: name.into(), + content: content.into(), + } + } +} + +/// 一次补全请求。 +#[derive(Debug, Clone, PartialEq)] +pub struct ChatRequest { + /// 模型名。 + pub model: String, + /// 消息(system 在首位)。 + pub messages: Vec, + /// 工具定义(双投影的 rig 侧载荷)。 + pub tools: Vec, + /// 采样温度。 + pub temperature: Option, + /// token 上限。 + pub max_tokens: Option, +} + +/// token 用量(本地模型可能不报)。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Usage { + /// 输入 token。 + pub prompt_tokens: Option, + /// 输出 token。 + pub completion_tokens: Option, +} + +/// 一次补全结果。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ChatReply { + /// 文本正文。 + pub text: String, + /// 工具调用(非空 ⇒ 循环继续)。 + pub tool_calls: Vec, + /// 用量。 + pub usage: Option, +} + +impl ChatReply { + /// 纯文本回复。 + #[must_use] + pub fn text(text: impl Into) -> Self { + Self { + text: text.into(), + tool_calls: Vec::new(), + usage: None, + } + } + + /// 带工具调用的回复。 + #[must_use] + pub fn with_tool_calls(tool_calls: Vec) -> Self { + Self { + text: String::new(), + tool_calls, + usage: None, + } + } +} + +/// 流式增量接收者。实现方必须**非阻塞**。 +pub trait DeltaObserver: Send + Sync { + /// 收到一段文本增量。 + fn on_delta(&self, text: &str); +} + +/// 丢弃增量(不关心流式的调用方)。 +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopObserver; + +impl DeltaObserver for NoopObserver { + fn on_delta(&self, _text: &str) {} +} + +/// 把增量转成 [`AgentEvent::TextDelta`](P1 聊天面板逐字渲染就靠它)。 +#[derive(Clone)] +pub struct EventDeltaObserver { + sink: Arc, +} + +impl EventDeltaObserver { + /// 用事件流构造。 + #[must_use] + pub fn new(sink: Arc) -> Self { + Self { sink } + } +} + +impl DeltaObserver for EventDeltaObserver { + fn on_delta(&self, text: &str) { + self.sink.emit(AgentEvent::TextDelta { + text: text.to_string(), + }); + } +} + +/// 收集增量(测试断言流式拼接)。 +#[derive(Debug, Default)] +pub struct CollectingObserver { + chunks: std::sync::Mutex>, +} + +impl CollectingObserver { + /// 收完的内容。 + #[must_use] + pub fn collected(&self) -> String { + self.chunks.lock().map(|c| c.concat()).unwrap_or_default() + } +} + +impl DeltaObserver for CollectingObserver { + fn on_delta(&self, text: &str) { + if let Ok(mut chunks) = self.chunks.lock() { + chunks.push(text.to_string()); + } + } +} + +/// 补全未来的箱子类型。 +pub type ProviderFuture<'a> = + Pin> + Send + 'a>>; + +/// LLM 端点抽象:**这是 agent 唯一的异步边界**。 +/// +/// P0 实现 = 测试里的 mock;阶段 B 实现 = [`super::rig`] 的 rig 适配器。 +/// 换实现(genai / 自写循环)只需要满足本 trait,[`super::Brain`] 不动。 +pub trait ChatProvider: Send + Sync { + /// 当前配置(循环用它取 model/temperature,也用它判断是否启用)。 + fn config(&self) -> &ProviderConfig; + /// 一次补全。流式增量通过 `observer` 回吐。 + fn complete<'a>( + &'a self, + request: &'a ChatRequest, + observer: &'a dyn DeltaObserver, + ) -> ProviderFuture<'a>; +} + +/// 装配期用的 provider 工厂:按配置造实现(阶段 B 在这里接 rig,测试接 mock)。 +pub type ProviderFactory = + Arc Result, ProviderError> + Send + Sync>; + +#[cfg(test)] +mod tests { + use super::*; + use crate::event::MemoryEventSink; + + #[test] + fn collecting_observer_concatenates_chunks() { + let observer = CollectingObserver::default(); + observer.on_delta("正在"); + observer.on_delta("读取日志"); + assert_eq!(observer.collected(), "正在读取日志"); + } + + #[test] + fn event_observer_emits_text_delta() { + let sink = Arc::new(MemoryEventSink::new()); + let observer = EventDeltaObserver::new(sink.clone()); + observer.on_delta("嗨"); + assert_eq!(sink.streamed_text(), "嗨"); + } + + #[test] + fn message_helpers_round_trip_through_json() { + let messages = vec![ + ChatMessage::system("你是助手"), + ChatMessage::user("看看日志"), + ChatMessage::tool("call_1", "read_game_log", "{}"), + ]; + let json = serde_json::to_string(&messages).expect("可序列化"); + let back: Vec = serde_json::from_str(&json).expect("可反序列化"); + assert_eq!(back, messages); + assert!(json.contains("\"role\":\"tool\"")); + } +} diff --git a/crates/agent/src/brain/exec.rs b/crates/agent/src/brain/exec.rs new file mode 100644 index 0000000..2efbc59 --- /dev/null +++ b/crates/agent/src/brain/exec.rs @@ -0,0 +1,347 @@ +//! 工具执行编排:schema 校验 → 分级确认 → 执行 → 审计 → 事件。 +//! +//! **YOLO 只在这里**(mission M16 任务 3 的分工):工具自己不判断 policy, +//! 只声明「这次需要确认 + diff」;本模块按 [`ConfirmPolicy`] 决定问不问。 +//! +//! 一次工具调用最多两条审计记录: +//! 1. 需要确认时先记一条 `awaiting_confirmation`(「AI 想改 X」本身必须留痕); +//! 2. 结局记一条 `ok` / `denied` / `tool_error`(带 `yolo` 与 `confirmed` 标记)。 +//! +//! 这两条区分了三种在复盘里完全不同的事:用户点了拒绝、YOLO 自动放行、用户点了允许。 + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::audit::{make_record, AuditDraft, AuditOutcome, AuditSink}; +use crate::brain::chat::ToolCall; +use crate::clock::SharedClock; +use crate::confirm::{ConfirmDecision, ConfirmGate, ConfirmPolicy, ConfirmRequest, DenyGuidance}; +use crate::event::{AgentEvent, AgentEventSink}; +use crate::registry::validate::validate; +use crate::registry::{ToolCtx, ToolError, ToolRegistry}; + +/// 一次工具执行的结局(回灌给模型的内容 + 成败标记)。 +#[derive(Debug, Clone, PartialEq)] +pub struct ToolExecOutcome { + /// 回灌内容(结构化)。 + pub content: Value, + /// 是否成功(UI/事件用;模型看的是 `content`)。 + pub ok: bool, +} + +impl ToolExecOutcome { + /// 成功。 + #[must_use] + pub fn ok(content: Value) -> Self { + Self { content, ok: true } + } + + /// 失败(`code` 给模型看,帮助它改正后重试)。 + #[must_use] + pub fn error(code: &str, message: impl Into) -> Self { + Self { + content: json!({ "status": "error", "code": code, "message": message.into() }), + ok: false, + } + } + + /// 序列化成回灌给模型的字符串(工具消息的 `content`)。 + #[must_use] + pub fn to_model_content(&self) -> String { + serde_json::to_string(&self.content).unwrap_or_else(|_| { + json!({ "status": "error", "code": "internal", "message": "结果序列化失败" }) + .to_string() + }) + } +} + +/// 工具执行器(无状态,可跨轮复用)。 +pub struct ToolExecutor { + registry: Arc, + audit: Arc, + gate: Arc, + policy: ConfirmPolicy, + clock: SharedClock, + sink: Arc, + guidance: DenyGuidance, + confirm_seq: AtomicU64, +} + +impl ToolExecutor { + /// 装配(默认拒绝口径 = 内置对话:[`DenyGuidance::InApp`])。 + pub fn new( + registry: Arc, + audit: Arc, + gate: Arc, + policy: ConfirmPolicy, + clock: SharedClock, + sink: Arc, + ) -> Self { + Self { + registry, + audit, + gate, + policy, + clock, + sink, + guidance: DenyGuidance::InApp, + confirm_seq: AtomicU64::new(0), + } + } + + /// 换拒绝口径(外部 MCP 客户端用 [`DenyGuidance::External`]:没有弹窗可点)。 + /// + /// 只改**文案**:策略、闸门、审计标记一个字都不变——这正是「同一注册表、 + /// 同一安全策略、同一审计」在代码上的落点。 + #[must_use] + pub fn with_guidance(mut self, guidance: DenyGuidance) -> Self { + self.guidance = guidance; + self + } + + /// 执行一次工具调用(含确认与审计)。 + pub async fn execute( + &self, + agent_session_id: &str, + call: &ToolCall, + step: usize, + ) -> ToolExecOutcome { + let started_at = self.clock.now_unix_ms(); + self.sink.emit(AgentEvent::ToolCallStarted { + tool: call.name.clone(), + // 与审计同一套脱敏:事件(P1 会持久化)不能带出凭据明文。 + args: crate::redact::redact_args(&call.arguments), + step, + }); + + let Some(spec) = self.registry.get(&call.name).cloned() else { + let message = format!( + "没有名为 `{}` 的工具。可用工具:{}", + call.name, + self.registry.names().join("、") + ); + self.record( + agent_session_id, + &call.name, + &call.arguments, + AuditOutcome::UnknownTool, + &message, + false, + false, + started_at, + ); + self.finish_event(&call.name, false, &message, false); + return ToolExecOutcome::error("unknown_tool", message); + }; + + if let Err(errors) = validate(&spec.input_schema, &call.arguments) { + let message = format!("参数不合法:{}", errors.join(";")); + self.record( + agent_session_id, + &call.name, + &call.arguments, + AuditOutcome::InvalidArgs, + &message, + false, + false, + started_at, + ); + self.finish_event(&call.name, false, &message, false); + return ToolExecOutcome::error("invalid_args", message); + } + + let ctx = ToolCtx { + agent_session_id: agent_session_id.to_string(), + confirmed: false, + }; + let quoted = match (spec.handler)(&ctx, &call.arguments) { + Ok(output) => output, + Err(err) => return self.fail(agent_session_id, call, err, started_at), + }; + + let Some(confirmation) = quoted.confirmation.clone() else { + return self.succeed(agent_session_id, call, quoted, false, false, started_at); + }; + + // ── 需要确认:决定问不问(YOLO 只影响这里)── + let asked = self.policy.requires_confirmation(confirmation.sensitivity); + let yolo_auto = !asked && confirmation.sensitivity == crate::confirm::Sensitivity::High; + + if asked { + self.record( + agent_session_id, + &call.name, + &call.arguments, + AuditOutcome::AwaitingConfirmation, + &confirmation.summary, + false, + false, + started_at, + ); + let request = ConfirmRequest { + id: format!( + "confirm-{}", + self.confirm_seq.fetch_add(1, Ordering::Relaxed) + 1 + ), + tool: call.name.clone(), + summary: confirmation.summary.clone(), + details: confirmation.details.clone(), + sensitivity: confirmation.sensitivity, + }; + self.sink.emit(AgentEvent::ConfirmRequested { + request: Box::new(request.clone()), + }); + if self.gate.request(request).await == ConfirmDecision::Denied { + let message = self.guidance.message().to_string(); + self.record( + agent_session_id, + &call.name, + &call.arguments, + AuditOutcome::Denied, + &message, + false, + false, + started_at, + ); + self.sink.emit(AgentEvent::ToolDenied { + tool: call.name.clone(), + reason: message.clone(), + }); + self.finish_event(&call.name, false, &message, true); + return ToolExecOutcome::error("denied", message); + } + } + + // ── 已获授权(用户允许或 YOLO):带 confirmed 重放 ── + let confirmed_ctx = ToolCtx { + agent_session_id: agent_session_id.to_string(), + confirmed: true, + }; + match (spec.handler)(&confirmed_ctx, &call.arguments) { + Ok(output) => { + if let Some(second) = output.confirmation { + // 工具在"已确认"状态下仍要确认 = 实现写错了,明确报出来而不是死循环。 + let message = format!( + "工具 `{}` 在已确认状态下仍请求确认:{}", + call.name, second.summary + ); + self.record( + agent_session_id, + &call.name, + &call.arguments, + AuditOutcome::ToolError, + &message, + yolo_auto, + asked, + started_at, + ); + self.finish_event(&call.name, false, &message, true); + return ToolExecOutcome::error("internal", message); + } + self.succeed(agent_session_id, call, output, asked, yolo_auto, started_at) + } + Err(err) => { + self.fail_after_confirm(agent_session_id, call, err, asked, yolo_auto, started_at) + } + } + } + + fn succeed( + &self, + agent_session_id: &str, + call: &ToolCall, + output: crate::registry::ToolOutput, + confirmed: bool, + yolo: bool, + started_at: u64, + ) -> ToolExecOutcome { + self.record( + agent_session_id, + &call.name, + &call.arguments, + AuditOutcome::Ok, + &output.summary, + yolo, + confirmed, + started_at, + ); + self.finish_event( + &call.name, + true, + &output.summary, + output.confirmation.is_some(), + ); + ToolExecOutcome::ok(output.content) + } + + fn fail( + &self, + agent_session_id: &str, + call: &ToolCall, + err: ToolError, + started_at: u64, + ) -> ToolExecOutcome { + self.fail_after_confirm(agent_session_id, call, err, false, false, started_at) + } + + fn fail_after_confirm( + &self, + agent_session_id: &str, + call: &ToolCall, + err: ToolError, + confirmed: bool, + yolo: bool, + started_at: u64, + ) -> ToolExecOutcome { + let message = err.message().to_string(); + self.record( + agent_session_id, + &call.name, + &call.arguments, + AuditOutcome::ToolError, + &message, + yolo, + confirmed, + started_at, + ); + self.finish_event(&call.name, false, &message, false); + ToolExecOutcome::error(err.code(), message) + } + + #[allow(clippy::too_many_arguments)] + fn record( + &self, + agent_session_id: &str, + tool: &str, + args: &Value, + outcome: AuditOutcome, + summary: &str, + yolo: bool, + confirmed: bool, + started_at: u64, + ) { + let duration_ms = self.clock.now_unix_ms().saturating_sub(started_at); + let draft = AuditDraft { + tool, + args, + outcome, + summary, + yolo, + confirmed, + duration_ms, + }; + self.audit + .record(&make_record(self.clock.as_ref(), agent_session_id, draft)); + } + + fn finish_event(&self, tool: &str, ok: bool, summary: &str, requires_confirmation: bool) { + self.sink.emit(AgentEvent::ToolCallFinished { + tool: tool.to_string(), + ok, + summary: summary.to_string(), + requires_confirmation, + }); + } +} diff --git a/crates/agent/src/brain/mod.rs b/crates/agent/src/brain/mod.rs new file mode 100644 index 0000000..152fb18 --- /dev/null +++ b/crates/agent/src/brain/mod.rs @@ -0,0 +1,323 @@ +//! Agent 大脑:Provider 抽象 + 对话循环 + 流式事件。 +//! +//! 选型(M10 定稿):`rig-core` + `rig-agent`,**版本锁 `=0.45.x`**——上游 12 周 5 个 minor +//! 的 breaking 频率决定了必须锁死;依赖面只在本文件收口,其它模块不许直接碰 rig, +//! 以便随时换 genai / 自写循环逃生(`DevDocs/PlanDoc/02-ai-agent-design.md` §0)。 +//! +//! Provider 形态(02 §2):OpenAI 兼容(默认,含本地 llama.cpp `127.0.0.1:PORT/v1`)、 +//! Anthropic 原生、Ollama 原生、以及「禁用」——禁用时规则引擎仍可用。 +//! +//! 统一 trait(M16 落地):`stream_chat(messages, tools) -> Stream`,含 tool_call 事件。 +//! 本 crate 的落地形态是 [`chat::ChatProvider::complete`](`request + observer → ChatReply`): +//! rig 0.45 的流在 [`rig`] 适配层里展开成增量回调,循环侧只消费「文本增量 + 工具调用」两件事。 +//! **rig 依赖面收口在 [`rig`] 一个文件**,本文件不 `use rig_*`。 +//! +//! ## 对话循环(02 文档 §3.3) +//! +//! ```text +//! run_turn(user_input) +//! ├─ provider.complete(request) ← 流式增量经 EventDeltaObserver → UI +//! ├─ 有 tool_calls → ToolExecutor.execute(校验/确认/YOLO/审计)→ Tool 消息回灌 +//! └─ 没有 tool_calls → 结束,返回最终文本 +//! ``` +//! +//! 三条不变量: +//! 1. **system prompt 每轮重新注入**,不进历史(改提示词立刻生效,也不会被压缩逻辑吃掉); +//! 2. 工具结果**永远回灌**(包括失败),模型才有机会改正; +//! 3. 步数上限 [`DEFAULT_MAX_STEPS`] 兜底,防止模型在两个工具间打转烧 token。 + +pub mod chat; +pub mod exec; +pub mod provider; +pub mod rig; + +use std::fmt; +use std::sync::Arc; + +use crate::audit::AuditSink; +use crate::clock::SharedClock; +use crate::confirm::{ConfirmGate, ConfirmPolicy}; +use crate::event::{AgentEvent, AgentEventSink}; +use crate::registry::ToolRegistry; + +pub use chat::{ + ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, EventDeltaObserver, + NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage, +}; +pub use exec::{ToolExecOutcome, ToolExecutor}; +pub use provider::{ProviderConfig, ProviderError, ProviderKind}; + +/// 一轮对话最多几轮工具调用。 +pub const DEFAULT_MAX_STEPS: usize = 8; + +/// 内置系统提示词(P0 版本;P2 会按实例上下文按需注入)。 +#[must_use] +pub fn default_system_prompt() -> String { + [ + "你是 Unlucky-Launcher(ULL,Minecraft 启动器)的内置助手。", + "你能调用工具:读配置(get_config)、改配置(set_config)、列出游戏会话(list_game_sessions)、", + "读会话日志(read_game_log)、跑崩溃规则引擎(diagnose_crash)。", + "纪律:", + "1. 先查后改:改任何配置前先用 get_config 确认键名与现值。", + "2. 崩溃问题先看 diagnose_crash 的规则结论;规则未命中或需要更多信息时,才用 read_game_log", + " 取 stderr / exit / crash_found,不要拉全量日志。", + "3. 只能改 get_config 列出的可写键。敏感键会要求用户确认:被拒绝就换方案,不要重复尝试同一改动。", + "4. 用中文回答,给用户可以立即执行的下一步;不确定时直说不确定,不要编造规则或日志内容。", + ] + .join("\n") +} + +/// 循环参数。 +#[derive(Debug, Clone)] +pub struct BrainConfig { + /// 最多几轮工具调用。 + pub max_steps: usize, + /// 系统提示词。 + pub system_prompt: String, +} + +impl Default for BrainConfig { + fn default() -> Self { + Self { + max_steps: DEFAULT_MAX_STEPS, + system_prompt: default_system_prompt(), + } + } +} + +impl BrainConfig { + /// 自定义提示词(步数用默认)。 + #[must_use] + pub fn with_system_prompt(system_prompt: impl Into) -> Self { + Self { + max_steps: DEFAULT_MAX_STEPS, + system_prompt: system_prompt.into(), + } + } +} + +/// 循环的旁路依赖(审计/确认/时钟/事件)。 +pub struct BrainDeps { + /// 审计出口。 + pub audit: Arc, + /// 确认闸门(P0 = [`crate::confirm::NoUiGate`])。 + pub gate: Arc, + /// YOLO 等策略快照。 + pub policy: ConfirmPolicy, + /// 时间源。 + pub clock: SharedClock, + /// 事件出口。 + pub sink: Arc, +} + +impl BrainDeps { + /// 装配。 + pub fn new( + audit: Arc, + gate: Arc, + policy: ConfirmPolicy, + clock: SharedClock, + sink: Arc, + ) -> Self { + Self { + audit, + gate, + policy, + clock, + sink, + } + } +} + +/// 一轮对话的结局。 +#[derive(Debug, Clone, PartialEq)] +pub struct TurnOutcome { + /// 最终文本(可能是空的,例如撞上步数上限且模型最后一句只有工具调用)。 + pub text: String, + /// 用掉的轮次。 + pub steps: usize, + /// 工具调用总次数。 + pub tool_calls: usize, + /// 为什么停下来。 + pub stopped_reason: StopReason, +} + +/// 停止原因。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StopReason { + /// 模型给出了最终回答。 + Completed, + /// 撞上 [`BrainConfig::max_steps`]。 + StepLimit, +} + +/// 循环级错误。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BrainError { + /// AI 被禁用(规则引擎仍可用)。 + Disabled, + /// provider 报错。 + Provider(ProviderError), +} + +impl fmt::Display for BrainError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Disabled => f.write_str(DISABLED_MESSAGE), + Self::Provider(err) => write!(f, "LLM 调用失败:{err}"), + } + } +} + +impl std::error::Error for BrainError {} + +/// AI 被禁用时给用户的话(装配层直接展示,不用先构造 Brain)。 +pub const DISABLED_MESSAGE: &str = + "AI 已禁用:请在设置 → AI 中配置端点。规则引擎崩溃诊断不受影响。"; + +/// 对话循环。 +pub struct Brain { + provider: Arc, + registry: Arc, + executor: ToolExecutor, + config: BrainConfig, + sink: Arc, +} + +impl Brain { + /// 装配。 + pub fn new( + provider: Arc, + registry: Arc, + deps: BrainDeps, + config: BrainConfig, + ) -> Self { + let executor = ToolExecutor::new( + Arc::clone(®istry), + Arc::clone(&deps.audit), + Arc::clone(&deps.gate), + deps.policy, + Arc::clone(&deps.clock), + Arc::clone(&deps.sink), + ); + Self { + provider, + registry, + executor, + config, + sink: deps.sink, + } + } + + /// 当前 provider 配置(设置页/UI 展示用)。 + #[must_use] + pub fn provider_config(&self) -> &ProviderConfig { + self.provider.config() + } + + /// 跑一轮对话。`history` 由调用方持有(P1 落盘为 `agent/sessions/*.jsonl`)。 + /// + /// # Errors + /// AI 被禁用([`BrainError::Disabled`])或 provider 报错([`BrainError::Provider`])。 + pub async fn run_turn( + &self, + agent_session_id: &str, + history: &mut Vec, + user_input: &str, + ) -> Result { + if !self.provider.config().is_enabled() { + self.sink.emit(AgentEvent::Error { + message: DISABLED_MESSAGE.to_string(), + }); + return Err(BrainError::Disabled); + } + + self.sink.emit(AgentEvent::TurnStarted { + session_id: agent_session_id.to_string(), + user_text: user_input.to_string(), + }); + history.push(ChatMessage::user(user_input)); + + let observer = EventDeltaObserver::new(Arc::clone(&self.sink)); + let mut tool_calls = 0usize; + let mut last_text = String::new(); + let mut used_steps = 0usize; + + for step in 1..=self.config.max_steps { + used_steps = step; + let request = self.build_request(history); + let reply = match self.provider.complete(&request, &observer).await { + Ok(reply) => reply, + Err(err) => { + self.sink.emit(AgentEvent::Error { + message: err.to_string(), + }); + return Err(BrainError::Provider(err)); + } + }; + + if !reply.text.trim().is_empty() { + last_text = reply.text.clone(); + self.sink.emit(AgentEvent::AssistantText { + text: reply.text.clone(), + }); + } + + if reply.tool_calls.is_empty() { + history.push(ChatMessage::Assistant { + text: reply.text.clone(), + tool_calls: Vec::new(), + }); + self.sink.emit(AgentEvent::TurnFinished { + steps: step, + text: reply.text.clone(), + }); + return Ok(TurnOutcome { + text: reply.text, + steps: step, + tool_calls, + stopped_reason: StopReason::Completed, + }); + } + + history.push(ChatMessage::Assistant { + text: reply.text.clone(), + tool_calls: reply.tool_calls.clone(), + }); + for call in &reply.tool_calls { + let outcome = self.executor.execute(agent_session_id, call, step).await; + tool_calls += 1; + history.push(ChatMessage::tool( + call.id.clone(), + call.name.clone(), + outcome.to_model_content(), + )); + } + } + + self.sink.emit(AgentEvent::TurnFinished { + steps: used_steps, + text: last_text.clone(), + }); + Ok(TurnOutcome { + text: last_text, + steps: used_steps, + tool_calls, + stopped_reason: StopReason::StepLimit, + }) + } + + fn build_request(&self, history: &[ChatMessage]) -> ChatRequest { + let config = self.provider.config(); + let mut messages = Vec::with_capacity(history.len() + 1); + messages.push(ChatMessage::system(self.config.system_prompt.clone())); + messages.extend_from_slice(history); + ChatRequest { + model: config.model.clone(), + messages, + tools: self.registry.definitions(), + temperature: config.temperature, + max_tokens: config.max_tokens, + } + } +} diff --git a/crates/agent/src/brain/provider.rs b/crates/agent/src/brain/provider.rs new file mode 100644 index 0000000..804fa3d --- /dev/null +++ b/crates/agent/src/brain/provider.rs @@ -0,0 +1,340 @@ +//! Provider 四态端点配置(02 文档 §2)。 +//! +//! | 形态 | 端点 | 备注 | +//! |------|------|------| +//! | OpenAI 兼容(默认) | `base_url` + `api_key` + `model` | 覆盖 OpenAI/DeepSeek/Qwen/GLM/**本地 llama.cpp server**(`http://127.0.0.1:8080/v1`)/LM Studio | +//! | Anthropic 原生 | `api_key` + `model` | Claude 系列(没有可用的 Rust 单厂商 SDK,见 research/02 §3.6) | +//! | Ollama 原生 | `http://127.0.0.1:11434` | 本地另一种主流 | +//! | 禁用 | — | 完全关闭 agent,规则引擎(`diagnose_crash`)仍可用 | +//! +//! 本文件只放**配置与校验**;对话协议在 [`super::chat`]。这样 rig 的 pre-1.0 破坏面 +//! (research/02 §7 R1)被关在 [`super::rig`] 一个文件里,这里连 rig 的名字都不出现。 + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。 +pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1"; +/// Ollama 默认端点(research/02 §5.2)。 +pub const OLLAMA_DEFAULT_BASE_URL: &str = "http://127.0.0.1:11434"; +/// Anthropic 官方 API 端点。 +pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com"; + +/// Provider 四态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderKind { + /// OpenAI 兼容(含本地 llama.cpp / LM Studio / DeepSeek / Qwen…)。 + OpenAiCompatible, + /// Anthropic 原生。 + Anthropic, + /// Ollama 原生。 + Ollama, + /// 完全禁用 agent。 + Disabled, +} + +impl ProviderKind { + /// 该形态是否必须提供 API key(本地推理通常不需要)。 + #[must_use] + pub fn requires_api_key(self) -> bool { + matches!(self, Self::Anthropic) + } + + /// 该形态的默认端点(OpenAI 兼容没有统一默认值,必须显式填; + /// 本地 llama.cpp 走 [`ProviderConfig::llamacpp_local`] 带的显式默认)。 + #[must_use] + pub fn default_base_url(self) -> &'static str { + match self { + Self::OpenAiCompatible => "", + Self::Anthropic => ANTHROPIC_DEFAULT_BASE_URL, + Self::Ollama => OLLAMA_DEFAULT_BASE_URL, + Self::Disabled => "", + } + } + + /// 设置页展示名。 + #[must_use] + pub fn display_name(self) -> &'static str { + match self { + Self::OpenAiCompatible => "OpenAI 兼容", + Self::Anthropic => "Anthropic 原生", + Self::Ollama => "Ollama 原生", + Self::Disabled => "已禁用", + } + } +} + +/// Provider 配置(设置 → AI 的一份快照)。 +/// +/// `api_key` 只在进程内存在,**任何日志/审计/事件都不得带它**—— +/// `Debug` 是手写的(掩码 key),要连形态一起看用 [`ProviderConfig::redacted_debug`]。 +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct ProviderConfig { + /// 四态。 + pub kind: ProviderKind, + /// 端点;空串表示用 [`ProviderKind::default_base_url`]。 + pub base_url: String, + /// 模型名;`Disabled` 时可为空。 + pub model: String, + /// API key(本地推理可空)。 + pub api_key: Option, + /// 采样温度。 + pub temperature: Option, + /// 单次回复 token 上限。 + pub max_tokens: Option, +} + +impl Default for ProviderConfig { + fn default() -> Self { + Self::disabled() + } +} + +impl ProviderConfig { + fn base(kind: ProviderKind, base_url: &str, model: &str) -> Self { + Self { + kind, + base_url: base_url.to_string(), + model: model.to_string(), + api_key: None, + temperature: None, + max_tokens: None, + } + } + + /// OpenAI 兼容端点(云端或自建)。 + #[must_use] + pub fn openai_compatible(base_url: &str, model: &str) -> Self { + Self::base(ProviderKind::OpenAiCompatible, base_url, model) + } + + /// 本机 llama.cpp server(用户就在跑 llama-server 的默认场景)。 + #[must_use] + pub fn llamacpp_local(model: &str) -> Self { + Self::base( + ProviderKind::OpenAiCompatible, + LLAMACPP_DEFAULT_BASE_URL, + model, + ) + } + + /// Anthropic 原生。 + #[must_use] + pub fn anthropic(model: &str) -> Self { + Self::base(ProviderKind::Anthropic, ANTHROPIC_DEFAULT_BASE_URL, model) + } + + /// 本机 Ollama。 + #[must_use] + pub fn ollama(model: &str) -> Self { + Self::base(ProviderKind::Ollama, OLLAMA_DEFAULT_BASE_URL, model) + } + + /// 禁用 agent。 + #[must_use] + pub fn disabled() -> Self { + Self::base(ProviderKind::Disabled, "", "") + } + + /// 带上 API key。 + #[must_use] + pub fn with_api_key(mut self, api_key: impl Into) -> Self { + self.api_key = Some(api_key.into()); + self + } + + /// 带上温度。 + #[must_use] + pub fn with_temperature(mut self, temperature: f32) -> Self { + self.temperature = Some(temperature); + self + } + + /// agent 是否启用。 + #[must_use] + pub fn is_enabled(&self) -> bool { + self.kind != ProviderKind::Disabled + } + + /// 实际使用的端点(空则回落默认值)。 + #[must_use] + pub fn resolved_base_url(&self) -> &str { + if self.base_url.trim().is_empty() { + self.kind.default_base_url() + } else { + self.base_url.trim() + } + } + + /// 配置是否可用(装配期调用一次,错就进设置页提示而不是等到对话时炸)。 + /// + /// # Errors + /// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。 + pub fn validate(&self) -> Result<(), ProviderError> { + if !self.is_enabled() { + return Err(ProviderError::new( + "AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)", + )); + } + if self.model.trim().is_empty() { + return Err(ProviderError::new("没有配置模型名(model)")); + } + if self.resolved_base_url().trim().is_empty() { + return Err(ProviderError::new(format!( + "{} 需要填写端点(base_url)", + self.kind.display_name() + ))); + } + if self.kind.requires_api_key() + && self + .api_key + .as_deref() + .is_none_or(|key| key.trim().is_empty()) + { + return Err(ProviderError::new(format!( + "{} 需要 API key", + self.kind.display_name() + ))); + } + Ok(()) + } + + /// 可打印的形态描述(**key 已掩码**,给日志/审计/设置页用)。 + #[must_use] + pub fn redacted_debug(&self) -> String { + let key = match self.api_key.as_deref() { + None => "无".to_string(), + Some(k) if k.trim().is_empty() => "无".to_string(), + Some(_) => crate::redact::MASK.to_string(), + }; + format!( + "{} base_url={} model={} api_key={key}", + self.kind.display_name(), + self.resolved_base_url(), + if self.model.is_empty() { + "(未设置)" + } else { + &self.model + }, + ) + } +} + +impl fmt::Debug for ProviderConfig { + /// 手写 Debug:**key 掩码**(`api_key` 是唯一不能出现在日志里的字段)。 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ProviderConfig") + .field("kind", &self.kind) + .field("base_url", &self.resolved_base_url()) + .field("model", &self.model) + .field( + "api_key", + &self.api_key.as_deref().map(|_| crate::redact::MASK), + ) + .field("temperature", &self.temperature) + .field("max_tokens", &self.max_tokens) + .finish() + } +} + +/// Provider 层错误(消息面向用户:端点连不上、key 无效、模型不存在)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderError { + message: String, +} + +impl ProviderError { + /// 从可读文本构造。 + #[must_use] + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for ProviderError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ProviderError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_defaults_match_research() { + assert_eq!( + ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(), + LLAMACPP_DEFAULT_BASE_URL + ); + assert_eq!( + ProviderConfig::ollama("llama3").resolved_base_url(), + OLLAMA_DEFAULT_BASE_URL + ); + assert_eq!( + ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(), + ANTHROPIC_DEFAULT_BASE_URL + ); + } + + #[test] + fn validate_requires_model_base_url_and_key() { + assert!(!ProviderConfig::disabled().is_enabled()); + assert!(ProviderConfig::disabled().validate().is_err()); + assert!(ProviderConfig::llamacpp_local("").validate().is_err()); + assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok()); + // OpenAI 兼容必须显式给端点 + assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini") + .validate() + .is_err()); + assert!( + ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat") + .validate() + .is_ok() + ); + assert!(ProviderConfig::anthropic("claude").validate().is_err()); + assert!(ProviderConfig::anthropic("claude") + .with_api_key("sk-ant") + .validate() + .is_ok()); + } + + #[test] + fn redacted_debug_never_leaks_key() { + let config = + ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat") + .with_api_key("sk-super-secret"); + let text = config.redacted_debug(); + assert!(!text.contains("sk-super-secret"), "{text}"); + assert!(text.contains(crate::redact::MASK)); + // `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。 + let debug = format!("{config:?}"); + assert!(!debug.contains("sk-super-secret"), "{debug}"); + assert!(debug.contains(crate::redact::MASK), "{debug}"); + } + + #[test] + fn empty_base_url_falls_back_to_default() { + let mut config = ProviderConfig::ollama("llama3"); + config.base_url = String::new(); + assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL); + } + + #[test] + fn four_states_serialize_as_snake_case() { + assert_eq!( + serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(), + "\"open_ai_compatible\"" + ); + assert_eq!( + serde_json::to_string(&ProviderConfig::disabled()).unwrap(), + "{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}" + ); + } +} diff --git a/crates/agent/src/brain/rig.rs b/crates/agent/src/brain/rig.rs new file mode 100644 index 0000000..3043564 --- /dev/null +++ b/crates/agent/src/brain/rig.rs @@ -0,0 +1,368 @@ +//! rig 适配层:[`ChatProvider`] 的**唯一**实现,也是整个 crate 唯一允许 `use rig_*` 的文件。 +//! +//! 依赖面收窄纪律(`DevDocs/PlanDoc/02-ai-agent-design.md` §0 + research/02 §7 R1): +//! rig 是 pre-1.0、12 周 5 个 minor、CHANGELOG 满是 `[**breaking**]`(0.43 把 MCP 拆出 rig-agent、 +//! 0.45 升 rmcp 3.5 并改 owned connection)。所以把它关在这一个文件里:它变了,只有这里编译失败, +//! [`super::ChatProvider`] 以上的循环/编排/审计一行不用动。 +//! +//! ## 0.45 的接线(实读源码,非照文档猜) +//! +//! ```text +//! ProviderConfig ─┬─ OpenAiCompatible → OpenAIConfig::new(key).with_base_url(url).client().chat(model) +//! ├─ Anthropic → AnthropicConfig::new(key).with_base_url(url).client().completion(model) +//! ├─ Ollama → OllamaConfig::new().with_base_url(url).client().completion(model) +//! └─ Disabled → 不构造(Brain::run_turn 已提前返回) +//! ↓ Model.erase() +//! DynModel(wire + transport 擦除,本文件只持有这一个类型) +//! ``` +//! +//! 循环侧只要两件事:流式文本增量、以及折好的 [`ChatReply`](文本 + 工具调用)。 +//! 所以这里用 `model.stream(request)` 边转发 [`StreamEvent::Text`] 增量给 [`DeltaObserver`], +//! 最后 `Streamed::finish()` 拿到完整 [`CompletionResponse`]——工具调用从响应里取, +//! 不自己拼分片 JSON(拼分片是 rig 的 `ReplyFold` 已经做过的事)。 +//! +//! ## 三个已确认的坑 +//! +//! 1. **llama.cpp 默认端点就是 `http://localhost:8080/v1`**(`providers/llamacpp/mod.rs`), +//! 与 [`LLAMACPP_DEFAULT_BASE_URL`] 一致。这里统一走 OpenAI 方言 + 显式 base_url: +//! llama.cpp / LM Studio / DeepSeek 都是 OpenAI 兼容面,方言差异主要在默认端点与特有位 +//! (`chat_template_kwargs`、`num_ctx`)——真要下发这些,再换成 `providers::llamacpp` 方言或 +//! Ollama 的 `native_completion`。 +//! 2. **Ollama 原生方言的工具结果用 `tool_name` 配对(没有 id)**,OpenAI 方言用 `tool_call_id`。 +//! 这里走 rig 的 `Ollama::completion`(OpenAI 兼容 `/v1/chat/completions`),由 rig 负责回灌形状; +//! 要下发 `num_ctx`(崩溃日志几千行,上下文必须可配)才需要 `native_completion`,留作 P1。 +//! 3. **取消**:`Streamed` 是普通 future/stream,丢弃即断开——P0 用「丢弃 future」实现停止生成, +//! P1 接 UI 的「停止」按钮时按 rig 的取消语义(drop stream)对齐即可,不需要 unsafe。 +//! +//! ## 逃生口(保留但不是 Plan A) +//! +//! rig 的破坏变更真伤到(或 Rust 1.92 MSRV 追不上 rig 的 1.97.1)时:换 `genai` +//! (provider + function calling,无 agent 循环)或自己写 OpenAI 方言(约 300~500 行)。 +//! **代价只是实现一个新的 [`ChatProvider`]**——这正是把它关在一个文件里的意义。 +//! +//! [`LLAMACPP_DEFAULT_BASE_URL`]: super::provider::LLAMACPP_DEFAULT_BASE_URL +//! [`StreamEvent::Text`]: rig_core::streaming::StreamEvent::Text + +use std::sync::Arc; + +use futures_util::StreamExt; +use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition}; +use rig_core::error::ProviderError as RigError; +use rig_core::message::{ + AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId, + Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction, + ToolName as RigToolName, ToolResult, ToolResultContent, UserContent, +}; +use rig_core::operation::Completion; +use rig_core::providers::anthropic::AnthropicConfig; +use rig_core::providers::ollama::OllamaConfig; +use rig_core::providers::openai::OpenAIConfig; +use rig_core::streaming::{Item, StreamEvent}; +use rig_core::DynModel; + +use super::chat::{ + ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory, + ProviderFuture, +}; +use super::provider::{ProviderConfig, ProviderError, ProviderKind}; + +/// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。 +pub const RIG_VERSION_PIN: &str = "=0.45.0"; + +/// 擦掉 wire / transport 之后的补全模型——本文件对外只暴露这一个 rig 类型之外的东西。 +type ErasedModel = DynModel; + +/// rig 实现的 [`ChatProvider`]:四态配置 + 一个擦除后的模型。 +pub struct RigProvider { + config: ProviderConfig, + model: ErasedModel, +} + +impl RigProvider { + /// 按配置造 provider。`Disabled`、缺模型名、缺 key 都在这里被拒(fail-fast)。 + /// + /// # Errors + /// AI 被禁用、配置不完整、或 rig 端点构造失败时返回可读错误。 + pub fn new(config: ProviderConfig) -> Result { + config.validate()?; + let model = build_model(&config)?; + Ok(Self { config, model }) + } + + /// 装配层用的工厂:`ProviderFactory` 直接塞给 `crates/app`。 + #[must_use] + pub fn factory() -> ProviderFactory { + Arc::new(|config: &ProviderConfig| { + Ok(Arc::new(Self::new(config.clone())?) as Arc) + }) + } +} + +impl ChatProvider for RigProvider { + fn config(&self) -> &ProviderConfig { + &self.config + } + + fn complete<'a>( + &'a self, + request: &'a ChatRequest, + observer: &'a dyn DeltaObserver, + ) -> ProviderFuture<'a> { + Box::pin(async move { + let rig_request = build_request(request)?; + let mut stream = self.model.stream(rig_request).map_err(map_error)?; + + // 先转发增量,再拿折好的完整响应(工具调用只从响应取,不拼分片)。 + let mut text = String::new(); + while let Some(item) = stream.next().await { + match item.map_err(map_error)? { + Item::Event(StreamEvent::Text { text: fragment, .. }) => { + text.push_str(&fragment); + observer.on_delta(&fragment); + } + Item::Event(_) | Item::Unknown(_) => {} + } + } + let response = stream.finish().await.map_err(map_error)?; + to_reply(&response, text) + }) + } +} + +/// 四态 → rig 模型。 +fn build_model(config: &ProviderConfig) -> Result { + let model = config.model.trim().to_string(); + let base_url = config.resolved_base_url().to_string(); + let api_key = config.api_key.clone().unwrap_or_default(); + let erased = match config.kind { + ProviderKind::OpenAiCompatible => OpenAIConfig::new(api_key) + .with_base_url(base_url) + .client() + .chat(model) + .erase(), + ProviderKind::Anthropic => AnthropicConfig::new(api_key) + .with_base_url(base_url) + .client() + .completion(model) + .erase(), + ProviderKind::Ollama => OllamaConfig::new() + .with_base_url(base_url) + .client() + .completion(model) + .erase(), + ProviderKind::Disabled => { + return Err(ProviderError::new( + "AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)", + )) + } + }; + Ok(erased) +} + +/// 本方消息 → rig 补全请求。 +fn build_request(request: &ChatRequest) -> Result { + let mut history = to_rig_messages(&request.messages)?; + // rig 把历史最后一条当 prompt,其余是上下文;空历史没有可发的 prompt。 + let prompt = history + .pop() + .ok_or_else(|| ProviderError::new("对话历史为空:至少需要一条用户消息"))?; + let prompt = Arc::unwrap_or_clone(prompt); + let tools = request + .tools + .iter() + .map(to_rig_tool) + .collect::, _>>()?; + + Ok(CompletionRequest::new(prompt) + .messages(history) + .tools(tools) + .temperature(request.temperature.map(f64::from)) + .max_tokens(request.max_tokens.map(u64::from))) +} + +/// 本方工具定义 → rig 工具定义(入参都是裸 JSON Schema,这正是双投影的支点)。 +fn to_rig_tool(tool: &crate::registry::ToolDefinition) -> Result { + let name = tool_name(&tool.name)?; + Ok(RigToolDefinition::new( + name, + tool.description.clone(), + tool.input_schema.clone(), + )) +} + +/// 本方消息 → rig 消息。 +fn to_rig_messages(messages: &[ChatMessage]) -> Result>, ProviderError> { + let mut out = Vec::with_capacity(messages.len()); + for message in messages { + let rig = match message { + ChatMessage::System { content } => RigMessage::system(content.clone()), + ChatMessage::User { content } => RigMessage::user(content.clone()), + ChatMessage::Assistant { text, tool_calls } => { + let mut parts = Vec::with_capacity(tool_calls.len() + 1); + if !text.trim().is_empty() { + parts.push(RigAssistantContent::Text(RigText::new(text.clone()))); + } + for call in tool_calls { + parts.push(RigAssistantContent::ToolCall(RigToolCall::new( + CallId::from_wire(call.id.clone()), + ToolFunction::new(tool_name(&call.name)?, call.arguments.clone()), + ))); + } + RigMessage::Assistant(RigAssistantMessage::new(parts)) + } + ChatMessage::Tool { + call_id, + name, + content, + } => RigMessage::User { + content: vec![UserContent::ToolResult(ToolResult { + call: CallId::from_wire(call_id.clone()), + name: tool_name(name)?, + content: vec![ToolResultContent::text(content.clone())], + is_error: false, + })], + }, + }; + out.push(Arc::new(rig)); + } + Ok(out) +} + +/// rig 响应 → 本方回复(文本 + 工具调用 + 用量)。 +fn to_reply( + response: &rig_core::completion::CompletionResponse, + streamed: String, +) -> Result { + let mut text = streamed; + let mut tool_calls = Vec::new(); + for part in &response.choice { + match part { + RigAssistantContent::Text(chunk) => { + if text.is_empty() { + text.push_str(&chunk.text); + } + } + RigAssistantContent::ToolCall(call) => { + if let Some(raw) = call.function.invalid_arguments.as_deref() { + return Err(ProviderError::new(format!( + "模型返回的工具调用参数不是 JSON 对象(工具 `{}`):{raw}", + call.function.name.as_str() + ))); + } + tool_calls.push(super::chat::ToolCall { + id: call.id.to_string(), + name: call.function.name.as_str().to_string(), + arguments: serde_json::Value::Object(call.function.arguments.clone()), + }); + } + // 推理链/图片/未知块不进对话循环(P0 只消费文本 + 工具调用)。 + _ => {} + } + } + Ok(ChatReply { + text, + tool_calls, + usage: Some(super::chat::Usage { + prompt_tokens: response + .usage + .input_tokens + .and_then(|v| u32::try_from(v).ok()), + completion_tokens: response + .usage + .output_tokens + .and_then(|v| u32::try_from(v).ok()), + }), + }) +} + +/// rig 错误 → 本方错误(文案面向用户)。 +fn map_error(error: RigError) -> ProviderError { + ProviderError::new(error.to_string()) +} + +/// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。 +fn tool_name(name: &str) -> Result { + RigToolName::new(name) + .map_err(|_| ProviderError::new("工具名不能为空(ToolSpec 注册阶段应已拦截)")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tool(id: &str, name: &str) -> super::super::chat::ToolCall { + super::super::chat::ToolCall { + id: id.to_string(), + name: name.to_string(), + arguments: serde_json::json!({ "path": "appearance.theme_mode" }), + } + } + + #[test] + fn disabled_config_is_rejected_before_touching_the_network() { + let Err(err) = RigProvider::new(ProviderConfig::disabled()) else { + panic!("禁用态应在构造阶段被拒"); + }; + assert!(err.to_string().contains("禁用")); + } + + #[test] + fn anthropic_without_key_is_rejected() { + assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err()); + assert!(RigProvider::new( + ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant") + ) + .is_ok()); + } + + #[test] + fn local_states_build_offline() { + assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok()); + assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok()); + } + + #[test] + fn request_maps_messages_and_tools() { + let request = ChatRequest { + model: "mock".to_string(), + messages: vec![ + ChatMessage::system("你是助手"), + ChatMessage::user("把主题换成浅色"), + ChatMessage::Assistant { + text: String::new(), + tool_calls: vec![tool("call_1", "set_config")], + }, + ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"), + ], + tools: vec![crate::registry::ToolDefinition { + name: "set_config".to_string(), + description: "改配置".to_string(), + input_schema: serde_json::json!({ "type": "object" }), + }], + temperature: Some(0.5), + max_tokens: Some(512), + }; + let built = build_request(&request).expect("应能构造"); + assert_eq!(built.chat_history.len(), 4); + assert_eq!(built.tools.len(), 1); + assert_eq!(built.tools[0].name.as_str(), "set_config"); + assert_eq!(built.temperature, Some(0.5)); + assert_eq!(built.max_tokens, Some(512)); + // 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块 + let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化"); + assert!(json.contains("toolresult"), "{json}"); + } + + #[test] + fn empty_history_is_a_readable_error() { + let request = ChatRequest { + model: "m".to_string(), + messages: Vec::new(), + tools: Vec::new(), + temperature: None, + max_tokens: None, + }; + let err = build_request(&request).unwrap_err(); + assert!(err.to_string().contains("对话历史为空")); + } +} diff --git a/crates/agent/src/clock.rs b/crates/agent/src/clock.rs new file mode 100644 index 0000000..f24b042 --- /dev/null +++ b/crates/agent/src/clock.rs @@ -0,0 +1,139 @@ +//! 时间源:审计日志、事件流、工具耗时都要时间戳,但**测试不能依赖真实时钟** +//! (否则断言不稳定)。所以照既有做法注入 [`Clock`]。 +//! +//! 时间戳格式化自己实现([`format_unix_ms`]),不引 `chrono`:agent 的依赖面按 +//! `DevDocs/PlanDoc/02-ai-agent-design.md` §0 越窄越好,RFC3339 UTC 只需要一个 +//! 民用历换算(Howard Hinnant 的 `civil_from_days`)。 + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// 时间源。生产用 [`SystemClock`],测试用 [`FixedClock`]。 +pub trait Clock: Send + Sync { + /// 当前 Unix 毫秒时间戳。 + fn now_unix_ms(&self) -> u64; +} + +/// 真实系统时钟(单调性不保证,审计只要"大致有序")。 +#[derive(Debug, Default, Clone, Copy)] +pub struct SystemClock; + +impl Clock for SystemClock { + fn now_unix_ms(&self) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64) + } +} + +/// 可控时钟:测试里固定起点、必要时手动推进。 +#[derive(Debug)] +pub struct FixedClock { + ms: AtomicU64, + step_ms: u64, +} + +impl FixedClock { + /// 从固定时间戳开始,每次读走一步 `step_ms`(0 = 不自动推进)。 + #[must_use] + pub fn new(start_ms: u64) -> Self { + Self { + ms: AtomicU64::new(start_ms), + step_ms: 0, + } + } + + /// 每次读取自动前进 `step_ms`,用来断言工具耗时字段。 + #[must_use] + pub fn with_step(start_ms: u64, step_ms: u64) -> Self { + Self { + ms: AtomicU64::new(start_ms), + step_ms, + } + } + + /// 手动推进。 + pub fn advance(&self, ms: u64) { + self.ms.fetch_add(ms, Ordering::Relaxed); + } +} + +impl Clock for FixedClock { + fn now_unix_ms(&self) -> u64 { + if self.step_ms == 0 { + return self.ms.load(Ordering::Relaxed); + } + self.ms.fetch_add(self.step_ms, Ordering::Relaxed) + self.step_ms + } +} + +/// `Arc` 的简写。 +pub type SharedClock = Arc; + +/// Unix 毫秒 → `YYYY-MM-DDTHH:MM:SS.mmmZ`(UTC)。 +#[must_use] +pub fn format_unix_ms(ms: u64) -> String { + let secs = (ms / 1000) as i64; + let millis = ms % 1000; + let days = secs.div_euclid(86_400); + let sod = secs.rem_euclid(86_400); + let (y, m, d) = civil_from_days(days); + let (h, mi, s) = (sod / 3600, (sod % 3600) / 60, sod % 60); + format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{millis:03}Z") +} + +/// Unix 毫秒 → `YYYY-MM-DD`(UTC),审计落盘按天分文件用。 +#[must_use] +pub fn date_of_unix_ms(ms: u64) -> String { + let (y, m, d) = civil_from_days(((ms / 1000) as i64).div_euclid(86_400)); + format!("{y:04}-{m:02}-{d:02}") +} + +/// 1970-01-01 起的天数 → (年, 月, 日)。 +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_epoch_and_known_instants() { + assert_eq!(format_unix_ms(0), "1970-01-01T00:00:00.000Z"); + // 2026-10-29T20:12:33.123Z + assert_eq!( + format_unix_ms(1_793_304_753_123), + "2026-10-29T20:12:33.123Z" + ); + assert_eq!(date_of_unix_ms(1_793_304_753_123), "2026-10-29"); + } + + #[test] + fn handles_leap_day() { + // 2024-02-29T00:00:00Z + assert_eq!( + format_unix_ms(1_709_164_800_000), + "2024-02-29T00:00:00.000Z" + ); + } + + #[test] + fn fixed_clock_can_step() { + let clock = FixedClock::with_step(1_000, 5); + assert_eq!(clock.now_unix_ms(), 1_005); + assert_eq!(clock.now_unix_ms(), 1_010); + clock.advance(10); + assert_eq!(clock.now_unix_ms(), 1_025); + } +} diff --git a/crates/agent/src/confirm.rs b/crates/agent/src/confirm.rs new file mode 100644 index 0000000..8689c24 --- /dev/null +++ b/crates/agent/src/confirm.rs @@ -0,0 +1,235 @@ +//! 分级确认协议(02 文档 §3.1「分级确认」+ §4b「YOLO」)。 +//! +//! 分工: +//! - **工具**只声明「这次调用需要确认」并给出 diff([`ConfirmRequest`]),不自己做决定; +//! - **编排层**([`crate::brain::exec`])拿 [`ConfirmPolicy`] 决定是问人、还是走 YOLO; +//! - **UI** 在 P1 通过 [`ConfirmGate`] 接进来(P0 只有事件 + fail-closed 的默认实现)。 +//! +//! fail-closed 是刻意的:P0 没有确认弹窗,敏感键的写入**一律拒绝**而不是静默放行 +//! (research/02 §7 R9「写操作的破坏半径」)。 + +use std::collections::VecDeque; +use std::future::Future; +use std::pin::Pin; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// 敏感等级。**与 M12 `setting::spec::Sensitivity` 逐字一致** +/// (`DevDocs/SubAgentsTalk/setting-接口约定.md` §8): +/// +/// | 等级 | 语义 | +/// |------|------| +/// | `Low` | 静默生效 | +/// | `Medium` | 静默生效,审计里能查到(每次调用本来都审计,这里只是标记用途) | +/// | `High` | 必须用户确认(YOLO 可跳过确认,但**仍受白名单约束**) | +/// +/// 「AI 完全不能写」的键(`ai.api_key` 等凭据)由 M12 的 `writable=false` 表达, +/// 它们**不进白名单**,agent 侧走「不在白名单」的拒绝分支,不需要第四级。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Sensitivity { + /// 静默生效。 + #[default] + Low, + /// 静默生效但审计标记(下载源、语言、日志级别)。 + Medium, + /// 必须用户确认(数据目录、Java 路径、代理、AI 端点/YOLO 开关)。 + High, +} + +/// 一个待确认的操作:给用户看的措辞 + 结构化细节(UI 画 diff 用)。 +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ConfirmRequest { + /// 请求 id(编排层生成,UI 回填决定时原样带回)。 + pub id: String, + /// 工具名。 + pub tool: String, + /// 一句话请求(「AI 请求把 `instance.java.path` 改为 `D:\jdk17`」)。 + pub summary: String, + /// 结构化细节(`{"path":…, "old":…, "new":…}`)。 + pub details: Value, + /// 敏感等级。 + pub sensitivity: Sensitivity, +} + +/// 用户决定。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConfirmDecision { + /// 允许这一次。 + Approved, + /// 拒绝(工具调用返回「用户拒绝」给模型)。 + Denied, + /// 允许,且本会话后续不再询问(UI 的「全部允许」)。 + /// + /// 编排层当前**只当普通放行**处理(`brain/exec.rs` 不区分两者);「据此临时开 YOLO」 + /// 是 P1 的行为,落地前不要按已实现来看。 + ApprovedAll, +} + +impl ConfirmDecision { + /// 是否放行(`Approved` 与 `ApprovedAll` 都放行)。 + #[must_use] + pub fn is_approved(self) -> bool { + matches!(self, Self::Approved | Self::ApprovedAll) + } +} + +/// 确认闸门:P1 由 app 的 Slint 弹窗实现;测试用 [`ScriptedGate`]。 +pub trait ConfirmGate: Send + Sync { + /// 发起确认并等待决定(UI 未接时由实现方立即给出 fail-closed 结果)。 + fn request(&self, request: ConfirmRequest) -> ConfirmFuture; +} + +/// 确认未来的箱子类型。 +pub type ConfirmFuture = Pin + Send + 'static>>; + +/// 一律放行(YOLO 单测 / 未来"用户已信任该工具"场景)。 +#[derive(Debug, Default, Clone, Copy)] +pub struct AutoApproveGate; + +impl ConfirmGate for AutoApproveGate { + fn request(&self, _request: ConfirmRequest) -> ConfirmFuture { + Box::pin(async { ConfirmDecision::Approved }) + } +} + +/// 一律拒绝(fail-closed 的极简形态)。 +#[derive(Debug, Default, Clone, Copy)] +pub struct AutoDenyGate; + +impl ConfirmGate for AutoDenyGate { + fn request(&self, _request: ConfirmRequest) -> ConfirmFuture { + Box::pin(async { ConfirmDecision::Denied }) + } +} + +/// P0 默认闸门:**没有确认 UI** → 一律拒绝(fail-closed)。 +/// +/// 请求本身已经由编排层作为 [`crate::event::AgentEvent::ConfirmRequested`] 发出去了, +/// 所以 P0 的行为是「敏感操作既不静默执行,也不卡住对话」:事件流里留下了 +/// 「AI 想改 X」的完整记录,P1 接上弹窗后同一个事件直接变成可点的确认框。 +#[derive(Debug, Default, Clone, Copy)] +pub struct NoUiGate; + +impl ConfirmGate for NoUiGate { + fn request(&self, _request: ConfirmRequest) -> ConfirmFuture { + Box::pin(async { ConfirmDecision::Denied }) + } +} + +/// 脚本化闸门:按顺序返回预设决定,用完之后走 `default`(测试用)。 +pub struct ScriptedGate { + decisions: Mutex>, + default: ConfirmDecision, +} + +impl ScriptedGate { + /// 依次给出 `decisions`,耗尽后返回 `default`。 + #[must_use] + pub fn new( + decisions: impl IntoIterator, + default: ConfirmDecision, + ) -> Self { + Self { + decisions: Mutex::new(decisions.into_iter().collect()), + default, + } + } +} + +impl ConfirmGate for ScriptedGate { + fn request(&self, _request: ConfirmRequest) -> ConfirmFuture { + let decision = self + .decisions + .lock() + .ok() + .and_then(|mut q| q.pop_front()) + .unwrap_or(self.default); + Box::pin(async move { decision }) + } +} + +/// 确认策略(编排层用)。YOLO 的真实开关在设置里,这里只是当次运行的快照。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ConfirmPolicy { + /// 自动批准全部工具调用(设置 → AI → YOLO,默认关)。 + pub yolo: bool, +} + +/// 拒绝时的引导口径:**内置对话**有弹窗可点,**外部 MCP** 没有 UI——这条差别只在 +/// 「拒绝文案」上,策略本身完全相同(同一条 `ConfirmPolicy`、同一份审计)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DenyGuidance { + /// 内置对话:弹窗拒绝(或 P0 的 fail-closed)。 + InApp, + /// 外部 MCP 客户端:引导用户去设置页确认,或打开 YOLO。 + External, +} + +impl DenyGuidance { + /// 拒绝后回给模型/用户的一句话(区分「谁拒的」与「下一步怎么办」)。 + #[must_use] + pub fn message(self) -> &'static str { + match self { + Self::InApp => "用户拒绝了这次操作,未做任何修改", + Self::External => { + "这次操作需要用户确认,但当前是外部 MCP 客户端(没有确认弹窗),已按默认策略拒绝。\ + 请让用户在启动器设置页手动修改,或在设置 → AI 里打开 YOLO 模式后重试(审计照记)。" + } + } + } +} + +impl ConfirmPolicy { + /// 该等级是否需要**询问**用户:只有 `High` 会问;YOLO 下连 `High` 也不问 + /// (02 文档 §4b:YOLO 跳过分级确认,但**仍受白名单约束**——能改的键没变少)。 + #[must_use] + pub fn requires_confirmation(self, sensitivity: Sensitivity) -> bool { + !self.yolo && sensitivity == Sensitivity::High + } + + /// 该等级是否可以直接执行(不需要询问)。 + #[must_use] + pub fn auto_approves(self, sensitivity: Sensitivity) -> bool { + !self.requires_confirmation(sensitivity) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn low_and_medium_keys_never_ask() { + let policy = ConfirmPolicy { yolo: false }; + assert!(!policy.requires_confirmation(Sensitivity::Low)); + assert!(!policy.requires_confirmation(Sensitivity::Medium)); + assert!(policy.auto_approves(Sensitivity::Low)); + assert!(policy.auto_approves(Sensitivity::Medium)); + } + + #[test] + fn high_keys_ask_unless_yolo() { + assert!(ConfirmPolicy { yolo: false }.requires_confirmation(Sensitivity::High)); + assert!(!ConfirmPolicy { yolo: false }.auto_approves(Sensitivity::High)); + assert!(!ConfirmPolicy { yolo: true }.requires_confirmation(Sensitivity::High)); + assert!(ConfirmPolicy { yolo: true }.auto_approves(Sensitivity::High)); + } + + #[test] + fn sensitivity_matches_m12_levels() { + // 序列化名必须与 M12 `setting::spec::Sensitivity` 一致 + assert_eq!(serde_json::to_string(&Sensitivity::Low).unwrap(), "\"low\""); + assert_eq!( + serde_json::to_string(&Sensitivity::Medium).unwrap(), + "\"medium\"" + ); + assert_eq!( + serde_json::to_string(&Sensitivity::High).unwrap(), + "\"high\"" + ); + } +} diff --git a/crates/agent/src/event.rs b/crates/agent/src/event.rs new file mode 100644 index 0000000..20e75d8 --- /dev/null +++ b/crates/agent/src/event.rs @@ -0,0 +1,197 @@ +//! agent 事件流:**UI 与审计之外的观察者**都从这里接(02 文档 §1 的「event channel」)。 +//! +//! P0 只做输出([`AgentEventSink`]),P1 的 Slint 聊天面板订阅同一路事件把 +//! 「正在读取日志…」「请求修改设置:下载并发 64→16」画出来,确认弹窗直接复用 +//! [`AgentEvent::ConfirmRequested`]。 +//! +//! 实现方必须**非阻塞**:`emit` 在对话循环的同步路径上被调用。 + +use std::sync::mpsc::Sender; +use std::sync::Mutex; + +use serde::Serialize; +use serde_json::Value; + +use crate::confirm::ConfirmRequest; + +/// 一条 agent 事件(全部可序列化:P1 走 IPC 给 app 时不写两套类型)。 +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AgentEvent { + /// 一轮对话开始。 + TurnStarted { + /// agent 会话 id。 + session_id: String, + /// 用户输入。 + user_text: String, + }, + /// 流式文本增量(P1 聊天面板逐字渲染)。 + TextDelta { + /// 增量文本。 + text: String, + }, + /// 模型这一轮的非流式完整文本(流式之外的兜底,也是审计用的正文)。 + AssistantText { + /// 文本。 + text: String, + }, + /// 开始执行一个工具调用。 + ToolCallStarted { + /// 工具名。 + tool: String, + /// 模型给的参数。 + args: Value, + /// 第几轮工具循环(从 1 开始)。 + step: usize, + }, + /// 一个工具调用结束。 + ToolCallFinished { + /// 工具名。 + tool: String, + /// 是否成功。 + ok: bool, + /// 一句话结果摘要(UI 直接显示)。 + summary: String, + /// 这次是否触发了确认请求。 + requires_confirmation: bool, + }, + /// 需要用户确认(P0 只发事件,P1 变成可点的弹窗)。 + ConfirmRequested { + /// 待确认请求。 + request: Box, + }, + /// 工具调用被拒绝(策略或用户)。 + ToolDenied { + /// 工具名。 + tool: String, + /// 拒绝原因。 + reason: String, + }, + /// 一轮对话结束。 + TurnFinished { + /// 实际用掉的最大工具轮次。 + steps: usize, + /// 最终给用户看的文本。 + text: String, + }, + /// 循环级错误(provider 挂了、AI 被禁用…)。 + Error { + /// 人类可读原因。 + message: String, + }, +} + +/// 事件出口。 +pub trait AgentEventSink: Send + Sync { + /// 发一条事件。实现方不得阻塞。 + fn emit(&self, event: AgentEvent); +} + +/// 丢弃事件。 +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopSink; + +impl AgentEventSink for NoopSink { + fn emit(&self, _event: AgentEvent) {} +} + +/// 内存事件流(测试断言 + P0 的「暂不接 UI」)。 +#[derive(Debug, Default)] +pub struct MemoryEventSink { + events: Mutex>, +} + +impl MemoryEventSink { + /// 空 sink。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 取快照。 + #[must_use] + pub fn events(&self) -> Vec { + self.events.lock().map(|e| e.clone()).unwrap_or_default() + } + + /// 已发条数。 + #[must_use] + pub fn len(&self) -> usize { + self.events.lock().map(|e| e.len()).unwrap_or(0) + } + + /// 是否还没发过事件。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// 拼接所有文本增量(测试断言「流式内容拼起来等于完整文本」)。 + #[must_use] + pub fn streamed_text(&self) -> String { + self.events() + .into_iter() + .filter_map(|e| match e { + AgentEvent::TextDelta { text } => Some(text), + _ => None, + }) + .collect() + } +} + +impl AgentEventSink for MemoryEventSink { + fn emit(&self, event: AgentEvent) { + if let Ok(mut events) = self.events.lock() { + events.push(event); + } + } +} + +/// 标准库 channel 出口(P1 的 app 用一个线程收、喂给 Slint 的事件循环)。 +#[derive(Debug, Clone)] +pub struct MpscSink { + tx: Sender, +} + +impl MpscSink { + /// 包一个 `std::sync::mpsc::Sender`。 + #[must_use] + pub fn new(tx: Sender) -> Self { + Self { tx } + } +} + +impl AgentEventSink for MpscSink { + fn emit(&self, event: AgentEvent) { + // 接收端已关闭说明 UI 没了,事件直接丢弃(不能拖累对话)。 + let _ = self.tx.send(event); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn memory_sink_collects_and_concatenates_deltas() { + let sink = MemoryEventSink::new(); + sink.emit(AgentEvent::TextDelta { + text: "正在".into(), + }); + sink.emit(AgentEvent::TextDelta { + text: "读取日志".into(), + }); + assert_eq!(sink.streamed_text(), "正在读取日志"); + assert_eq!(sink.len(), 2); + } + + #[test] + fn mpsc_sink_forwards() { + let (tx, rx) = std::sync::mpsc::channel(); + let sink = MpscSink::new(tx); + sink.emit(AgentEvent::Error { + message: "boom".into(), + }); + assert!(matches!(rx.recv(), Ok(AgentEvent::Error { .. }))); + } +} diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs new file mode 100644 index 0000000..909f19b --- /dev/null +++ b/crates/agent/src/lib.rs @@ -0,0 +1,45 @@ +//! Unlucky-Launcher AI Agent(`unlucky_agent`)。 +//! +//! 形态定案(决策 D4):**内置 Agent 客户端 + 对外的 MCP server 双形态,共享同一个工具内核**。 +//! 设计定稿:`DevDocs/PlanDoc/02-ai-agent-design.md`。 +//! +//! 三条不能破的边界: +//! 1. 工具只定义一次([`registry`]),内置客户端与 MCP server 都把同一个 `ToolSpec` 投影出去; +//! 2. AI 是 Port 不是 Service——LLM 不可用时规则引擎仍能诊断; +//! 3. AI 永不接触账户凭据;写配置受 schema 白名单约束并留审计。 +//! +//! 与 core 的关系:agent 依赖 core 的公共 API,core 不知道 agent 的存在。 +//! 落地方式是把需要 core 的能力收成[`port`]里的五个 trait(配置读写 / 会话日志 / 崩溃诊断 / +//! 实例与 Java / 账户),由装配层用 core 的实现填充——这样 M12/M14/M15/M18/M31/M33 未合并时 +//! agent 也能独立编译、独立测试。装配处有两条:`crates/app`(GUI)与 `ull agent serve`(headless)。 +//! +//! 当前状态:工具内核 + 对话循环 + 审计(P0 定稿)+ **对外 MCP server**(M39)已落地。 +//! 两个外部依赖都收口在单文件:[`brain::rig`] 对 rig、[`mcp`] 对 rmcp(3.8.x)—— +//! 它们升 breaking 时只有那两个模块编译失败,上层的循环/编排/审计一行不用动。 +//! +//! 模块分工: +//! - [`registry`] 工具注册表(唯一真相源 + 双投影载荷); +//! - [`tools`] 工具 v1 实现(配置读写 / 会话日志 / 崩溃诊断); +//! - [`brain`] Provider 抽象 + 对话循环 + 工具编排; +//! - [`mcp`] 对外 MCP server(rmcp 双投影 + stdio / Streamable HTTP 传输); +//! - [`confirm`] 分级确认协议与 YOLO 策略; +//! - [`audit`] 每次工具调用的审计留痕; +//! - [`event`] 面向 UI 的事件流; +//! - [`port`] 与 core 的接触面(依赖倒置); +//! - [`redact`] 密钥/主目录脱敏; +//! - [`clock`] 可注入时间源(审计/事件用); +//! - [`testing`] 五个端口的 mock 实现(供本 crate、app 与 MCP 契约测试复用)。 + +#![forbid(unsafe_code)] + +pub mod audit; +pub mod brain; +pub mod clock; +pub mod confirm; +pub mod event; +pub mod mcp; +pub mod port; +pub mod redact; +pub mod registry; +pub mod testing; +pub mod tools; diff --git a/crates/agent/src/mcp/mod.rs b/crates/agent/src/mcp/mod.rs new file mode 100644 index 0000000..7687567 --- /dev/null +++ b/crates/agent/src/mcp/mod.rs @@ -0,0 +1,159 @@ +//! 对外 MCP server(02 文档 §1「双形态共享一个工具内核」的第二形态)。 +//! +//! 传输定论(§0):**Streamable HTTP on 127.0.0.1 是主力**(外部 agent 起 stdio 会变成 +//! 双实例抢数据目录锁),**stdio 形态保留**给 llama.cpp server 的内置 MCP client。 +//! +//! 与内置客户端的关系:两侧都只吃 [`crate::registry::ToolRegistry`] 的 `(name, description, +//! input_schema)`,并且**都必须走** [`crate::brain::exec::ToolExecutor`]——schema 校验、 +//! 分级确认、YOLO、审计一个字都不少。所以「用户在聊天里让 AI 改配置」与「Claude Code +//! 通过 MCP 改配置」是同一条路径(02 §1 的铁律)。 +//! +//! 安全边界(MCP 场景的差别只有一条): +//! - 敏感键(`Sensitivity::High`)默认**拒绝**而不是弹窗——外部客户端没有我们的 UI; +//! 拒绝文案引导用户去设置页确认,或在设置里打开 `ai.yolo`(YOLO 下照记 `yolo=true` 审计); +//! - 写工具默认不暴露(`agent.mcp.allow_write=false`):只读子集注册进注册表, +//! 调写工具会得到「没有这个工具 + 可用工具清单」(并留一条 `unknown_tool` 审计)。 +//! +//! 依赖面纪律:整个 crate 只有本模块允许 `use rmcp::`(与 `brain::rig` 对 rig 的规则同形)。 + +pub mod projection; +pub mod sample; +pub mod serve; +pub mod server; + +use std::fmt; + +pub use sample::{config_samples, ConfigSamples}; +pub use serve::{bind, serve_http, serve_http_on, serve_stdio}; +pub use server::RegistryServer; + +/// 一次 MCP server 运行的配置(由设置键 `agent.mcp.*` + 命令行覆盖合成)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpConfig { + /// 监听地址。**只允许回环地址**(默认 `127.0.0.1`):MCP 工具能改配置,不能对外开。 + pub host: String, + /// Streamable HTTP 端口(默认 47823,设置键 `agent.mcp.port`)。 + pub port: u16, + /// 访问令牌(`Authorization: Bearer `);`None` = 不校验(仅限回环监听时允许)。 + pub token: Option, + /// 是否只暴露只读工具(默认 **true**;`agent.mcp.allow_write=true` 才放开写侧)。 + pub read_only: bool, +} + +impl Default for McpConfig { + fn default() -> Self { + Self { + host: "127.0.0.1".to_string(), + port: 47823, + token: None, + read_only: true, + } + } +} + +impl McpConfig { + /// `host:port` 形态的监听地址(IPv6 字面量自动加方括号,`::1` 不会拼成 `::1:47823`)。 + #[must_use] + pub fn socket_addr(&self) -> String { + if self.host.contains(':') { + format!("[{}]:{}", self.host, self.port) + } else { + format!("{}:{}", self.host, self.port) + } + } + + /// MCP 端点 URL(客户端配置样例里用的那串)。 + #[must_use] + pub fn url(&self) -> String { + format!("http://{}/mcp", self.socket_addr()) + } + + /// 拒绝非回环监听——这是安全铁律的**代码化**,不是文档里的建议。 + /// + /// # Errors + /// `host` 不是回环地址时返回 [`McpServeError::NotLoopback`]。 + pub fn ensure_loopback(&self) -> Result<(), McpServeError> { + let host = self.host.trim(); + if host == "127.0.0.1" || host == "localhost" || host == "::1" { + return Ok(()); + } + Err(McpServeError::NotLoopback(host.to_string())) + } +} + +/// MCP server 启动/运行期错误(文案面向用户,可直接进 CLI 输出)。 +#[derive(Debug)] +pub enum McpServeError { + /// 监听地址不是回环地址。 + NotLoopback(String), + /// 端口被占用(最常见:另一个 `ull agent serve` 已经在跑)。 + PortInUse { + /// 监听地址(`127.0.0.1:47823`)。 + addr: String, + }, + /// 其他 IO 失败(权限、端口不足、传输中断)。 + Io(String), +} + +impl fmt::Display for McpServeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotLoopback(host) => write!( + f, + "拒绝在 `{host}` 上监听:MCP server 只允许回环地址(127.0.0.1 / localhost / ::1)" + ), + Self::PortInUse { addr } => write!( + f, + "端口已被占用:{addr}。可能另一个 `ull agent serve` 正在运行;\ + 用 `--port <端口>` 或设置键 `agent.mcp.port` 换一个" + ), + Self::Io(message) => write!(f, "MCP server 传输失败:{message}"), + } + } +} + +impl std::error::Error for McpServeError {} + +impl From for McpServeError { + fn from(error: std::io::Error) -> Self { + Self::Io(error.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_loopback_hosts_are_accepted() { + let mut cfg = McpConfig::default(); + assert!(cfg.ensure_loopback().is_ok()); + cfg.host = "localhost".to_string(); + assert!(cfg.ensure_loopback().is_ok()); + cfg.host = "::1".to_string(); + assert_eq!(cfg.url(), "http://[::1]:47823/mcp"); + assert!(cfg.ensure_loopback().is_ok()); + cfg.host = "0.0.0.0".to_string(); + let err = cfg.ensure_loopback().unwrap_err(); + assert!(matches!(err, McpServeError::NotLoopback(_))); + assert!(err.to_string().contains("回环地址")); + } + + #[test] + fn default_is_read_only_on_the_documented_port() { + let cfg = McpConfig::default(); + assert!(cfg.read_only); + assert_eq!(cfg.port, 47823); + assert_eq!(cfg.url(), "http://127.0.0.1:47823/mcp"); + } + + #[test] + fn port_in_use_message_is_actionable() { + let err = McpServeError::PortInUse { + addr: "127.0.0.1:47823".to_string(), + }; + let text = err.to_string(); + assert!(text.contains("--port"), "{text}"); + assert!(text.contains("agent.mcp.port"), "{text}"); + } +} diff --git a/crates/agent/src/mcp/projection.rs b/crates/agent/src/mcp/projection.rs new file mode 100644 index 0000000..ffb5ae5 --- /dev/null +++ b/crates/agent/src/mcp/projection.rs @@ -0,0 +1,106 @@ +//! 双投影的 **MCP 侧**:`ToolDefinition` → rmcp `Tool`(`ToolRoute::new_dyn` 的属性入参)。 +//! +//! rig 侧在 [`crate::brain::rig`](`ToolDefinition → RigToolDefinition`)。两侧都只吃裸 +//! `serde_json::Value` schema,所以「一致性」是可断言的:`tests/mcp_contract.rs` 遍历注册表, +//! 断言 MCP 侧的 `(name, description, input_schema)` 与注册表逐项相等——这就是 +//! research/02 §6.2 建议进 CI 的那条契约。 +//! +//! 额外一位信息:`annotations.read_only_hint`。MCP 客户端拿它决定「这个工具能不能 +//! 静默调用」,我们用它把 [`Access`] 语义透出去(只读提示,不是权限——权限在编排层)。 + +use std::sync::Arc; + +use rmcp::model::{JsonObject, Tool, ToolAnnotations}; +use serde_json::Value; + +use crate::registry::{Access, ToolDefinition, ToolRegistry}; + +/// 一个 `ToolDefinition` → rmcp `Tool`。 +/// +/// 注册表在注册阶段已保证 `input_schema` 根是 `object`,所以这里的兜底分支 +/// (非对象 → 空 schema)只是为了不 panic:真走到它说明注册校验被绕过了。 +#[must_use] +pub fn tool_attr(definition: &ToolDefinition, access: Access) -> Tool { + let schema: JsonObject = match &definition.input_schema { + Value::Object(map) => map.clone(), + _ => JsonObject::new(), + }; + let mut attr = Tool::new( + definition.name.clone(), + definition.description.clone(), + Arc::new(schema), + ); + let mut annotations = ToolAnnotations::default(); + annotations.read_only_hint = Some(access == Access::ReadOnly); + annotations.destructive_hint = Some(access == Access::Mutating); + attr.annotations = Some(annotations); + attr +} + +/// 注册表 → rmcp `Tool` 列表(顺序同注册顺序,便于两侧比对)。 +#[must_use] +pub fn tool_attrs(registry: &ToolRegistry) -> Vec { + registry + .specs() + .iter() + .map(|spec| tool_attr(&spec.definition(), spec.access)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::mock_ports; + use crate::tools::build_v1_registry; + use serde_json::json; + + fn registry() -> ToolRegistry { + let (ports, _settings) = mock_ports(); + build_v1_registry(&ports).expect("注册表应装配成功") + } + + #[test] + fn projection_keeps_name_description_and_schema() { + let registry = registry(); + let attrs = tool_attrs(®istry); + assert_eq!(attrs.len(), registry.len()); + + for (attr, spec) in attrs.iter().zip(registry.specs()) { + let definition = spec.definition(); + assert_eq!(attr.name.as_ref(), definition.name); + assert_eq!( + attr.description.as_deref(), + Some(definition.description.as_str()), + "{} 的说明必须逐字相等", + definition.name + ); + let projected = Value::Object((*attr.input_schema).clone()); + assert_eq!( + projected, definition.input_schema, + "{} 的 JSON Schema 必须逐字相等", + definition.name + ); + } + } + + #[test] + fn read_only_hint_matches_access() { + let registry = registry(); + for (attr, spec) in tool_attrs(®istry).iter().zip(registry.specs()) { + let hint = attr + .annotations + .as_ref() + .and_then(|a| a.read_only_hint) + .unwrap_or(false); + assert_eq!(hint, spec.access == Access::ReadOnly, "{}", attr.name); + } + } + + #[test] + fn schema_root_stays_an_object_on_the_wire() { + for attr in tool_attrs(®istry()) { + let value = Value::Object((*attr.input_schema).clone()); + assert_eq!(value["type"], json!("object"), "{}", attr.name); + } + } +} diff --git a/crates/agent/src/mcp/sample.rs b/crates/agent/src/mcp/sample.rs new file mode 100644 index 0000000..b533895 --- /dev/null +++ b/crates/agent/src/mcp/sample.rs @@ -0,0 +1,80 @@ +//! 对外客户端配置样例(用户把它们粘进 Claude Code / Cursor 就能连上)。 +//! +//! 为什么要生成而不是写在文档里:端口与令牌是**用户本机**的值,手抄必错。 +//! 两个客户端的形状不同(02 §0): +//! - **Claude Code**:`{"type":"http","url":…}`(`type` 必填,缺了会被当成 stdio) +//! - **Cursor**:`{"url":…}`(`mcp.json` 里只认 url;多写 type 会被忽略但不报错) +//! +//! 令牌只在设置里非空时出现——回环 + 无令牌是允许的(研究里“至少一个 token”是建议不是铁律, +//! 而我们用 `McpConfig::ensure_loopback` 硬性禁掉对外监听)。 + +use serde_json::{json, Value}; + +/// 客户端配置样例(三件套:端点 URL + 两份可直接粘的 JSON)。 +#[derive(Debug, Clone, PartialEq)] +pub struct ConfigSamples { + /// `http://127.0.0.1:/mcp`。 + pub url: String, + /// Claude Code(`claude mcp add` / `~/.claude.json` 的 `mcpServers` 片段)。 + pub claude_code: Value, + /// Cursor(`.cursor/mcp.json` 的 `mcpServers` 片段)。 + pub cursor: Value, +} + +/// 生成样例。`token` 非空时补 `headers.Authorization`。 +#[must_use] +pub fn config_samples(url: &str, token: Option<&str>) -> ConfigSamples { + let headers = token + .filter(|t| !t.trim().is_empty()) + .map(|t| json!({ "Authorization": format!("Bearer {t}") })); + + let mut claude_entry = json!({ "type": "http", "url": url }); + let mut cursor_entry = json!({ "url": url }); + if let Some(headers) = &headers { + claude_entry["headers"] = headers.clone(); + cursor_entry["headers"] = headers.clone(); + } + + ConfigSamples { + url: url.to_string(), + claude_code: json!({ "mcpServers": { "unlucky-launcher": claude_entry } }), + cursor: json!({ "mcpServers": { "unlucky-launcher": cursor_entry } }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claude_code_needs_type_http_and_cursor_does_not() { + let samples = config_samples("http://127.0.0.1:47823/mcp", None); + assert_eq!( + samples.claude_code["mcpServers"]["unlucky-launcher"]["type"], + json!("http") + ); + assert_eq!( + samples.claude_code["mcpServers"]["unlucky-launcher"]["url"], + json!("http://127.0.0.1:47823/mcp") + ); + assert!(samples.cursor["mcpServers"]["unlucky-launcher"] + .get("type") + .is_none()); + assert_eq!( + samples.cursor["mcpServers"]["unlucky-launcher"]["url"], + json!("http://127.0.0.1:47823/mcp") + ); + } + + #[test] + fn token_becomes_bearer_header_and_empty_token_is_skipped() { + let with = config_samples("http://127.0.0.1:1/mcp", Some("s3cret")); + let header = &with.cursor["mcpServers"]["unlucky-launcher"]["headers"]["Authorization"]; + assert_eq!(header, &json!("Bearer s3cret")); + + let without = config_samples("http://127.0.0.1:1/mcp", Some(" ")); + assert!(without.claude_code["mcpServers"]["unlucky-launcher"] + .get("headers") + .is_none()); + } +} diff --git a/crates/agent/src/mcp/serve.rs b/crates/agent/src/mcp/serve.rs new file mode 100644 index 0000000..e1ddcc5 --- /dev/null +++ b/crates/agent/src/mcp/serve.rs @@ -0,0 +1,144 @@ +//! 两种传输:stdio(llama.cpp 内置 MCP client 用)与 Streamable HTTP(主力)。 +//! +//! 传输定论(02 §0):**外部 MCP 主力是 127.0.0.1 上的 Streamable HTTP**, +//! 因为 stdio 形态要被外部 agent 拉起 = 同一份数据目录出现第二个实例, +//! 迟早撞上单写者锁(M18 的会话 jsonl、M12 的 launcher.json 都是单写者)。 +//! stdio 保留给**已经常驻的**本地模型服务(llama.cpp server 的 MCP client 由它自己拉子进程)。 +//! +//! 令牌:`Authorization: Bearer `。设置里为空 = 不校验(只允许回环监听, +//! `McpConfig::ensure_loopback` 已经拦掉对外监听)。research/02 §4.4 的原话是 +//! 「至少要有 headers 里的一枚随机 token」——我们做成可配、默认留空但样例里带。 + +use std::net::SocketAddr; + +use axum::extract::Request; +use axum::http::header::AUTHORIZATION; +use axum::http::StatusCode; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService}; +use rmcp::ServiceExt; +use tokio::net::TcpListener; + +use crate::mcp::server::RegistryServer; +use crate::mcp::{McpConfig, McpServeError}; + +/// stdio 形态:读 stdin 写 stdout,直到对端关闭。 +/// +/// 注意:**stdout 只能有 JSON-RPC**。任何日志都要走 stderr(否则客户端解析失败)。 +/// +/// # Errors +/// 传输中断或初始化失败时返回 [`McpServeError`]。 +pub async fn serve_stdio(server: RegistryServer) -> Result<(), McpServeError> { + let service = server + .serve(rmcp::transport::stdio()) + .await + .map_err(|error| McpServeError::Io(format!("stdio 初始化失败:{error}")))?; + service + .waiting() + .await + .map_err(|error| McpServeError::Io(format!("stdio 传输中断:{error}")))?; + Ok(()) +} + +/// 绑定 Streamable HTTP 监听(**只绑回环**),返回实际地址。 +/// +/// 拆出来是为了让调用方在进入 `axum::serve` 之前就能打印「监听在哪」—— +/// 端口被占用时也能拿到一条可操作的错误(tower 的提醒:撞端口要给出换端口的指引)。 +/// +/// # Errors +/// 非回环地址、端口被占用、或绑定失败时返回 [`McpServeError`]。 +pub async fn bind(cfg: &McpConfig) -> Result<(SocketAddr, TcpListener), McpServeError> { + cfg.ensure_loopback()?; + let addr = cfg.socket_addr(); + let listener = TcpListener::bind(&addr).await.map_err(|error| { + if error.kind() == std::io::ErrorKind::AddrInUse { + McpServeError::PortInUse { addr: addr.clone() } + } else { + McpServeError::Io(format!("绑定 {addr} 失败:{error}")) + } + })?; + let local = listener + .local_addr() + .map_err(|error| McpServeError::Io(error.to_string()))?; + Ok((local, listener)) +} + +/// Streamable HTTP 形态:`POST /mcp` 一条 JSON-RPC 请求 / 一条响应。 +/// +/// `json_response = true`:简单请求直接回 `application/json`(脚本与裸 JSON-RPC +/// 客户端最省事;需要服务端推送时 rmcp 会自动回落到 SSE)。 +/// +/// # Errors +/// 绑定失败(见 [`bind`])或服务运行期致命错误时返回 [`McpServeError`]。 +pub async fn serve_http(server: RegistryServer, cfg: McpConfig) -> Result<(), McpServeError> { + let (local, listener) = bind(&cfg).await?; + serve_http_on(server, cfg, local, listener).await +} + +/// 在已绑定的监听上跑(测试用临时端口,避免抢 47823)。 +/// +/// # Errors +/// 服务运行期致命错误时返回 [`McpServeError`]。 +pub async fn serve_http_on( + server: RegistryServer, + cfg: McpConfig, + local: SocketAddr, + listener: TcpListener, +) -> Result<(), McpServeError> { + let http_config = StreamableHttpServerConfig::default() + .with_json_response(true) + .with_legacy_session_mode(false); + let factory_server = server.clone(); + let service: StreamableHttpService = + StreamableHttpService::new( + move || Ok(factory_server.clone()), + std::sync::Arc::new(LocalSessionManager::default()), + http_config, + ); + + let token = cfg.token.clone(); + let app = axum::Router::new() + .nest_service("/mcp", service) + .layer(axum::middleware::from_fn( + move |request: Request, next: Next| { + let token = token.clone(); + async move { authorize(token, request, next).await } + }, + )); + + eprintln!( + "[unlucky-agent] MCP server 监听 http://{local}/mcp({} 个工具;令牌:{})", + server.tool_count(), + if cfg.token.is_some() { + "已启用" + } else { + "未设置(仅回环监听)" + } + ); + + axum::serve(listener, app) + .await + .map_err(|error| McpServeError::Io(error.to_string())) +} + +/// Bearer 令牌闸门:未配置 = 放行;配置了则严格比对。 +async fn authorize(token: Option, request: Request, next: Next) -> Response { + let Some(expected) = token else { + return next.run(request).await; + }; + let presented = request + .headers() + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + if presented == format!("Bearer {expected}") { + return next.run(request).await; + } + ( + StatusCode::UNAUTHORIZED, + "缺少或错误的 Authorization: Bearer ", + ) + .into_response() +} diff --git a/crates/agent/src/mcp/server.rs b/crates/agent/src/mcp/server.rs new file mode 100644 index 0000000..e0546f2 --- /dev/null +++ b/crates/agent/src/mcp/server.rs @@ -0,0 +1,243 @@ +//! rmcp `ServerHandler`:把同一份注册表挂成 MCP 工具路由。 +//! +//! 每次 `tools/call` 都走 [`ToolExecutor`]——schema 校验、确认闸门、YOLO、审计、 +//! 事件**与内置客户端完全同一条路径**(02 §1 铁律)。工具级失败返回 +//! `CallToolResult::error`(客户端能看到文案),协议级错误只留给「路由不到」的情况 +//! (research/02 §3.2:`Err(McpError)` 会被大多数客户端吞成 "internal error")。 + +use std::sync::Arc; + +use rmcp::handler::server::tool::{ToolCallContext, ToolRoute, ToolRouter}; +use rmcp::model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation, + ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool, +}; +use rmcp::service::RequestContext; +use rmcp::{ErrorData as McpError, RoleServer, ServerHandler}; +use serde_json::Value; + +use crate::brain::chat::ToolCall; +use crate::brain::exec::ToolExecutor; +use crate::mcp::projection::tool_attr; +use crate::registry::{Access, ToolRegistry}; + +/// MCP server 的 `ServerHandler` 实现。 +/// +/// `Clone` 是 Streamable HTTP 要的:每个会话/请求由工厂造一份(共享同一批 `Arc`, +/// 因此审计与策略仍是同一份)。 +#[derive(Clone)] +pub struct RegistryServer { + router: ToolRouter, + visible: Arc>, + session_id: String, +} + +impl std::fmt::Debug for RegistryServer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RegistryServer") + .field("session_id", &self.session_id) + .field("tools", &self.visible.len()) + .finish() + } +} + +impl RegistryServer { + /// 用注册表 + 执行器装配。 + /// + /// **`router_registry` 必须是完整注册表**(含写工具),`executor` 才决定「哪些工具真能跑」: + /// - 只读模式:`-executor` 用 `read_only_subset()` 装配,`tools/list` 只列只读项, + /// 硬调写工具会路由到执行器 → `unknown_tool`(**tool-level 错误 + 审计**), + /// 而不是 rmcp 的协议级 `-32602 tool not found`(那个客户端只会显示 "internal error")。 + /// - 写模式:两者都是完整注册表。 + /// + /// `session_id` 进审计的 `agent_session_id` 字段(`mcp-http` / `mcp-stdio`), + /// 让复盘时能区分「内置对话改的」和「外部 MCP 改的」。 + #[must_use] + pub fn new( + router_registry: Arc, + executor: Arc, + session_id: impl Into, + read_only: bool, + ) -> Self { + let mut router = ToolRouter::new(); + for spec in router_registry.specs() { + let attr = tool_attr(&spec.definition(), spec.access); + router.add_route(route(attr, Arc::clone(&executor))); + } + let visible: Vec = router_registry + .specs() + .iter() + .filter(|spec| !read_only || spec.access == Access::ReadOnly) + .map(|spec| tool_attr(&spec.definition(), spec.access)) + .collect(); + Self { + router, + visible: Arc::new(visible), + session_id: session_id.into(), + } + } + + /// 本 server **对外可见**的工具(`tools/list` 的内容)。 + #[must_use] + pub fn tools(&self) -> Vec { + (*self.visible).clone() + } + + /// 审计用的会话标签。 + #[must_use] + pub fn session_id(&self) -> &str { + &self.session_id + } + + /// 对外可见的工具数(启动日志/断言用)。 + #[must_use] + pub fn tool_count(&self) -> usize { + self.visible.len() + } +} + +fn route(attr: Tool, executor: Arc) -> ToolRoute { + ToolRoute::new_dyn(attr, move |context: ToolCallContext<'_, RegistryServer>| { + let executor = Arc::clone(&executor); + let session_id = context.service.session_id().to_string(); + let name = context.name().to_string(); + let arguments = context + .arguments + .clone() + .map_or_else(|| Value::Object(serde_json::Map::new()), Value::Object); + + Box::pin(async move { + let call = ToolCall { + id: format!("mcp-{name}"), + name, + arguments, + }; + let outcome = executor.execute(&session_id, &call, 0).await; + let text = outcome.to_model_content(); + let content = vec![ContentBlock::text(text)]; + let result = if outcome.ok { + CallToolResult::structured(outcome.content) + } else { + CallToolResult::error(content) + }; + Ok(result.into()) + }) + }) +} + +impl ServerHandler for RegistryServer { + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new( + "unlucky-launcher", + env!("CARGO_PKG_VERSION"), + )) + .with_instructions( + "Unlucky-Launcher 的工具服务:读写启动器配置、看实例/版本/Java、读游戏日志、跑崩溃诊断。\ + 账户凭据永不经由本服务出口;写配置受 schema 白名单与敏感键确认约束。", + ) + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListToolsResult::with_all_items(self.tools())) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let call_context = ToolCallContext::new(self, request, context); + self.router.call(call_context).await + } + + fn get_tool(&self, name: &str) -> Option { + self.router.get(name).cloned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audit::MemoryAudit; + use crate::clock::{FixedClock, SharedClock}; + use crate::confirm::{AutoDenyGate, ConfirmPolicy}; + use crate::event::NoopSink; + use crate::testing::mock_ports; + use crate::tools::build_v1_registry; + use serde_json::json; + + fn server(registry: Arc, read_only: bool) -> (RegistryServer, Arc) { + let audit = Arc::new(MemoryAudit::new()); + let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123)); + let exposed = if read_only { + Arc::new(registry.read_only_subset()) + } else { + Arc::clone(®istry) + }; + let executor = Arc::new(ToolExecutor::new( + exposed, + audit.clone(), + Arc::new(AutoDenyGate), + ConfirmPolicy::default(), + clock, + Arc::new(NoopSink), + )); + ( + RegistryServer::new(registry, executor, "mcp-test", read_only), + audit, + ) + } + + fn registry() -> Arc { + let (ports, _settings) = mock_ports(); + Arc::new(build_v1_registry(&ports).expect("注册表应装配成功")) + } + + #[test] + fn lists_every_registered_tool_in_write_mode() { + let registry = registry(); + let (server, _audit) = server(Arc::clone(®istry), false); + let tools = server.tools(); + assert_eq!(tools.len(), registry.len()); + assert_eq!(server.tool_count(), registry.len()); + assert_eq!(server.session_id(), "mcp-test"); + assert!(tools.iter().any(|tool| tool.name == "set_config")); + } + + #[test] + fn read_only_mode_hides_the_writing_tool_but_keeps_it_routable() { + let (server, _audit) = server(registry(), true); + let names: Vec = server.tools().iter().map(|t| t.name.to_string()).collect(); + assert!(!names.iter().any(|n| n == "set_config"), "{names:?}"); + assert!(names.iter().any(|n| n == "get_config")); + assert_eq!(names.len(), 9); + // 路由仍然存在(否则 rmcp 会回协议级 -32602,客户端看到的是 "internal error"), + // 真正拦下来的是只读子集执行器 → tool-level 错误 + 审计。 + assert!(server.router.get("set_config").is_some()); + } + + #[test] + fn info_advertises_tools_and_a_stable_identity() { + let (server, _audit) = server(registry(), false); + let info = server.get_info(); + assert!(info.capabilities.tools.is_some()); + assert_eq!(info.server_info.name, "unlucky-launcher"); + } + + #[test] + fn get_tool_returns_the_projected_attr() { + let (server, _audit) = server(registry(), false); + let attr = server.get_tool("get_config").expect("工具存在"); + assert_eq!(attr.name, "get_config"); + assert_eq!( + Value::Object((*attr.input_schema).clone())["type"], + json!("object") + ); + assert!(server.get_tool("nope").is_none()); + } +} diff --git a/crates/agent/src/port.rs b/crates/agent/src/port.rs new file mode 100644 index 0000000..c95c3e1 --- /dev/null +++ b/crates/agent/src/port.rs @@ -0,0 +1,315 @@ +//! 端口层(port):agent 与 `ull-core` 各模块之间**唯一**的接触面。 +//! +//! 为什么不直接 `use ull_core::*`:M12(setting)/ M14(game)/ M15(diagnose)/ M18(launch) +//! 与本 crate 并行开发,直接依赖会把 agent 的编译期绑死在别人的 API 日期上。这里用 +//! **依赖倒置**——agent 只声明自己需要的形状(本文件的 trait + 结构体),由 `crates/app` +//! 在装配处用 `ull-core` 的实现去满足(`#![forbid(unsafe_code)]` 的纯适配层)。 +//! +//! | 端口 | 提供的工具 | 对方模块 / 数据源 | +//! |------|-----------|------------------| +//! | [`SettingPort`] | `get_config` / `set_config` | M12 · `config/*.json` schema | +//! | [`GameSessionPort`] | `list_game_sessions` / `read_game_log` | M18 · `agent/sessions/game/{index,}.jsonl` | +//! | [`DiagnosePort`] | `diagnose_crash` | M15 · 规则引擎 | +//! +//! 形状依据(以对方文件为准,改字段先改对方文件): +//! - `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§5(事件行 + 索引行) +//! - `DevDocs/SubAgentsTalk/agent-ToolSpec约定.md`(本 crate 对外的对齐说明) +//! +//! 错误一律用 `Result`:错误文本会**原样进工具结果**给模型与用户看 +//! (rmcp 的 `CallToolResult::error` 同理——协议级错误不给模型看,见 research/02 §3.2)。 +//! 因此端口实现写中文、面向用户、可执行(「配置键 `x` 不存在」而不是 `ENOENT`)。 + +use std::collections::BTreeMap; +use std::sync::Arc; + +use serde::Serialize; +use serde_json::Value; + +use crate::confirm::Sensitivity; + +pub mod game; + +pub use game::{ + AccountPort, AccountSummary, GamePort, InstanceSummary, JavaRuntimeSummary, JavaStatus, + VersionSummary, +}; + +/// 端口调用结果的错误文本(面向用户可读,直接进工具结果)。 +pub type PortResult = Result; + +/// agent 需要的全部端口。装配处(`crates/app` / `ull agent serve`)用它一次性注入。 +#[derive(Clone)] +pub struct Ports { + /// 配置读写(M12)。 + pub setting: Arc, + /// 游戏会话与日志(M18)。 + pub sessions: Arc, + /// 崩溃规则引擎(M15)。 + pub diagnose: Arc, + /// 实例 / 版本 / Java(M14 + M33)。 + pub game: Arc, + /// 账户元数据(M31,**只有公开字段**,凭据永不出口)。 + pub accounts: Arc, +} + +// ───────────────────────────── 配置(M12) ───────────────────────────── + +/// AI 可写白名单里的一个键,带**敏感分级**(02 文档 §3.1 的「分级确认」依据)。 +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct SettingKey { + /// 点分路径,如 `download.concurrency`、`java.selected_path`(键名以 M12 的 schema 为准)。 + pub path: String, + /// 敏感等级(M12 `Low/Medium/High`):只有 `High` 需要用户确认。 + pub sensitivity: Sensitivity, + /// 值类型(`string` / `integer` / `boolean` / `enum` / `json`),用于错误提示。 + pub value_type: String, + /// 一句话说明,同时进 `get_config` 概要与模型上下文。 + pub description: String, +} + +/// 一次成功的写入,带旧值即「diff」(research/02 §6.4 的 dry-run 落点)。 +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct SettingChange { + /// 被修改的键路径。 + pub path: String, + /// 修改前的值;键此前不存在时为 `None`。 + pub old: Option, + /// 写入后的值。 + pub new: Value, +} + +/// 配置端口(M12 实现,`ull-core::setting` 的 schema API 直译)。 +/// +/// agent 侧只做**白名单 + 敏感分级 + 确认编排**,值本身的范围/类型校验归 setting 模块所有 +/// (单一真相源:配置 schema 在 M12,agent 不复制一份)。 +pub trait SettingPort: Send + Sync { + /// 全量概要(`get_config()` 无参时返回):键 → 当前值的扁平表,敏感键已脱敏。 + fn summary(&self) -> Value; + /// 读单个键。键不存在时返回人类可读错误。 + /// + /// **凭据契约**:`writable=false` 的凭据类键(`ai.api_key` 等)必须**拒绝或掩码**, + /// 不得返回明文——agent 侧还有一道 `redact::is_sensitive_key` 守卫,但端口是数据的 + /// 第一道闸(模型上下文与对话历史都会带上这里的返回值)。 + fn get(&self, path: &str) -> PortResult; + /// 写单个键(值校验由实现方负责),返回 diff。 + fn set(&self, path: &str, value: &Value) -> PortResult; + /// **AI 可写白名单**(含敏感分级)。不在表里的键 agent 一律拒绝。 + fn writable_keys(&self) -> Vec; + /// 配置 schema 的给 AI 版本(`get_config_schema` 工具):只含 AI 可写键与 + /// `x-ai-sensitivity` 标注,凭据类键天然不在其中(M12 `SettingSchema::to_json_schema`)。 + fn schema(&self) -> Value; +} + +// ──────────────────────── 游戏会话与日志(M18) ──────────────────────── + +/// `index.jsonl` 一行 → 一个会话摘要(同 id 多行由实现方取最后一行)。 +/// +/// 字段对齐 `launch-接口约定.md` §5;对方新增字段时这里补 `Option`,不破坏旧实现。 +#[derive(Debug, Clone, Default, Serialize, PartialEq)] +pub struct GameSessionSummary { + /// 会话 id,格式 `YYYYMMDDTHHMMSS-<6 位 hex>`(UTC)。 + pub session_id: String, + /// 实例 id。 + pub instance_id: Option, + /// 实例显示名。 + pub instance_name: Option, + /// MC 版本。 + pub game_version: Option, + /// 启动时间(RFC3339 UTC)。 + pub started_at: Option, + /// 结束时间;会话未结束时为 `None`。 + pub ended_at: Option, + /// 时长毫秒。 + pub duration_ms: u64, + /// 退出码;未正常结束时可能缺失。 + pub exit_code: Option, + /// `exit_type`:`normal|application_error|jvm_error|sigkill|sigterm|killed|interrupted`。 + pub exit_type: Option, + /// exit 0 但疑似静默失败(D8 升级 1)。 + pub silent_failure: bool, + /// 是否检测到游戏窗口。 + pub window_detected: bool, + /// 事件条数。 + pub event_count: u64, + /// 是否发现 crash-report / hs_err。 + pub crash_found: bool, + /// 规则引擎首选结论 id(`diagnosis` 事件的 top candidate)。 + pub diagnosis_top: Option, +} + +/// `read_game_log` 的查询条件:**按事件 seq 切片**,不是按行号 +/// (stdout/stderr 是一行一事件,但同一条日志行可能被切成多块,见 launch §1)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LogQuery { + /// 起始 `seq`(含)。 + pub from_seq: Option, + /// 结束 `seq`(含)。 + pub to_seq: Option, + /// 只要这些 `kind`(空 = 全部)。未知 kind 由实现方忽略而不是报错(前向兼容)。 + pub kinds: Vec, + /// 最多返回多少条事件(工具层已钳到 [`crate::tools::session::MAX_LOG_EVENTS`])。 + pub max_events: usize, +} + +/// 一条事件行(`{v,seq,ts,t_ms,kind,session_id,payload}`),落盘时已脱敏。 +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct LogEvent { + /// 会话内单调递增序号。 + pub seq: u64, + /// RFC3339 UTC。 + pub ts: String, + /// 距会话开始的毫秒数。 + pub t_ms: u64, + /// 事件类型(`stdout|stderr|exit|crash_found|diagnosis|…`)。 + pub kind: String, + /// 事件负载,原样透传(已脱敏)。 + pub payload: Value, +} + +/// 一页日志事件 + 全量统计(模型拿到摘要就不会盲目翻页,防 token 爆炸)。 +#[derive(Debug, Clone, Default, Serialize, PartialEq)] +pub struct LogPage { + /// 会话 id。 + pub session_id: String, + /// 文件内事件总数。 + pub total_events: u64, + /// 命中过滤条件的事件数。 + pub matched_events: u64, + /// 是否因 `max_events` 截断。 + pub truncated: bool, + /// 全量 kind 计数(不受过滤影响)。 + pub kind_counts: BTreeMap, + /// 首事件时间。 + pub first_ts: Option, + /// 末事件时间。 + pub last_ts: Option, + /// 本次返回的事件(已按 seq 升序)。 + pub events: Vec, +} + +/// 会话端口(M18 实现,`ull-core::launch::session_index` + `session` 的读侧)。 +/// +/// 读取**必须容忍最后一行不完整**(会话被 kill 时 jsonl 尾部可能截断)——这是实现方义务, +/// 由 `launch-接口约定.md` §1 规定,agent 只消费结果。 +pub trait GameSessionPort: Send + Sync { + /// 按 `started_at` 倒序、按 `session_id` 去重后的会话列表;`instance_id` 非空时过滤。 + fn list_sessions( + &self, + limit: usize, + instance_id: Option<&str>, + ) -> PortResult>; + /// 按 [`LogQuery`] 切片读取一个会话的事件流。 + fn read_log(&self, session_id: &str, query: &LogQuery) -> PortResult; +} + +// ───────────────────────── 崩溃诊断(M15) ───────────────────────── + +/// 六维修复动作(M15 `diagnose::rules::FixActionKind`,internally-tagged enum, +/// YAML 里写 `kind:`)。序列化名与 M15 逐字相同:`change_java|adjust_memory| +/// change_graphics|remove_mod|reinstall|seek_help`。 +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FixActionKind { + /// 换 Java(大版本上下界/指定实现)。 + ChangeJava, + /// 调 Xmx/Xms、64 位 Java、系统虚拟内存。 + AdjustMemory, + /// 驱动/渲染器/分辨率/覆盖层。 + ChangeGraphics, + /// 删/禁/换 mod。 + RemoveMod, + /// 重装/修组件(java|game_jar|loader|natives|libraries|assets|mods…)。 + Reinstall, + /// 导出诊断包 / 需要更多信息。 + SeekHelp, +} + +/// 一条修复建议(M15 的 `MaterializedFixAction { kind, tool, args }` + 一句人话)。 +/// +/// `tool` 是 M15 给的**语义动作 id**(`java.set` / `memory.set` / …)。P0 没有这些细分工具, +/// 所以 agent 侧按 [`FixActionKind`] 桥接到现有工具(见 [`crate::tools::diagnose`]), +/// `tool` 只原样透传给模型与审计,不用于分发。 +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct FixAction { + /// 六维动作类别。 + pub kind: FixActionKind, + /// 语义动作 id(如 `memory.set`);P1 落对应工具后可直接当工具名。 + pub tool: String, + /// 一句话建议(可为空,agent 会按 `kind` 补兜底文案)。 + pub label: String, + /// 已做模板替换与单位换算的参数(M15 `materialize()` 的产物)。 + pub args: std::collections::BTreeMap, +} + +/// 一条诊断候选(M15 `DiagnosisRecord::candidates[]`)。 +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct DiagnosisCandidate { + /// 规则 id(如 `jvm.32bit`)。 + pub id: String, + /// `critical|error|warn|info`。 + pub severity: String, + /// 分组(`jvm|memory|graphics|integrity|mod|…`)。 + pub category: String, + /// 置信度 0.0~1.0。 + pub confidence: f32, + /// i18n key(如 `diagnose.cause.jvm.32bit`)。 + pub message_key: String, + /// **渲染后**的人类可读结论(M15 保证非空;agent 侧只在极旧数据上回落 `message_key`)。 + pub message: String, + /// 更细的处理建议(M15 `detail`,可能为空)。 + pub detail: String, + /// 证据行(已脱敏)。 + pub evidence: Vec, + /// 该候选的修复动作。 + pub fix_actions: Vec, +} + +/// 退出摘要(M15 `DiagnosisRecord::exit`,与 launch 的 `exit` 事件同源)。 +#[derive(Debug, Clone, Default, Serialize, PartialEq)] +pub struct ExitSummary { + /// 退出码。 + pub code: Option, + /// `normal|application_error|jvm_error|sigkill|sigterm|killed|interrupted`。 + pub exit_type: Option, + /// exit 0 但疑似静默失败。 + pub silent_failure: bool, +} + +/// 规则引擎结论:**这就是给 LLM 的全部输入**(不是裸日志)。 +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct CrashDiagnosis { + /// 会话 id。 + pub session_id: String, + /// `rules|ai`(谁产出的)。 + pub source: String, + /// 规则拿不准、需要更多信息时置 true(此时才值得让 LLM 深挖)。 + pub needs_more_info: bool, + /// 主因候选,`candidates[0]` 即首因(M15 已按 priority→confidence 排序)。 + pub candidates: Vec, + /// 被抑制的次要候选。 + pub secondary: Vec, + /// 会话级关键片段(`crash_found.excerpt` / `hs_err` 摘要,已脱敏)。 + pub evidence: Vec, + /// 退出摘要。 + pub exit: ExitSummary, +} + +impl CrashDiagnosis { + /// 首选候选(主因)。 + #[must_use] + pub fn top(&self) -> Option<&DiagnosisCandidate> { + self.candidates.first() + } + + /// 最高置信度(没有候选时为 0)。 + #[must_use] + pub fn top_confidence(&self) -> f32 { + self.top().map_or(0.0, |c| c.confidence) + } +} + +/// 诊断端口(M15 实现)。agent 只做「调规则引擎 → 把结论 + 片段交给 LLM」。 +pub trait DiagnosePort: Send + Sync { + /// 对一个会话跑规则引擎。会话不存在/没有日志时返回人类可读错误。 + fn diagnose(&self, session_id: &str) -> PortResult; +} diff --git a/crates/agent/src/port/game.rs b/crates/agent/src/port/game.rs new file mode 100644 index 0000000..b5b312c --- /dev/null +++ b/crates/agent/src/port/game.rs @@ -0,0 +1,152 @@ +//! 实例 / 版本 / Java / 账户四个读侧端口(M39 的只读工具集用)。 +//! +//! 与 [`super`] 的既有三个端口同形:trait 由 agent 声明,实现在装配层用 `unlucky-core` 填充。 +//! 拆分出来的原因是 `port.rs` 挨着 400 行硬上限(`guards.yml` 的 file-size 门禁)—— +//! 「会话与日志」「崩溃诊断」留在父模块,「游戏与账户」在这里,语义上也是一刀干净的分界。 +//! +//! **凭据红线**(D9 / 02 文档 §3.1):账户端口只露 [`AccountSummary`] 的公开字段。 +//! `Credentials`(access token / 刷新令牌 / authlib 密码)在 core 的 Secret 体系里, +//! 根本不出现在本文件的类型里,因此不可能被工具结果带出去。 + +use serde::Serialize; + +use super::PortResult; + +// ───────────────────────── 实例 / 版本(M14) ───────────────────────── + +/// 一个实例(`profiles.json` 一行)的摘要:模型只需要「能定位、能判断载荷在不在」。 +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +pub struct InstanceSummary { + /// 实例 id。 + pub id: String, + /// 显示名。 + pub name: String, + /// 版本 id(如 `1.21.1` / `fabric-loader-0.16.9-1.21.1`)。 + pub version_id: String, + /// 该版本的载荷是否真的在 `versions/` 下(`false` = 装了档案但载荷缺失)。 + pub version_present: bool, + /// 独立游戏目录(未覆盖时为 `None`)。 + pub game_dir: Option, + /// 实例级 Java 覆盖路径(未覆盖时为 `None`)。 + pub java_path: Option, + /// 上次启动时刻(Unix 毫秒;从未启动为 `None`)。 + pub last_played_at: Option, +} + +/// 已安装版本的摘要(`versions//` 的存在性 + 可读的元信息)。 +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +pub struct VersionSummary { + /// 版本 id。 + pub id: String, + /// 版本 JSON 里声明的 `type`(`release` / `snapshot` / 加载器自报),读不到为 `None`。 + pub kind: Option, + /// 被几个实例引用。 + pub instance_count: usize, +} + +// ──────────────────────────── Java(M33) ──────────────────────────── + +/// 一个探测到的 Java 运行时。 +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +pub struct JavaRuntimeSummary { + /// `java` 可执行文件路径。 + pub binary: String, + /// 完整版本串(`21.0.2` / `1.8.0_402`)。 + pub version: Option, + /// 主版本号(8 / 17 / 21)。 + pub major: Option, + /// 发行方(temurin / oracle / microsoft / zulu …)。 + pub vendor: Option, + /// 来源类别(`instance` / `managed` / `env` / `system`)。 + pub origin: String, +} + +/// Java 现状(`get_java_status`)。 +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +pub struct JavaStatus { + /// 默认堆内存(MB,`java.default_memory_mb`)。 + pub default_memory_mb: Option, + /// 托管 JDK 的扫描根。 + pub managed_roots: Vec, + /// 探测到的候选(已去重,优先级从高到低)。 + pub candidates: Vec, +} + +// ──────────────────────────── 账户(M31) ──────────────────────────── + +/// 账户的**公开字段**摘要。凭据类字段刻意不存在(凭据红线)。 +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +pub struct AccountSummary { + /// 条目 id。 + pub id: String, + /// 轨道:`offline` / `microsoft` / `authlib`。 + pub kind: String, + /// Minecraft 档案 UUID(32 位无连字符)。 + pub profile_id: String, + /// 玩家名(可能为空串 = 有档案但名字待刷新)。 + pub profile_name: String, + /// 登录名(仅 authlib 轨有值)。 + pub login_name: Option, + /// 创建时刻(Unix 毫秒)。 + pub created_at_ms: u64, +} + +/// 游戏资产端口(M14 实例/版本 + M33 Java 探测)。 +pub trait GamePort: Send + Sync { + /// 全部实例(按上次启动时间倒序)。 + fn list_instances(&self) -> PortResult>; + /// 已安装版本清单。 + fn list_versions(&self) -> PortResult>; + /// Java 现状(候选 + 托管根 + 默认内存)。 + fn java_status(&self) -> PortResult; +} + +/// 账户端口(M31)。实现方只允许读 `AccountStore::accounts()`(`AccountMeta`), +/// **不得**调用 `credentials()`——那是凭据出口,agent 侧没有对应字段可以承接。 +pub trait AccountPort: Send + Sync { + /// 账户清单(公开字段)。 + fn list_accounts(&self) -> PortResult>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn account_summary_has_no_credential_fields() { + // 把摘要序列化成 JSON:任何 token / password / secret 形态的键都不许出现。 + // 这是凭据红线的**可执行断言**——将来有人往结构体里加字段,这条测试先炸。 + let summary = AccountSummary { + id: "a1".to_string(), + kind: "offline".to_string(), + profile_id: "0123456789abcdef0123456789abcdef".to_string(), + profile_name: "Steve".to_string(), + login_name: Some("steve@example.com".to_string()), + created_at_ms: 1_793_304_753_123, + }; + let json = serde_json::to_string(&summary).expect("可序列化"); + for forbidden in ["token", "password", "secret", "credential", "refresh"] { + assert!( + !json.to_lowercase().contains(forbidden), + "账户摘要不得包含 `{forbidden}`:{json}" + ); + } + } + + #[test] + fn java_status_round_trips() { + let status = JavaStatus { + default_memory_mb: Some(2048), + managed_roots: vec!["/data/java-managed".to_string()], + candidates: vec![JavaRuntimeSummary { + binary: "/jdk/bin/java".to_string(), + version: Some("21.0.2".to_string()), + major: Some(21), + vendor: Some("temurin".to_string()), + origin: "managed".to_string(), + }], + }; + assert_eq!(status.candidates.len(), 1); + assert_eq!(status.candidates[0].major, Some(21)); + } +} diff --git a/crates/agent/src/redact.rs b/crates/agent/src/redact.rs new file mode 100644 index 0000000..5ec81d3 --- /dev/null +++ b/crates/agent/src/redact.rs @@ -0,0 +1,217 @@ +//! 脱敏:审计日志的参数、以及日志工具返回文本的**第二道**防线。 +//! +//! 第一道在 launch 落盘时(`launch-接口约定.md` §4:`` / `` / +//! ``),所以这里不重复实现那套;本模块只兜两件事: +//! 1. 审计日志里的参数可能含 API key(`set_config` 能改 AI 端点)→ 按键名掩码; +//! 2. 日志正文万一含用户主目录绝对路径(旧会话、第三方 mod 输出)→ 掩码用户名段。 +//! +//! 幂等:对已脱敏文本再跑一遍结果不变(`` / `***` 原样保留)。 + +use serde_json::{Map, Value}; + +/// 掩码占位。 +pub const MASK: &str = "***"; + +/// 键名是否敏感(不区分大小写,含子串即算)。 +/// +/// 覆盖:`api_key` / `apikey` / `accessToken` / `session_token` / `password` / +/// `secret` / `authorization` / `credential`。 +#[must_use] +pub fn is_sensitive_key(key: &str) -> bool { + const NEEDLES: [&str; 8] = [ + "api_key", + "apikey", + "token", + "password", + "passwd", + "secret", + "authorization", + "credential", + ]; + let lower = key.to_ascii_lowercase(); + NEEDLES.iter().any(|n| lower.contains(n)) +} + +/// 审计用:递归把敏感键的值换成 [`MASK`](保留结构,读审计的人仍能看出改的是哪个键)。 +/// +/// 除了「键名本身敏感」之外,还认 `set_config {path, value}` 这种**值随路径敏感**的形状: +/// 当同级的 `path` 是敏感键名(`ai.api_key`)时,`value`/`old`/`new` 一律掩码—— +/// 否则审计日志会把 API key 原文写进磁盘。 +#[must_use] +pub fn redact_args(value: &Value) -> Value { + match value { + Value::Object(map) => { + let sensitive_path = map + .get("path") + .and_then(Value::as_str) + .is_some_and(is_sensitive_key); + let mut out = Map::with_capacity(map.len()); + for (k, v) in map { + let mask = is_sensitive_key(k) + || (sensitive_path && matches!(k.as_str(), "value" | "old" | "new")); + let masked = if mask { + Value::String(MASK.to_string()) + } else { + redact_args(v) + }; + out.insert(k.clone(), masked); + } + Value::Object(out) + } + Value::Array(items) => Value::Array(items.iter().map(redact_args).collect()), + other => other.clone(), + } +} + +/// 文本脱敏:用户主目录段 + `Bearer` 令牌。已脱敏文本原样返回(幂等)。 +#[must_use] +pub fn redact_text(text: &str) -> String { + let masked = mask_user_dirs(text); + mask_bearer(&masked) +} + +/// 任意 JSON 的每个字符串都过 [`redact_text`](工具结果出口的第二道防线)。 +/// +/// 与 [`redact_args`] 的分工:那个按键名掩码(审计参数),这个按**内容**掩码 +/// (日志正文、账户展示名之类可能夹带主目录/Bearer 的字符串)。 +#[must_use] +pub fn redact_texts(value: &Value) -> Value { + match value { + Value::String(text) => Value::String(redact_text(text)), + Value::Array(items) => Value::Array(items.iter().map(redact_texts).collect()), + Value::Object(map) => Value::Object( + map.iter() + .map(|(key, child)| (key.clone(), redact_texts(child))) + .collect(), + ), + other => other.clone(), + } +} + +/// `C:\Users\<名>` / `/home/<名>` / `/Users/<名>` → 用户名段替换为 [`MASK`]。 +fn mask_user_dirs(text: &str) -> String { + let lower = text.to_ascii_lowercase(); + let mut out = String::with_capacity(text.len()); + let mut cursor = 0usize; + while cursor < text.len() { + let Some((start, marker_len)) = find_marker(&lower, cursor) else { + out.push_str(&text[cursor..]); + break; + }; + out.push_str(&text[cursor..start + marker_len]); + let name_start = start + marker_len; + let name_end = segment_end(text, name_start); + let segment = &text[name_start..name_end]; + if segment.is_empty() || segment == MASK || segment.eq_ignore_ascii_case("") { + out.push_str(segment); + } else { + out.push_str(MASK); + } + cursor = name_end; + } + out +} + +/// 找最靠前的用户目录标记,返回 `(字节位置, 标记长度)`。标记须与 `lower` 同源。 +fn find_marker(lower: &str, from: usize) -> Option<(usize, usize)> { + const MARKERS: [&str; 3] = ["c:\\users\\", "/home/", "/users/"]; + MARKERS + .iter() + .filter_map(|m| lower[from..].find(m).map(|p| (from + p, m.len()))) + .min_by_key(|(start, _)| *start) +} + +/// 从 `from` 起取到下一个路径分隔符/空白/引号为止。 +fn segment_end(text: &str, from: usize) -> usize { + const STOPS: [char; 9] = ['\\', '/', '"', '\'', ' ', '\t', '\n', '\r', ',']; + text[from..] + .find(STOPS) + .map_or(text.len(), |offset| from + offset) +} + +/// `Bearer ` → `Bearer ***`。 +fn mask_bearer(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut cursor = 0usize; + while let Some(pos) = text[cursor..].find("Bearer ") { + let token_start = cursor + pos + "Bearer ".len(); + let token_end = segment_end(text, token_start); + out.push_str(&text[cursor..token_start]); + if token_end > token_start { + out.push_str(MASK); + } + cursor = token_end; + } + out.push_str(&text[cursor..]); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn masks_sensitive_arg_keys_only() { + let args = json!({ + "path": "launcher.theme", + "value": "light", + "nested": { "accessToken": "abc", "keep": 1 }, + "list": [{ "password": "p" }] + }); + let red = redact_args(&args); + // 非敏感路径:值原样保留(审计要能看出改成了什么) + assert_eq!(red["value"], json!("light")); + assert_eq!(red["path"], json!("launcher.theme")); + // 键名敏感:值掩码 + assert_eq!(red["nested"]["accessToken"], json!(MASK)); + assert_eq!(red["nested"]["keep"], json!(1)); + assert_eq!(red["list"][0]["password"], json!(MASK)); + } + + #[test] + fn masks_value_when_path_itself_is_sensitive() { + // `set_config {path: "ai.api_key", value: "sk-…"}` 是最容易漏的泄漏点 + let args = json!({ "path": "ai.api_key", "value": "sk-super-secret" }); + let red = redact_args(&args); + assert_eq!(red["value"], json!(MASK)); + let rendered = red.to_string(); + assert!(!rendered.contains("sk-super-secret"), "{rendered}"); + } + + #[test] + fn masks_windows_and_posix_home() { + assert_eq!( + redact_text(r"C:\Users\aska\.minecraft\logs\latest.log"), + r"C:\Users\***\.minecraft\logs\latest.log" + ); + assert_eq!( + redact_text("/home/aska/.minecraft/logs"), + "/home/***/.minecraft/logs" + ); + } + + #[test] + fn keeps_already_redacted_and_is_idempotent() { + let once = redact_text(r"C:\Users\aska\game\log.txt"); + assert_eq!(once, r"C:\Users\***\game\log.txt"); + assert_eq!(redact_text(&once), once); + assert_eq!(redact_text("/.minecraft"), "/.minecraft"); + } + + #[test] + fn masks_bearer_token() { + assert_eq!( + redact_text("Authorization: Bearer eyJhbGciOi.payload.sig"), + "Authorization: Bearer ***" + ); + } + + #[test] + fn detects_sensitive_key_names() { + assert!(is_sensitive_key("ai.api_key")); + assert!(is_sensitive_key("AccessToken")); + assert!(is_sensitive_key("proxy.password")); + assert!(!is_sensitive_key("download.concurrency")); + } +} diff --git a/crates/agent/src/registry/mod.rs b/crates/agent/src/registry/mod.rs new file mode 100644 index 0000000..98bb426 --- /dev/null +++ b/crates/agent/src/registry/mod.rs @@ -0,0 +1,260 @@ +//! 工具注册表:整个 agent 的**唯一真相源**。 +//! +//! 一个 `ToolSpec`(名称 + 描述 + serde JSON Schema)投影两份: +//! - 内置客户端的 rig `DynamicTool` 视图; +//! - 对外 MCP server 的 rmcp `ToolRouter` 视图。 +//! +//! 两份投影必须一致,用契约测试固定(同 name / 同 description / 同 schema)。 +//! 设计依据:`DevDocs/PlanDoc/02-ai-agent-design.md` §1。 +//! +//! 工具 v1 清单(M16 起,M39 扩到十个):`get_config` / `set_config` / `get_config_schema` / +//! `list_instances` / `list_versions` / `get_java_status` / `list_accounts` / +//! `list_game_sessions` / `read_game_log` / `diagnose_crash`。 +//! +//! 安全策略随工具走:敏感键分级确认、YOLO 模式标记、审计留痕(02 §3.1 / §4b)。 +//! +//! ## 落地形态 +//! +//! ```text +//! registry(Vec) +//! ├─ definitions() → rig `DynamicTool::new` (内置 agent,`brain::rig`) +//! └─ definitions() → rmcp `ToolRoute::new_dyn` (外部 MCP,`mcp::projection`) +//! ``` +//! +//! 防漂移的关键一招(research/02 §6.2 建议进 CI):**契约测试**遍历注册表,断言两侧 +//! 投影拿到的 `(name, description, schema)` 逐字节相等。因为两侧都只吃 +//! [`ToolDefinition`],这条约束在本 crate 里天然成立——测试只需要锁住 +//! 「definition 是纯投影」+「schema 根必须是 object」+「名字不重复」。 +//! +//! 注册表**不持有任何端口**:工具闭包在构造时就捕获了 `Arc`,注册表只是索引。 + +pub mod schema; +pub mod spec; +pub mod validate; + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::sync::Arc; + +pub use spec::{ + Access, Confirmation, ToolCtx, ToolDefinition, ToolError, ToolFn, ToolOutput, ToolSpec, +}; + +/// 注册失败原因。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RegistryError { + /// 工具名为空或重复。 + DuplicateName(String), + /// schema 不合法(根必须是 `type: object`,否则 MCP 侧直接拒收)。 + InvalidSchema { + /// 工具名。 + tool: String, + /// 原因。 + reason: String, + }, + /// 说明为空(模型靠 description 选工具,空说明等于没有这个工具)。 + EmptyDescription(String), +} + +impl fmt::Display for RegistryError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateName(name) => write!(f, "工具名重复或为空:`{name}`"), + Self::InvalidSchema { tool, reason } => { + write!(f, "工具 `{tool}` 的 schema 不合法:{reason}") + } + Self::EmptyDescription(name) => write!(f, "工具 `{name}` 缺少说明"), + } + } +} + +impl std::error::Error for RegistryError {} + +/// 工具注册表。 +#[derive(Debug, Default, Clone)] +pub struct ToolRegistry { + specs: Vec>, + index: BTreeMap, +} + +impl ToolRegistry { + /// 空注册表。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 注册一个工具。注册即校验名/schema/说明,**运行时不再做二次判断**。 + /// + /// # Errors + /// 名字重复或为空、schema 根不是 object、说明为空时返回 [`RegistryError`]。 + pub fn register(&mut self, spec: ToolSpec) -> Result<(), RegistryError> { + let name = spec.name.trim(); + if name.is_empty() || self.index.contains_key(name) { + return Err(RegistryError::DuplicateName(name.to_string())); + } + if spec.description.trim().is_empty() { + return Err(RegistryError::EmptyDescription(name.to_string())); + } + if spec.input_schema.get("type").and_then(|t| t.as_str()) != Some("object") { + return Err(RegistryError::InvalidSchema { + tool: name.to_string(), + reason: "根类型必须是 object(rig 与 rmcp 的入参都是对象 schema)".to_string(), + }); + } + self.index.insert(name.to_string(), self.specs.len()); + self.specs.push(Arc::new(spec)); + Ok(()) + } + + /// 按名查找。 + #[must_use] + pub fn get(&self, name: &str) -> Option<&Arc> { + self.index.get(name).and_then(|i| self.specs.get(*i)) + } + + /// 全部工具(按注册顺序,模型看到的顺序稳定)。 + #[must_use] + pub fn specs(&self) -> &[Arc] { + &self.specs + } + + /// 工具名列表(错误提示/审计用)。 + #[must_use] + pub fn names(&self) -> Vec<&str> { + self.specs.iter().map(|s| s.name).collect() + } + + /// 工具数量。 + #[must_use] + pub fn len(&self) -> usize { + self.specs.len() + } + + /// 是否没有工具。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.specs.is_empty() + } + + /// **投影 1/2 的公共载荷**:`(name, description, input_schema)`。 + #[must_use] + pub fn definitions(&self) -> Vec { + self.specs.iter().map(|s| s.definition()).collect() + } + + /// 只读子集(P1 的 `--readonly` 外部 MCP 模式用)。 + #[must_use] + pub fn read_only_definitions(&self) -> Vec { + self.specs + .iter() + .filter(|s| s.access == Access::ReadOnly) + .map(|s| s.definition()) + .collect() + } + + /// **只读模式**的注册表副本(外部 MCP 默认形态)。 + /// + /// 返回的是「物理上不含写工具」的新注册表:`tools/list` 里看不到 `set_config`, + /// 硬调它也会在编排层得到 `unknown_tool`(并留一条审计),而不是静默执行。 + /// 这比「列表过滤 + 调用拦截」两套逻辑更不容易漏——只有一份子集,两侧天然一致。 + #[must_use] + pub fn read_only_subset(&self) -> Self { + let mut subset = Self::new(); + for spec in self.specs.iter().filter(|s| s.access == Access::ReadOnly) { + let name = spec.name.to_string(); + if subset.index.insert(name, subset.specs.len()).is_none() { + subset.specs.push(Arc::clone(spec)); + } + } + subset + } + + /// 名 → access(装配处检查白名单/只读模式)。 + #[must_use] + pub fn access_of(&self, name: &str) -> Option { + self.get(name).map(|s| s.access) + } + + /// 去重后的名字集合(契约测试用)。 + #[must_use] + pub fn unique_names(&self) -> BTreeSet<&str> { + self.specs.iter().map(|s| s.name).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::schema::{non_empty_string, object}; + use serde_json::{json, Value}; + + fn echo(name: &'static str) -> ToolSpec { + ToolSpec::read_only( + name, + "回显", + object(&[("text", non_empty_string("文本"))], &["text"]), + Arc::new(|_ctx: &ToolCtx, args: &Value| Ok(ToolOutput::ok(args.clone(), "echo"))), + ) + } + + #[test] + fn register_and_lookup() { + let mut reg = ToolRegistry::new(); + reg.register(echo("echo")).unwrap(); + assert_eq!(reg.len(), 1); + assert_eq!(reg.names(), vec!["echo"]); + assert!(reg.get("echo").is_some()); + assert!(reg.get("nope").is_none()); + assert_eq!(reg.access_of("echo"), Some(Access::ReadOnly)); + } + + #[test] + fn rejects_duplicate_empty_and_non_object_schema() { + let mut reg = ToolRegistry::new(); + reg.register(echo("echo")).unwrap(); + assert_eq!( + reg.register(echo("echo")), + Err(RegistryError::DuplicateName("echo".into())) + ); + + let bad = ToolSpec::read_only( + "bad", + "x", + json!({ "type": "array" }), + Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "x"))), + ); + assert!(matches!( + reg.register(bad), + Err(RegistryError::InvalidSchema { .. }) + )); + + let no_desc = ToolSpec::read_only( + "nodesc", + " ", + json!({ "type": "object" }), + Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "x"))), + ); + assert!(matches!( + reg.register(no_desc), + Err(RegistryError::EmptyDescription(_)) + )); + } + + #[test] + fn definitions_are_stable_and_filterable() { + let mut reg = ToolRegistry::new(); + reg.register(echo("a")).unwrap(); + reg.register(ToolSpec::mutating( + "b", + "写", + json!({ "type": "object" }), + Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "b"))), + )) + .unwrap(); + assert_eq!(reg.definitions().len(), 2); + assert_eq!(reg.read_only_definitions().len(), 1); + assert_eq!(reg.definitions()[0].name, "a"); + assert_eq!(reg.unique_names().len(), 2); + } +} diff --git a/crates/agent/src/registry/schema.rs b/crates/agent/src/registry/schema.rs new file mode 100644 index 0000000..1fe57dc --- /dev/null +++ b/crates/agent/src/registry/schema.rs @@ -0,0 +1,116 @@ +//! JSON Schema 构造helper。 +//! +//! 工具参数 schema 一律**手写 JSON**(research/02 §6.2:`rig` 的 `DynamicTool::new` 与 +//! rmcp 的 `ToolRoute::new_dyn` 吃的都是裸 `serde_json::Value`),所以不需要 schemars 派生。 +//! 手写的好处是同一份 schema 直接喂两方,坏处是啰嗦——这个模块把啰嗦收敛到几个函数。 +//! +//! 生成的 schema 一律按 **JSON Schema 2020-12** 的写法(rmcp 支持该版本; +//! `additionalProperties: false` 让模型少给野参数,[`super::validate`] 会执行它)。 + +use serde_json::{json, Map, Value}; + +/// 对象 schema。`properties` 按给定顺序写入(JSON 对象保序,UI 展示稳定)。 +#[must_use] +pub fn object(properties: &[(&str, Value)], required: &[&str]) -> Value { + let mut props = Map::new(); + for (name, schema) in properties { + props.insert((*name).to_string(), schema.clone()); + } + json!({ + "type": "object", + "properties": Value::Object(props), + "required": required, + "additionalProperties": false, + }) +} + +/// 任意 JSON 值(不校验类型)。 +#[must_use] +pub fn any() -> Value { + json!({}) +} + +/// 字符串。 +#[must_use] +pub fn string(description: &str) -> Value { + json!({ "type": "string", "description": description }) +} + +/// 非空字符串(`minLength: 1`,省掉一堆「路径不能为空」的运行时判断)。 +#[must_use] +pub fn non_empty_string(description: &str) -> Value { + json!({ "type": "string", "description": description, "minLength": 1 }) +} + +/// 枚举字符串。 +#[must_use] +pub fn string_enum(values: &[&str], description: &str) -> Value { + json!({ "type": "string", "description": description, "enum": values }) +} + +/// 布尔。 +#[must_use] +pub fn boolean(description: &str) -> Value { + json!({ "type": "boolean", "description": description }) +} + +/// 带范围约束的整数。 +#[must_use] +pub fn integer(description: &str, minimum: i64, maximum: i64) -> Value { + json!({ + "type": "integer", + "description": description, + "minimum": minimum, + "maximum": maximum, + }) +} + +/// 字符串数组(可选条数上限)。 +#[must_use] +pub fn string_array(description: &str, max_items: u64) -> Value { + json!({ + "type": "array", + "description": description, + "items": { "type": "string" }, + "maxItems": max_items, + }) +} + +/// 枚举字符串数组(如 `kinds: ["stderr","exit"]`)。 +#[must_use] +pub fn enum_string_array(values: &[&str], description: &str, max_items: u64) -> Value { + json!({ + "type": "array", + "description": description, + "items": { "type": "string", "enum": values }, + "maxItems": max_items, + "uniqueItems": true, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::validate::validate; + + #[test] + fn object_records_required_and_forbids_extras() { + let schema = object( + &[("path", non_empty_string("键路径")), ("value", any())], + &["path", "value"], + ); + assert_eq!(schema["type"], json!("object")); + assert_eq!(schema["additionalProperties"], json!(false)); + assert_eq!(schema["required"], json!(["path", "value"])); + assert!(validate(&schema, &json!({ "path": "a", "value": 1 })).is_ok()); + } + + #[test] + fn integer_builder_carries_bounds() { + let schema = integer("条数", 1, 500); + assert_eq!(schema["minimum"], json!(1)); + assert_eq!(schema["maximum"], json!(500)); + assert!(validate(&schema, &json!(0)).is_err()); + assert!(validate(&schema, &json!(500)).is_ok()); + } +} diff --git a/crates/agent/src/registry/spec.rs b/crates/agent/src/registry/spec.rs new file mode 100644 index 0000000..4f03cb0 --- /dev/null +++ b/crates/agent/src/registry/spec.rs @@ -0,0 +1,262 @@ +//! 工具的**唯一定义类型**([`ToolSpec`])与它的两份投影载荷([`ToolDefinition`])。 +//! +//! 双投影的支点在 research/02 §6.2:`rig::tool::DynamicTool::new(name, description, +//! parameters: serde_json::Value, ..)` 与 `rmcp::ToolRoute::new_dyn(name, description, +//! schema, handler)` 的入参**都是裸 JSON Schema**。所以本 crate 只维护一份 +//! [`ToolSpec`],由 [`ToolSpec::definition`] 生成两侧共用的 `(name, description, schema)`。 +//! +//! 工具实现是**同步**的:端口调用全是本地文件/内存,没有网络 IO;异步只存在于 +//! provider 边界([`crate::brain::provider::ChatProvider`])。P1 若出现网络类工具 +//! (mod 搜索/版本安装)再升成 `Fn -> BoxFuture`,不影响调用方语义。 + +use std::fmt; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::confirm::Sensitivity; + +/// 工具的副作用等级(外部 MCP 只读模式、P1 的 `--readonly` 都按它筛)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Access { + /// 只读:可以给外部 agent、可以在无确认的情况下调用。 + ReadOnly, + /// 会改用户数据(写配置),受白名单 + 分级确认约束。 + Mutating, +} + +/// 工具定义的三元组:**双投影就投这个**。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ToolDefinition { + /// 工具名(模型可见,`snake_case`)。 + pub name: String, + /// 一句话说明(模型可见)。 + pub description: String, + /// 参数 JSON Schema(2020-12,根类型必须 `object`)。 + pub input_schema: Value, +} + +/// 工具调用上下文:编排层注入,工具只读。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolCtx { + /// agent 对话会话 id(审计用;与游戏 session_id 不是一回事)。 + pub agent_session_id: String, + /// 是否已获确认。**只有编排层能置 true**(YOLO 或用户点了允许), + /// 工具据此决定「先报价再执行」还是「直接执行」。 + pub confirmed: bool, +} + +/// 工具的返回。 +#[derive(Debug, Clone, PartialEq)] +pub struct ToolOutput { + /// 给模型/审计的结构化结果(编排层序列化成 JSON 字符串回灌)。 + pub content: Value, + /// 一句话摘要(UI 展示 + 审计 summary)。 + pub summary: String, + /// 非空表示「这次没执行,先要确认」([`crate::confirm::ConfirmRequest`] 的原料)。 + pub confirmation: Option, +} + +/// 待确认的操作详情(dry-run 的产物)。 +#[derive(Debug, Clone, PartialEq)] +pub struct Confirmation { + /// 一句话请求(「AI 请求把 X 从 A 改为 B」)。 + pub summary: String, + /// 结构化 diff(`{"path":…,"old":…,"new":…}`)。 + pub details: Value, + /// 敏感等级(决定 YOLO 能不能跳过)。 + pub sensitivity: Sensitivity, +} + +impl ToolOutput { + /// 成功返回。 + #[must_use] + pub fn ok(content: Value, summary: impl Into) -> Self { + Self { + content, + summary: summary.into(), + confirmation: None, + } + } + + /// 需要确认(本次**没有**产生副作用)。 + #[must_use] + pub fn needs_confirmation( + summary: impl Into, + details: Value, + sensitivity: Sensitivity, + ) -> Self { + let summary = summary.into(); + Self { + content: serde_json::json!({ + "status": "needs_confirmation", + "summary": summary, + }), + confirmation: Some(Confirmation { + summary: summary.clone(), + details, + sensitivity, + }), + summary, + } + } +} + +/// 工具错误。**全部面向用户可读**——它们会作为工具结果回到模型, +/// 由模型决定重试还是换方案(rmcp 的 `Ok(CallToolResult::error(..))` 同义, +/// 见 research/02 §3.2:协议级 `Err` 会被客户端吞掉,不能用来报业务错误)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ToolError { + /// 参数语义不合法(schema 校验之外的规则,如路径不在白名单)。 + NotAllowed(String), + /// 找不到对象(会话/键/文件)。 + NotFound(String), + /// 端口(M12/M15/M18)报错,文本原样透传。 + Port(String), + /// 工具自身内部错误(不该发生)。 + Internal(String), +} + +impl ToolError { + /// 一句话文案(写日志/审计用)。 + #[must_use] + pub fn message(&self) -> &str { + match self { + Self::NotAllowed(m) | Self::NotFound(m) | Self::Port(m) | Self::Internal(m) => m, + } + } + + /// 错误码(审计与 UI 分类用)。 + #[must_use] + pub fn code(&self) -> &'static str { + match self { + Self::NotAllowed(_) => "not_allowed", + Self::NotFound(_) => "not_found", + Self::Port(_) => "port_error", + Self::Internal(_) => "internal", + } + } +} + +impl fmt::Display for ToolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.message()) + } +} + +impl std::error::Error for ToolError {} + +/// 工具处理函数。 +pub type ToolFn = Arc Result + Send + Sync>; + +/// 一个工具的完整定义 + 实现。 +#[derive(Clone)] +pub struct ToolSpec { + /// 工具名(`snake_case`,同时是 MCP 侧的工具名)。 + pub name: &'static str, + /// 说明。 + pub description: &'static str, + /// 参数 schema(根必须 object)。 + pub input_schema: Value, + /// 副作用等级。 + pub access: Access, + /// 实现。 + pub handler: ToolFn, +} + +impl fmt::Debug for ToolSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ToolSpec") + .field("name", &self.name) + .field("access", &self.access) + .finish_non_exhaustive() + } +} + +impl ToolSpec { + /// 建一个只读工具。 + #[must_use] + pub fn read_only( + name: &'static str, + description: &'static str, + input_schema: Value, + handler: ToolFn, + ) -> Self { + Self { + name, + description, + input_schema, + access: Access::ReadOnly, + handler, + } + } + + /// 建一个会改数据的工具。 + #[must_use] + pub fn mutating( + name: &'static str, + description: &'static str, + input_schema: Value, + handler: ToolFn, + ) -> Self { + Self { + name, + description, + input_schema, + access: Access::Mutating, + handler, + } + } + + /// 投影成两侧共用的 `(name, description, schema)`。 + #[must_use] + pub fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: self.name.to_string(), + description: self.description.to_string(), + input_schema: self.input_schema.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::schema::{non_empty_string, object}; + use serde_json::json; + + fn echo_handler() -> ToolFn { + Arc::new(|_ctx: &ToolCtx, args: &Value| Ok(ToolOutput::ok(args.clone(), "echo"))) + } + + #[test] + fn definition_is_a_pure_projection() { + let spec = ToolSpec::read_only( + "echo", + "回显", + object(&[("text", non_empty_string("文本"))], &["text"]), + echo_handler(), + ); + let def = spec.definition(); + assert_eq!(def.name, "echo"); + assert_eq!(def.description, "回显"); + assert_eq!(def.input_schema, spec.input_schema); + assert_eq!(spec.access, Access::ReadOnly); + } + + #[test] + fn confirmation_carries_details() { + let out = ToolOutput::needs_confirmation( + "AI 请求修改 x", + json!({ "path": "x" }), + Sensitivity::High, + ); + assert_eq!(out.content["status"], json!("needs_confirmation")); + let confirmation = out.confirmation.expect("必须有待确认详情"); + assert_eq!(confirmation.sensitivity, Sensitivity::High); + assert_eq!(confirmation.summary, out.summary); + assert_eq!(confirmation.details, json!({ "path": "x" })); + } +} diff --git a/crates/agent/src/registry/validate.rs b/crates/agent/src/registry/validate.rs new file mode 100644 index 0000000..d3f32d2 --- /dev/null +++ b/crates/agent/src/registry/validate.rs @@ -0,0 +1,332 @@ +//! 参数校验:JSON Schema 2020-12 的**最小子集**实现。 +//! +//! 为什么不引 `jsonschema` crate:agent 的 schema 全是自己手写的([`super::schema`]), +//! 用到的关键字只有 `type/required/properties/additionalProperties/enum/minimum/maximum/ +//! minLength/minItems/maxItems/uniqueItems/items`。三百行的校验器换掉一棵依赖树, +//! 对 Win7 单文件形态(D12)值得。 +//! +//! 校验失败的文案是给**模型**看的(它会据此改正参数重试),所以要具体: +//! 「参数 `limit` 必须是 1~500 的整数,实际是 2000」,不是 `schema validation failed`。 + +use serde_json::Value; + +/// 校验入口。返回全部错误(一次给全,省模型来回试错)。 +/// +/// # Errors +/// 参数不符合 schema 时返回人类可读的错误列表。 +pub fn validate(schema: &Value, args: &Value) -> Result<(), Vec> { + let mut errors = Vec::new(); + validate_at(schema, args, "", &mut errors); + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } +} + +/// 只取第一条错误(日志/审计摘要用)。 +#[must_use] +pub fn first_error(schema: &Value, args: &Value) -> Option { + validate(schema, args) + .err() + .and_then(|errors| errors.into_iter().next()) +} + +fn validate_at(schema: &Value, value: &Value, path: &str, errors: &mut Vec) { + let Some(schema) = schema.as_object() else { + return; // `{}` = 任意值 + }; + + if let Some(expected) = schema.get("type").and_then(Value::as_str) { + if !type_matches(expected, value) { + errors.push(format!( + "参数 {} 应为 {},实际是 {}", + label(path), + expected, + type_name(value) + )); + return; // 类型都不对,后面的范围检查没有意义 + } + } + + if let Some(allowed) = schema.get("enum").and_then(Value::as_array) { + if !allowed.iter().any(|v| v == value) { + let list: Vec = allowed.iter().map(render).collect(); + errors.push(format!( + "参数 {} 只能取 [{}],实际是 {}", + label(path), + list.join(", "), + render(value) + )); + } + } + + check_bounds(schema, value, path, errors); + + match value { + Value::Object(map) => { + let required = schema.get("required").and_then(Value::as_array); + for name in required.into_iter().flatten().filter_map(Value::as_str) { + if !map.contains_key(name) { + errors.push(format!("缺少必填参数 {}", label(&join(path, name)))); + } + } + let properties = schema.get("properties").and_then(Value::as_object); + for (key, entry) in map { + match properties.and_then(|p| p.get(key)) { + Some(sub) => validate_at(sub, entry, &join(path, key), errors), + None => { + if schema.get("additionalProperties").and_then(Value::as_bool) + == Some(false) + { + let known: Vec<&str> = properties + .map(|p| p.keys().map(String::as_str).collect()) + .unwrap_or_default(); + errors.push(format!( + "参数 {} 不是可接受的参数名(可用:{})", + label(&join(path, key)), + if known.is_empty() { + "无".to_string() + } else { + known.join(", ") + } + )); + } + } + } + } + } + Value::Array(items) => { + if let Some(sub) = schema.get("items") { + for (i, item) in items.iter().enumerate() { + validate_at(sub, item, &format!("{path}[{i}]"), errors); + } + } + } + _ => {} + } +} + +fn check_bounds( + schema: &serde_json::Map, + value: &Value, + path: &str, + errors: &mut Vec, +) { + let number = value.as_f64(); + let minimum = schema.get("minimum").and_then(Value::as_f64); + let maximum = schema.get("maximum").and_then(Value::as_f64); + if let (Some(n), Some(min), Some(max)) = (number, minimum, maximum) { + if n < min || n > max { + errors.push(format!( + "参数 {} 必须在 {}~{} 之间,实际是 {}", + label(path), + render_number(min), + render_number(max), + render_number(n) + )); + return; + } + } else if let (Some(n), Some(min)) = (number, minimum) { + if n < min { + errors.push(format!( + "参数 {} 不能小于 {},实际是 {}", + label(path), + render_number(min), + render_number(n) + )); + } + } else if let (Some(n), Some(max)) = (number, maximum) { + if n > max { + errors.push(format!( + "参数 {} 不能大于 {},实际是 {}", + label(path), + render_number(max), + render_number(n) + )); + } + } + + if let Some(text) = value.as_str() { + if let Some(min_len) = schema.get("minLength").and_then(Value::as_u64) { + if (text.chars().count() as u64) < min_len { + errors.push(format!( + "参数 {} 不能为空(最少 {} 个字符)", + label(path), + min_len + )); + } + } + } + + if let Some(items) = value.as_array() { + if let Some(max_items) = schema.get("maxItems").and_then(Value::as_u64) { + if items.len() as u64 > max_items { + errors.push(format!( + "参数 {} 最多 {} 项,实际 {} 项", + label(path), + max_items, + items.len() + )); + } + } + if let Some(min_items) = schema.get("minItems").and_then(Value::as_u64) { + if (items.len() as u64) < min_items { + errors.push(format!( + "参数 {} 至少 {} 项,实际 {} 项", + label(path), + min_items, + items.len() + )); + } + } + if schema.get("uniqueItems").and_then(Value::as_bool) == Some(true) { + if let Some(duplicate) = first_duplicate(items) { + errors.push(format!( + "参数 {} 不能有重复项({} 出现了多次)", + label(path), + render(duplicate) + )); + } + } + } +} + +/// 数组里第一个重复出现的元素(`uniqueItems` 用;`null` 也算一个值)。 +fn first_duplicate(items: &[Value]) -> Option<&Value> { + items + .iter() + .enumerate() + .find(|(index, item)| items.iter().take(*index).any(|prev| prev == *item)) + .map(|(_, item)| item) +} + +fn type_matches(expected: &str, value: &Value) -> bool { + match expected { + "object" => value.is_object(), + "array" => value.is_array(), + "string" => value.is_string(), + "boolean" => value.is_boolean(), + "null" => value.is_null(), + "integer" => value.as_f64().is_some_and(|n| n.fract() == 0.0), + "number" => value.is_number(), + _ => true, // 未知关键字不拦(前向兼容) + } +} + +fn type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(n) if n.as_f64().is_some_and(|f| f.fract() == 0.0) => "integer", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +fn label(path: &str) -> String { + if path.is_empty() { + "(根)".to_string() + } else { + format!("`{path}`") + } +} + +fn join(path: &str, key: &str) -> String { + if path.is_empty() { + key.to_string() + } else { + format!("{path}.{key}") + } +} + +fn render(value: &Value) -> String { + match value { + Value::String(s) => format!("\"{s}\""), + other => other.to_string(), + } +} + +fn render_number(n: f64) -> String { + if n.fract() == 0.0 { + format!("{}", n as i64) + } else { + n.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::schema::{ + enum_string_array, integer, non_empty_string, object, string_array, string_enum, + }; + use serde_json::json; + + fn errors(schema: &Value, args: &Value) -> Vec { + validate(schema, args).unwrap_err() + } + + #[test] + fn accepts_valid_and_reports_missing_required() { + let schema = object( + &[("session_id", non_empty_string("会话 id"))], + &["session_id"], + ); + assert!(validate(&schema, &json!({ "session_id": "20261029T124501-9f3c2a" })).is_ok()); + let errs = errors(&schema, &json!({})); + assert_eq!(errs.len(), 1); + assert!(errs[0].contains("缺少必填参数 `session_id`"), "{errs:?}"); + } + + #[test] + fn rejects_empty_string_and_wrong_type() { + let schema = object( + &[("session_id", non_empty_string("会话 id"))], + &["session_id"], + ); + assert!(errors(&schema, &json!({ "session_id": "" }))[0].contains("不能为空")); + assert!(errors(&schema, &json!({ "session_id": 1 }))[0].contains("应为 string")); + } + + #[test] + fn rejects_out_of_range_and_unknown_keys() { + let schema = object( + &[ + ("limit", integer("条数", 1, 500)), + ("kinds", string_array("类型", 8)), + ], + &[], + ); + let errs = errors(&schema, &json!({ "limit": 2000 })); + assert!(errs[0].contains("1~500"), "{errs:?}"); + let errs = errors(&schema, &json!({ "limit": 1, "evil": true })); + assert!(errs[0].contains("不是可接受的参数名"), "{errs:?}"); + } + + #[test] + fn checks_enum_and_nested_objects() { + let schema = object(&[("level", string_enum(&["error", "warn"], "级别"))], &[]); + assert!(validate(&schema, &json!({ "level": "error" })).is_ok()); + assert!(errors(&schema, &json!({ "level": "fatal" }))[0].contains("只能取")); + } + + #[test] + fn rejects_duplicate_items_when_unique() { + let schema = object( + &[("kinds", enum_string_array(&["stderr", "exit"], "类型", 4))], + &[], + ); + let errs = errors(&schema, &json!({ "kinds": ["stderr", "exit", "stderr"] })); + assert!(errs[0].contains("不能有重复项"), "{errs:?}"); + assert!(validate(&schema, &json!({ "kinds": ["stderr", "exit"] })).is_ok()); + } + + #[test] + fn integer_type_rejects_fraction() { + assert!(validate(&json!({ "type": "integer" }), &json!(1.5)).is_err()); + assert!(validate(&json!({ "type": "integer" }), &json!(2.0)).is_ok()); + } +} diff --git a/crates/agent/src/testing.rs b/crates/agent/src/testing.rs new file mode 100644 index 0000000..1c899a2 --- /dev/null +++ b/crates/agent/src/testing.rs @@ -0,0 +1,190 @@ +//! 测试替身:五个端口的 **mock 实现**(无盘、无网络、无时间依赖)。 +//! +//! 放在库内(而不是只在 `#[cfg(test)]`)是刻意的:`crates/app` 装配时也要能给 +//! 自己的单测造一套「无盘」环境,且 P1/M39 的 MCP 契约测试要复用同一批假数据。 +//! +//! 按端口拆文件(`port.rs` 挨 400 行上限的同款理由):`settings` 配置、`sessions` +//! 会话日志在下面,`game` 实例/Java/账户与 `diagnose` 诊断各一个子模块。 +//! 对外名字不变,`unlucky_agent::testing::MockSettings` 之类照旧可用(走 re-export)。 + +pub mod diagnose; +pub mod game; +pub mod settings; + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{json, Value}; + +use crate::port::{GameSessionPort, GameSessionSummary, LogEvent, LogPage, LogQuery, PortResult}; + +pub use diagnose::MockDiagnose; +pub use game::{MockAccounts, MockGame}; +pub use settings::MockSettings; + +/// 内存会话端口。 +#[derive(Debug, Default)] +pub struct MockSessions { + sessions: Vec, + events: BTreeMap>, +} + +impl MockSessions { + /// 空数据。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 加一个会话及其事件流。 + pub fn add(&mut self, summary: GameSessionSummary, events: Vec) { + self.events.insert(summary.session_id.clone(), events); + self.sessions.push(summary); + } + + /// 便捷:造一个崩溃会话(`exit_code=1` + stderr + crash_found)。 + #[must_use] + pub fn crashing_session(session_id: &str) -> (GameSessionSummary, Vec) { + let summary = GameSessionSummary { + session_id: session_id.to_string(), + instance_id: Some("inst-1".to_string()), + instance_name: Some("1.20.1 Forge".to_string()), + game_version: Some("1.20.1".to_string()), + started_at: Some("2026-10-29T12:45:01.123Z".to_string()), + ended_at: Some("2026-10-29T12:46:31.123Z".to_string()), + duration_ms: 90_000, + exit_code: Some(1), + exit_type: Some("application_error".to_string()), + silent_failure: false, + window_detected: true, + event_count: 5, + crash_found: true, + diagnosis_top: Some("forge_missing_library".to_string()), + }; + let events = vec![ + event( + 0, + "stdout", + json!({ "line": "Starting Minecraft 1.20.1", "level": "info" }), + ), + event( + 1, + "stderr", + json!({ "line": "java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher", "level": "error" }), + ), + event( + 2, + "crash_found", + json!({ "source": "crash_report", "path": r"C:\Users\aska\.minecraft\crash-reports\crash-1.txt", "excerpt": "Description: Mod loading failure" }), + ), + event( + 3, + "exit", + json!({ "code": 1, "exit_type": "application_error", "silent_failure": false }), + ), + event( + 4, + "session_end", + json!({ "reason": "exit", "duration_ms": 90000 }), + ), + ]; + (summary, events) + } +} + +fn event(seq: u64, kind: &str, payload: Value) -> LogEvent { + LogEvent { + seq, + ts: format!("2026-10-29T12:45:0{seq}.000Z"), + t_ms: seq * 1_000, + kind: kind.to_string(), + payload, + } +} + +impl GameSessionPort for MockSessions { + fn list_sessions( + &self, + limit: usize, + instance_id: Option<&str>, + ) -> PortResult> { + let mut out: Vec = self + .sessions + .iter() + .filter(|s| instance_id.is_none_or(|id| s.instance_id.as_deref() == Some(id))) + .cloned() + .collect(); + out.sort_by(|a, b| b.started_at.cmp(&a.started_at)); + out.truncate(limit); + Ok(out) + } + + fn read_log(&self, session_id: &str, query: &LogQuery) -> PortResult { + let events = self + .events + .get(session_id) + .ok_or_else(|| format!("会话 `{session_id}` 不存在"))?; + let mut kind_counts = BTreeMap::new(); + for event in events { + *kind_counts.entry(event.kind.clone()).or_insert(0u64) += 1; + } + let matched: Vec<&LogEvent> = events + .iter() + .filter(|e| query.from_seq.is_none_or(|from| e.seq >= from)) + .filter(|e| query.to_seq.is_none_or(|to| e.seq <= to)) + .filter(|e| query.kinds.is_empty() || query.kinds.contains(&e.kind)) + .collect(); + let max = if query.max_events == 0 { + matched.len() + } else { + query.max_events + }; + let truncated = matched.len() > max; + Ok(LogPage { + session_id: session_id.to_string(), + total_events: events.len() as u64, + matched_events: matched.len() as u64, + truncated, + kind_counts, + first_ts: events.first().map(|e| e.ts.clone()), + last_ts: events.last().map(|e| e.ts.clone()), + events: matched.into_iter().take(max).cloned().collect(), + }) + } +} + +/// 一次性造好全部端口的 [`crate::port::Ports`](含一个崩溃会话,够跑通对话与日志工具)。 +#[must_use] +pub fn mock_ports() -> (crate::port::Ports, std::sync::Arc) { + use std::sync::Arc; + let settings = Arc::new(MockSettings::new()); + let (summary, events) = MockSessions::crashing_session("20261029T124501-9f3c2a"); + let mut sessions = MockSessions::new(); + sessions.add(summary, events); + let ports = crate::port::Ports { + setting: settings.clone(), + sessions: Arc::new(sessions), + diagnose: Arc::new(MockDiagnose::new()), + game: Arc::new(MockGame::new()), + accounts: Arc::new(MockAccounts::new()), + }; + (ports, settings) +} + +/// 测试里常用的「全部工具名」集合(契约测试与 MCP 投影比对用)。 +#[must_use] +pub fn expected_v1_tool_names() -> BTreeSet<&'static str> { + [ + "get_config", + "get_config_schema", + "set_config", + "list_game_sessions", + "read_game_log", + "diagnose_crash", + "list_instances", + "list_versions", + "get_java_status", + "list_accounts", + ] + .into_iter() + .collect() +} diff --git a/crates/agent/src/testing/diagnose.rs b/crates/agent/src/testing/diagnose.rs new file mode 100644 index 0000000..7f914d2 --- /dev/null +++ b/crates/agent/src/testing/diagnose.rs @@ -0,0 +1,87 @@ +//! 内存诊断端口(`DiagnosePort` 的测试替身)。 + +use crate::port::{ + CrashDiagnosis, DiagnosePort, DiagnosisCandidate, ExitSummary, FixAction, FixActionKind, + PortResult, +}; + +/// 内存诊断端口(固定返回一份规则结论)。 +#[derive(Debug, Clone)] +pub struct MockDiagnose { + report: Option, +} + +impl Default for MockDiagnose { + fn default() -> Self { + Self { + report: Some(Self::forge_missing_library()), + } + } +} + +impl MockDiagnose { + /// 构造使用默认结论的端口。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 让端口对所有会话返回「会话不存在」。 + #[must_use] + pub fn unavailable() -> Self { + Self { report: None } + } + + /// 默认结论:Forge 缺库(对应 analyse/02 的高发场景)。 + #[must_use] + pub fn forge_missing_library() -> CrashDiagnosis { + CrashDiagnosis { + session_id: String::new(), + source: "rules".to_string(), + needs_more_info: false, + candidates: vec![DiagnosisCandidate { + id: "integrity.forge_missing_library".to_string(), + severity: "error".to_string(), + category: "integrity".to_string(), + confidence: 0.86, + message_key: "diagnose.cause.integrity.forge_missing_library".to_string(), + message: "Forge 启动缺少 bootstraplauncher 库,通常是版本组件未下载完整" + .to_string(), + detail: "在实例上重装 Forge 组件(或先用官方启动器补全一次再回来)".to_string(), + evidence: vec![ + "java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher" + .to_string(), + ], + fix_actions: vec![FixAction { + kind: FixActionKind::Reinstall, + tool: "component.reinstall".to_string(), + label: "重装该版本的 Forge 组件".to_string(), + args: [("component".to_string(), "forge".to_string())] + .into_iter() + .collect(), + }], + }], + secondary: Vec::new(), + evidence: vec!["Description: Mod loading failure".to_string()], + exit: ExitSummary { + code: Some(1), + exit_type: Some("application_error".to_string()), + silent_failure: false, + }, + } + } +} + +impl DiagnosePort for MockDiagnose { + fn diagnose(&self, session_id: &str) -> PortResult { + self.report.clone().map_or_else( + || Err(format!("会话 `{session_id}` 不存在")), + |report| { + Ok(CrashDiagnosis { + session_id: session_id.to_string(), + ..report + }) + }, + ) + } +} diff --git a/crates/agent/src/testing/game.rs b/crates/agent/src/testing/game.rs new file mode 100644 index 0000000..821b751 --- /dev/null +++ b/crates/agent/src/testing/game.rs @@ -0,0 +1,158 @@ +//! 实例 / 版本 / Java / 账户四个只读端口的测试替身。 + +use crate::port::game::{ + AccountPort, AccountSummary, GamePort, InstanceSummary, JavaRuntimeSummary, JavaStatus, + VersionSummary, +}; +use crate::port::PortResult; + +/// 内存游戏端口:一个 1.20.1 Forge 实例 + 两个已安装版本 + 两个 Java 候选。 +#[derive(Debug, Clone)] +pub struct MockGame { + instances: Vec, + versions: Vec, + java: JavaStatus, + fail: Option, +} + +impl Default for MockGame { + fn default() -> Self { + Self::new() + } +} + +impl MockGame { + /// 默认假数据(够跑通 `list_instances` / `list_versions` / `get_java_status`)。 + #[must_use] + pub fn new() -> Self { + Self { + instances: vec![InstanceSummary { + id: "inst-1".to_string(), + name: "1.20.1 Forge".to_string(), + version_id: "1.20.1-forge-47.2.20".to_string(), + version_present: true, + game_dir: None, + java_path: Some(r"D:\jdk17\bin\java.exe".to_string()), + last_played_at: Some(1_793_304_753_123), + }], + versions: vec![ + VersionSummary { + id: "1.20.1-forge-47.2.20".to_string(), + kind: Some("release".to_string()), + instance_count: 1, + }, + VersionSummary { + id: "1.21.1".to_string(), + kind: Some("release".to_string()), + instance_count: 0, + }, + ], + java: JavaStatus { + default_memory_mb: Some(2048), + managed_roots: vec![r"D:\ULL\.ULL\data\java-managed".to_string()], + candidates: vec![ + JavaRuntimeSummary { + binary: r"D:\ULL\.ULL\data\java-managed\jdk-17-temurin\bin\java.exe" + .to_string(), + version: Some("17.0.13".to_string()), + major: Some(17), + vendor: Some("temurin".to_string()), + origin: "managed".to_string(), + }, + JavaRuntimeSummary { + binary: "/usr/bin/java".to_string(), + version: Some("21.0.2".to_string()), + major: Some(21), + vendor: None, + origin: "PATH".to_string(), + }, + ], + }, + fail: None, + } + } + + /// 让三个查询全部返回同一个错误(测端口错误透传)。 + #[must_use] + pub fn failing(message: &str) -> Self { + Self { + fail: Some(message.to_string()), + ..Self::new() + } + } + + fn fail(&self) -> Option { + self.fail.clone() + } +} + +impl GamePort for MockGame { + fn list_instances(&self) -> PortResult> { + self.fail().map_or_else(|| Ok(self.instances.clone()), Err) + } + + fn list_versions(&self) -> PortResult> { + self.fail().map_or_else(|| Ok(self.versions.clone()), Err) + } + + fn java_status(&self) -> PortResult { + self.fail().map_or_else(|| Ok(self.java.clone()), Err) + } +} + +/// 内存账户端口:一个离线账户。**只有公开字段**,凭据无处可放。 +#[derive(Debug, Clone)] +pub struct MockAccounts { + accounts: Vec, + fail: Option, +} + +impl Default for MockAccounts { + fn default() -> Self { + Self::new() + } +} + +impl MockAccounts { + /// 一个离线账户(Steve)。 + #[must_use] + pub fn new() -> Self { + Self { + accounts: vec![AccountSummary { + id: "acc-1".to_string(), + kind: "offline".to_string(), + profile_id: "8667ba71b85a4004af54457a9734eed7".to_string(), + profile_name: "Steve".to_string(), + login_name: None, + created_at_ms: 1_793_304_753_123, + }], + fail: None, + } + } + + /// 空账户列表(测「没有账户」的提示文案)。 + #[must_use] + pub fn empty() -> Self { + Self { + accounts: Vec::new(), + fail: None, + } + } + + /// 让查询失败(测端口错误透传)。 + #[must_use] + pub fn failing(message: &str) -> Self { + Self { + fail: Some(message.to_string()), + ..Self::new() + } + } +} + +impl AccountPort for MockAccounts { + fn list_accounts(&self) -> PortResult> { + self.fail + .clone() + .map_or_else(|| Ok(self.accounts.clone()), Err) + } +} diff --git a/crates/agent/src/testing/settings.rs b/crates/agent/src/testing/settings.rs new file mode 100644 index 0000000..d72c518 --- /dev/null +++ b/crates/agent/src/testing/settings.rs @@ -0,0 +1,201 @@ +//! 内存配置端口(`SettingPort` 的测试替身)。 + +use std::collections::BTreeMap; +use std::sync::Mutex; + +use serde_json::{json, Map, Value}; + +use crate::confirm::Sensitivity; +use crate::port::{PortResult, SettingChange, SettingKey, SettingPort}; + +/// 内存配置端口。 +#[derive(Debug, Default)] +pub struct MockSettings { + keys: Vec, + values: Mutex>, + fail_set: Mutex>, + set_calls: Mutex, +} + +/// 与 `PlanDoc/04` §3 / M12 `setting-接口约定.md` §8 的键表一致的默认白名单 +/// (覆盖三档敏感度;`ai.api_key` 这类凭据是 `writable=false`,**故意不在这里**)。 +fn default_keys() -> Vec { + fn key( + path: &str, + sensitivity: Sensitivity, + value_type: &str, + description: &str, + ) -> SettingKey { + SettingKey { + path: path.to_string(), + sensitivity, + value_type: value_type.to_string(), + description: description.to_string(), + } + } + vec![ + key( + "appearance.theme_mode", + Sensitivity::Low, + "enum", + "界面主题", + ), + key( + "download.concurrency", + Sensitivity::Low, + "integer", + "下载并发(1~256)", + ), + key( + "java.default_memory_mb", + Sensitivity::Low, + "integer", + "最大内存(512~65536 MB)", + ), + key("download.region", Sensitivity::Medium, "enum", "下载源区域"), + key("ai.model", Sensitivity::Medium, "text", "AI 模型名"), + key( + "java.selected_path", + Sensitivity::High, + "path", + "Java 可执行文件路径", + ), + key("download.proxy_url", Sensitivity::High, "text", "HTTP 代理"), + key("ai.base_url", Sensitivity::High, "text", "AI 端点"), + key( + "core.data_mode", + Sensitivity::High, + "enum", + "数据目录模式(portable/standard/custom)", + ), + ] +} + +impl MockSettings { + /// 用默认白名单 + 一批初值造一个。 + #[must_use] + pub fn new() -> Self { + let mut values = BTreeMap::new(); + values.insert("appearance.theme_mode".to_string(), json!("metro_dark")); + values.insert("download.concurrency".to_string(), json!(8)); + values.insert("java.default_memory_mb".to_string(), json!(2048)); + Self { + keys: default_keys(), + values: Mutex::new(values), + fail_set: Mutex::new(None), + set_calls: Mutex::new(0), + } + } + + /// 让接下来的 `set` 全部失败(测错误透传)。 + pub fn fail_set_with(&self, message: &str) { + if let Ok(mut slot) = self.fail_set.lock() { + *slot = Some(message.to_string()); + } + } + + /// 当前值(断言「未确认时没有写进去」)。 + #[must_use] + pub fn value_of(&self, path: &str) -> Option { + self.values.lock().ok().and_then(|v| v.get(path).cloned()) + } + + /// 直接塞一个值,模拟 M12 真实实现里存在的**任意键**(含凭据键)。 + /// + /// 白名单以外的键也能塞——测「`get_config` 不把凭据读给模型」需要它 + /// (`ai.api_key` 是 `writable=false`,故意不在白名单里)。 + pub fn seed_value(&self, path: &str, value: Value) { + if let Ok(mut values) = self.values.lock() { + values.insert(path.to_string(), value); + } + } + + /// 成功写入次数。 + #[must_use] + pub fn set_calls(&self) -> usize { + self.set_calls.lock().map(|n| *n).unwrap_or(0) + } +} + +impl SettingPort for MockSettings { + fn summary(&self) -> Value { + let values = self.values.lock().map(|v| v.clone()).unwrap_or_default(); + let mut map = serde_json::Map::new(); + for key in &self.keys { + let value = values.get(&key.path).cloned().unwrap_or(Value::Null); + map.insert(key.path.clone(), value); + } + Value::Object(map) + } + + fn get(&self, path: &str) -> PortResult { + self.values + .lock() + .ok() + .and_then(|v| v.get(path).cloned()) + .ok_or_else(|| format!("配置键 `{path}` 没有值")) + } + + fn set(&self, path: &str, value: &Value) -> PortResult { + if let Ok(slot) = self.fail_set.lock() { + if let Some(message) = slot.as_ref() { + return Err(message.clone()); + } + } + let old = self.get(path).ok(); + if let Ok(mut values) = self.values.lock() { + values.insert(path.to_string(), value.clone()); + } + if let Ok(mut calls) = self.set_calls.lock() { + *calls += 1; + } + Ok(SettingChange { + path: path.to_string(), + old, + new: value.clone(), + }) + } + + fn writable_keys(&self) -> Vec { + self.keys.clone() + } + + fn schema(&self) -> Value { + // 与 `core::setting::catalog::SettingSchema::to_json_schema` 同形: + // 只含白名单键 + `x-ai-sensitivity`,凭据键天然缺席。 + let mut properties = Map::new(); + for key in &self.keys { + properties.insert( + key.path.clone(), + json!({ + "type": key.value_type, + "description": key.description, + "x-ai-sensitivity": key.sensitivity, + }), + ); + } + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ull.launcher", + "type": "object", + "additionalProperties": false, + "properties": properties, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_excludes_credential_keys() { + let schema = MockSettings::new().schema(); + let json = schema.to_string(); + assert!( + !json.contains("ai.api_key"), + "schema 不得包含凭据键:{json}" + ); + assert!(json.contains("download.concurrency")); + } +} diff --git a/crates/agent/src/tools/accounts.rs b/crates/agent/src/tools/accounts.rs new file mode 100644 index 0000000..40f0661 --- /dev/null +++ b/crates/agent/src/tools/accounts.rs @@ -0,0 +1,110 @@ +//! `list_accounts`:账户清单(**凭据永不出口**)。 +//! +//! 这个工具存在的理由很具体:用户常问「我有哪几个号 / 这局用的是哪个号」。 +//! 但 D4/D9 的凭据红线是硬的——工具结果会回灌模型、也可能被发往远端 LLM, +//! 所以: +//! 1. 端口只回 [`crate::port::AccountSummary`](`AccountMeta` 的公开字段); +//! 2. 本文件里**没有任何**能表达 token / 密码的类型(连字段名都不出现); +//! 3. 工具结果再过一遍 [`crate::redact`],兜住实现方把凭据塞进 `profile_name` 的意外。 + +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::port::AccountPort; +use crate::registry::schema::object; +use crate::registry::{ + RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec, +}; + +/// 工具名。 +pub const LIST_ACCOUNTS: &str = "list_accounts"; + +/// 注册 `list_accounts`。 +/// +/// # Errors +/// 工具名重复/schema 非法时返回 [`RegistryError`]。 +pub fn register( + registry: &mut ToolRegistry, + port: Arc, +) -> Result<(), RegistryError> { + let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, _args: &Value| { + let accounts = port.list_accounts().map_err(ToolError::Port)?; + let count = accounts.len(); + let display: Vec = accounts + .iter() + .map(|a| { + if a.profile_name.trim().is_empty() { + format!("{}({})", a.id, a.kind) + } else { + format!("{}({})", a.profile_name, a.kind) + } + }) + .collect(); + let mut content = json!({ + "count": count, + "accounts": accounts, + "credential_policy": "账户凭据(access token / 刷新令牌 / authlib 密码)永不经由 AI 工具出口", + }); + // 兜底脱敏:端口实现若把敏感串塞进了公开字段,这里也不会带出去。 + content = crate::redact::redact_texts(&content); + Ok(ToolOutput::ok( + content, + if count == 0 { + "还没有任何账户".to_string() + } else { + format!("账户 {count} 个:{}", display.join("、")) + }, + )) + }); + + registry.register(ToolSpec::read_only( + LIST_ACCOUNTS, + "列出启动器里已登录的账户(展示名 / 轨道 offline|microsoft|authlib / 档案 UUID / 创建时间)。**永不返回任何凭据**:令牌与密码不在工具结果的类型里,需要登录态请让用户在启动器界面查看。", + object(&[], &[]), + handler, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::MockAccounts; + + fn registry(port: Arc) -> ToolRegistry { + let mut registry = ToolRegistry::new(); + register(&mut registry, port).expect("注册应成功"); + registry + } + + fn call(registry: &ToolRegistry) -> Value { + let spec = registry.get(LIST_ACCOUNTS).expect("工具存在").clone(); + let ctx = ToolCtx { + agent_session_id: "s1".to_string(), + confirmed: false, + }; + (spec.handler)(&ctx, &json!({})).expect("应成功").content + } + + #[test] + fn lists_accounts_without_credentials() { + let content = call(®istry(Arc::new(MockAccounts::new()))); + assert_eq!(content["count"], json!(1)); + assert_eq!(content["accounts"][0]["profile_name"], json!("Steve")); + // 只扫账户数据本身:`credential_policy` 那句说明文字里本来就写着 "token"。 + let fields = content["accounts"].to_string().to_lowercase(); + for forbidden in ["token", "password", "refresh", "secret"] { + assert!( + !fields.contains(forbidden), + "账户字段含 `{forbidden}`:{fields}" + ); + } + } + + #[test] + fn empty_list_is_not_an_error() { + let content = call(®istry(Arc::new(MockAccounts::empty()))); + assert_eq!(content["count"], json!(0)); + assert_eq!(content["accounts"], json!([])); + } +} diff --git a/crates/agent/src/tools/config.rs b/crates/agent/src/tools/config.rs new file mode 100644 index 0000000..728771e --- /dev/null +++ b/crates/agent/src/tools/config.rs @@ -0,0 +1,345 @@ +//! `get_config` / `set_config`(02 文档 §3.1「有权限修改 ULL 各项配置」)。 +//! +//! 安全边界都落在这里,但**分级确认的"要不要问"不在这里**: +//! 工具只负责「白名单过滤 → 生成 diff → 需要确认时只报价不执行」, +//! 问不问由编排层按 [`crate::confirm::ConfirmPolicy`] 决定(YOLO 也只改那一处)。 +//! +//! 值校验(类型/范围/schema)归 M12 的配置 schema 所有:agent 不复制一份, +//! 否则两处规则迟早漂移(02 文档 §3.1「schema 强校验」)。 + +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::confirm::Sensitivity; +use crate::port::{SettingKey, SettingPort}; +use crate::registry::schema::{any, non_empty_string, object, string}; +use crate::registry::{ + RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec, +}; + +/// 读配置。 +pub const GET_CONFIG: &str = "get_config"; +/// 写配置。 +pub const SET_CONFIG: &str = "set_config"; +/// 错误提示里最多列多少个可写键(防"把白名单灌给模型")。 +pub const MAX_LISTED_KEYS: usize = 16; +/// 摘要里值的最大展示长度。 +const BRIEF_CHARS: usize = 60; + +/// 注册两个配置工具。 +/// +/// # Errors +/// 工具名重复/schema 非法时返回 [`RegistryError`]。 +pub fn register( + registry: &mut ToolRegistry, + port: Arc, +) -> Result<(), RegistryError> { + registry.register(get_config_spec(Arc::clone(&port)))?; + registry.register(set_config_spec(port)) +} + +fn get_config_spec(port: Arc) -> ToolSpec { + let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| { + let path = args + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|p| !p.is_empty()); + match path { + None => { + let summary = port.summary(); + let writable = port.writable_keys(); + Ok(ToolOutput::ok( + json!({ + "scope": "all", + "values": summary, + "writable_keys": writable.iter().map(describe_key).collect::>(), + }), + format!( + "读取全部配置概要({} 个键,其中 {} 个 AI 可写)", + summary.as_object().map_or(0, serde_json::Map::len), + writable.len() + ), + )) + } + Some(path) => { + // 凭据类键一律不读:值会进 ToolOutput.content → 回灌模型 → 发往远端 LLM, + // 违反本 crate 的硬边界(AI 永不接触账户凭据,见 `lib.rs`)。 + // 这是 agent 侧的第二道防线:第一道是 M12 不把凭据放白名单/概要。 + if crate::redact::is_sensitive_key(path) { + return Err(ToolError::NotAllowed(format!( + "`{path}` 是凭据类配置,AI 不可读取也不可写入;请让用户自己在设置页查看。" + ))); + } + let value = port.get(path).map_err(ToolError::Port)?; + Ok(ToolOutput::ok( + json!({ "scope": "key", "path": path, "value": value }), + format!("读取配置 `{path}` = {}", brief(&value)), + )) + } + } + }); + + ToolSpec::read_only( + GET_CONFIG, + "读取启动器配置。不带 path 返回全部配置概要(含 AI 可写键及其敏感等级);带 path 返回单个键的值。", + object( + &[( + "path", + string("配置键点分路径,如 `download.concurrency`;省略则返回全部概要"), + )], + &[], + ), + handler, + ) +} + +fn set_config_spec(port: Arc) -> ToolSpec { + let handler: ToolFn = + Arc::new(move |ctx: &ToolCtx, args: &Value| apply_set_config(port.as_ref(), ctx, args)); + + ToolSpec::mutating( + SET_CONFIG, + "修改启动器配置(仅限白名单内的键)。普通键立即生效;敏感键(Java 路径/数据目录/代理/AI 端点)会先返回 needs_confirmation,等用户确认后才会真正写入。值必须符合该键的类型与范围。", + object( + &[ + ("path", non_empty_string("配置键点分路径,必须是 get_config 列出的可写键")), + ("value", any()), + ], + &["path", "value"], + ), + handler, + ) +} + +/// `set_config` 的实现(独立成函数便于单测直接调用)。 +/// +/// # Errors +/// 键不在白名单、值缺失、端口写入失败时返回 [`ToolError`]。 +pub fn apply_set_config( + port: &dyn SettingPort, + ctx: &ToolCtx, + args: &Value, +) -> Result { + let path = args + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|p| !p.is_empty()) + .ok_or_else(|| ToolError::NotAllowed("缺少参数 `path`".to_string()))? + .to_string(); + let value = args + .get("value") + .cloned() + .ok_or_else(|| ToolError::NotAllowed("缺少参数 `value`".to_string()))?; + + let keys = port.writable_keys(); + let key = keys + .iter() + .find(|k| k.path == path) + .ok_or_else(|| ToolError::NotAllowed(not_writable_message(&keys, &path)))?; + + // 旧值读不到不影响写(首次设置),只是 diff 少一半。 + let old = port.get(&path).ok(); + + if !ctx.confirmed && key.sensitivity == Sensitivity::High { + let diff = json!({ + "path": path, + "old": old, + "new": value, + "sensitivity": key.sensitivity, + "description": key.description, + }); + return Ok(ToolOutput::needs_confirmation( + format!( + "AI 请求把 `{path}` 从 {} 改为 {}({})", + brief_opt(&old), + brief(&value), + sensitivity_label(key.sensitivity) + ), + diff, + key.sensitivity, + )); + } + + let change = port.set(&path, &value).map_err(ToolError::Port)?; + Ok(ToolOutput::ok( + json!({ + "status": "written", + "path": change.path, + "old": change.old, + "new": change.new, + "sensitivity": key.sensitivity, + }), + format!( + "已把 `{}` 从 {} 改为 {}", + change.path, + brief_opt(&change.old), + brief(&change.new) + ), + )) +} + +/// 白名单拒绝的文案:说清"不能写"并给出可写键(模型会据此改用正确路径)。 +fn not_writable_message(keys: &[SettingKey], path: &str) -> String { + let listed: Vec<&str> = keys + .iter() + .take(MAX_LISTED_KEYS) + .map(|k| k.path.as_str()) + .collect(); + let rest = keys.len().saturating_sub(listed.len()); + let tail = if rest > 0 { + format!(",另有 {rest} 个键见 get_config 概要") + } else { + String::new() + }; + format!( + "配置键 `{path}` 不在 AI 可写白名单内。可写键({} 个):{}{tail}", + keys.len(), + listed.join("、") + ) +} + +fn describe_key(key: &SettingKey) -> Value { + json!({ + "path": key.path, + "sensitivity": key.sensitivity, + "value_type": key.value_type, + "description": key.description, + }) +} + +fn sensitivity_label(sensitivity: Sensitivity) -> &'static str { + match sensitivity { + Sensitivity::Low => "普通", + Sensitivity::Medium => "中等(静默但审计)", + Sensitivity::High => "敏感,需确认", + } +} + +fn brief(value: &Value) -> String { + let rendered = match value { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + truncate(&rendered) +} + +fn brief_opt(value: &Option) -> String { + value + .as_ref() + .map_or_else(|| "(未设置)".to_string(), brief) +} + +fn truncate(text: &str) -> String { + if text.chars().count() <= BRIEF_CHARS { + return text.to_string(); + } + let head: String = text.chars().take(BRIEF_CHARS).collect(); + format!("{head}…") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::MockSettings; + + fn ctx(confirmed: bool) -> ToolCtx { + ToolCtx { + agent_session_id: "s1".to_string(), + confirmed, + } + } + + #[test] + fn rejects_keys_outside_whitelist() { + let port = MockSettings::new(); + let err = apply_set_config( + &port, + &ctx(true), + &json!({ "path": "java.random.thing", "value": 1 }), + ) + .unwrap_err(); + assert!(matches!(err, ToolError::NotAllowed(_))); + assert!(err.message().contains("不在 AI 可写白名单内")); + assert!(err.message().contains("download.concurrency")); + assert_eq!(port.set_calls(), 0); + } + + #[test] + fn normal_key_writes_without_confirmation() { + let port = MockSettings::new(); + let out = apply_set_config( + &port, + &ctx(false), + &json!({ "path": "download.concurrency", "value": 16 }), + ) + .unwrap(); + assert!(out.confirmation.is_none()); + assert_eq!(out.content["status"], json!("written")); + assert_eq!(port.value_of("download.concurrency"), Some(json!(16))); + } + + #[test] + fn medium_key_writes_silently_too() { + // M12:Medium = 静默生效但审计标记,不弹窗 + let port = MockSettings::new(); + let out = apply_set_config( + &port, + &ctx(false), + &json!({ "path": "download.region", "value": "cn" }), + ) + .unwrap(); + assert!(out.confirmation.is_none()); + assert_eq!(port.set_calls(), 1); + } + + #[test] + fn high_key_quotes_first_then_writes_after_confirm() { + let port = MockSettings::new(); + let args = json!({ "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" }); + + let quoted = apply_set_config(&port, &ctx(false), &args).unwrap(); + let confirmation = quoted.confirmation.expect("High 键应要求确认"); + assert_eq!(confirmation.sensitivity, Sensitivity::High); + assert_eq!(confirmation.details["new"], json!("D:/jdk17/bin/java.exe")); + assert_eq!(port.set_calls(), 0, "未确认前不得写入"); + + let written = apply_set_config(&port, &ctx(true), &args).unwrap(); + assert!(written.confirmation.is_none()); + assert_eq!(port.set_calls(), 1); + assert_eq!( + port.value_of("java.selected_path"), + Some(json!("D:/jdk17/bin/java.exe")) + ); + } + + #[test] + fn locked_credential_key_is_not_writable() { + // M12:ai.api_key 是 writable=false,不在白名单里 → 任何情况下都拒绝 + let port = MockSettings::new(); + let err = apply_set_config( + &port, + &ctx(true), + &json!({ "path": "ai.api_key", "value": "sk-x" }), + ) + .unwrap_err(); + assert!(matches!(err, ToolError::NotAllowed(_))); + assert_eq!(port.set_calls(), 0); + } + + #[test] + fn port_error_is_passed_through() { + let port = MockSettings::new(); + port.fail_set_with("磁盘只读"); + let err = apply_set_config( + &port, + &ctx(true), + &json!({ "path": "appearance.theme_mode", "value": "metro_light" }), + ) + .unwrap_err(); + assert_eq!(err.code(), "port_error"); + assert!(err.message().contains("磁盘只读")); + } +} diff --git a/crates/agent/src/tools/diagnose.rs b/crates/agent/src/tools/diagnose.rs new file mode 100644 index 0000000..273797e --- /dev/null +++ b/crates/agent/src/tools/diagnose.rs @@ -0,0 +1,335 @@ +//! `diagnose_crash`(02 文档 §3.2 第三条)。 +//! +//! 流水线是「**规则在前、模型补位**」(analyse/02 §8.3):本工具只跑 M15 的规则引擎, +//! 把**结构化结论 + 关键片段**交给模型,绝不让模型读裸日志。规则未命中 +//! (`candidates` 空 / `needs_more_info`)时,模型再去用 `read_game_log` 自己找证据。 +//! +//! P0 与 M15 的桥:M15 的修复动作指向 `java.set` / `memory.set` 这些 **P1 才有的**语义工具 id。 +//! P0 只有 `set_config`,所以这里按 [`FixActionKind`] 生成 `p0_next_steps` +//! (`adjust_memory` → `set_config {"path":"java.default_memory_mb"}`,`change_java` → +//! `set_config {"path":"java.selected_path"}`,见 [`MEMORY_KEY`] / [`JAVA_KEY`]), +//! 剩下的动作明确标成「P0 无工具,解释给用户」。 + +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::port::{CrashDiagnosis, DiagnosePort, DiagnosisCandidate, FixAction, FixActionKind}; +use crate::redact::redact_text; +use crate::registry::schema::{integer, non_empty_string, object}; +use crate::registry::{ + RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec, +}; + +/// 诊断工具名。 +pub const DIAGNOSE_CRASH: &str = "diagnose_crash"; +/// 单次返回的证据行上限(防 token 爆炸)。 +pub const MAX_EVIDENCE_LINES: usize = 40; +/// 证据行默认条数。 +pub const DEFAULT_EVIDENCE_LINES: usize = 20; +/// 单条证据最大字符数。 +pub const MAX_EVIDENCE_CHARS: usize = 400; +/// 内存建议的配置键(M12 白名单里的键名,见 `setting-接口约定.md` §8)。 +pub const MEMORY_KEY: &str = "java.default_memory_mb"; +/// Java 路径的配置键。 +pub const JAVA_KEY: &str = "java.selected_path"; + +/// 注册诊断工具。 +/// +/// # Errors +/// 工具名重复/schema 非法时返回 [`RegistryError`]。 +pub fn register( + registry: &mut ToolRegistry, + port: Arc, +) -> Result<(), RegistryError> { + let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| { + let session_id = args + .get("session_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ToolError::NotAllowed("缺少参数 `session_id`".to_string()))?; + let max_lines = args + .get("max_evidence_lines") + .and_then(Value::as_u64) + .map_or(DEFAULT_EVIDENCE_LINES, |n| n as usize) + .clamp(1, MAX_EVIDENCE_LINES); + let report = port.diagnose(session_id).map_err(ToolError::Port)?; + Ok(render(&report, max_lines)) + }); + + registry.register(ToolSpec::read_only( + DIAGNOSE_CRASH, + "对某次游戏会话跑规则引擎(59 条崩溃规则)并返回结构化结论:主因、置信度、证据行、修复动作。规则未命中时会提示需要更多信息——此时再用 read_game_log 取 stderr/exit 片段。", + object( + &[ + ("session_id", non_empty_string("会话 id,来自 list_game_sessions")), + ( + "max_evidence_lines", + integer("返回多少条证据行", 1, MAX_EVIDENCE_LINES as i64), + ), + ], + &["session_id"], + ), + handler, + )) +} + +/// 把规则引擎结论压成工具结果。 +fn render(report: &CrashDiagnosis, max_evidence_lines: usize) -> ToolOutput { + let candidates: Vec = report + .candidates + .iter() + .map(|c| render_candidate(c, max_evidence_lines)) + .collect(); + let secondary: Vec = report + .secondary + .iter() + .take(5) + .map(|c| { + json!({ + "id": c.id, + "severity": c.severity, + "category": c.category, + "confidence": c.confidence, + "message": message_of(c), + }) + }) + .collect(); + let next_steps: Vec = report + .candidates + .iter() + .flat_map(|c| c.fix_actions.iter()) + .map(bridge_to_p0) + .collect(); + + let content = json!({ + "session_id": report.session_id, + "source": report.source, + "needs_more_info": report.needs_more_info, + "exit": report.exit, + "top_confidence": report.top_confidence(), + "candidates": candidates, + "secondary": secondary, + "evidence": cap_lines(&report.evidence, max_evidence_lines), + "p0_next_steps": next_steps, + }); + + ToolOutput::ok(content, summarize(report)) +} + +fn render_candidate(candidate: &DiagnosisCandidate, max_evidence_lines: usize) -> Value { + json!({ + "id": candidate.id, + "severity": candidate.severity, + "category": candidate.category, + "confidence": candidate.confidence, + "message": message_of(candidate), + "detail": candidate.detail, + "evidence": cap_lines(&candidate.evidence, max_evidence_lines), + "fix_actions": candidate.fix_actions, + }) +} + +/// `message` 为空时回落显示 i18n key(装配层没渲染时至少不丢信息)。 +fn message_of(candidate: &DiagnosisCandidate) -> String { + let text = candidate.message.trim(); + if text.is_empty() { + candidate.message_key.clone() + } else { + text.to_string() + } +} + +fn summarize(report: &CrashDiagnosis) -> String { + match report.top() { + Some(top) => { + let label = top + .fix_actions + .first() + .map(action_label) + .unwrap_or_else(|| "没有可执行的修复动作".to_string()); + format!( + "规则引擎命中 {} 条候选,主因 `{}`(置信度 {:.2});建议:{label}", + report.candidates.len(), + top.id, + top.confidence + ) + } + None if report.needs_more_info => { + "规则引擎未命中,且需要更多信息(用 read_game_log 取 stderr/exit 片段后再判断)" + .to_string() + } + None => "规则引擎未命中(59 条规则都不匹配),需要结合日志片段人工/AI 判断".to_string(), + } +} + +/// M15 的语义动作 → P0 现有工具。返回给模型看的"下一步"。 +fn bridge_to_p0(action: &FixAction) -> Value { + let label = action_label(action); + match action.kind { + FixActionKind::AdjustMemory => { + let value = pick_memory_mb(action).map_or(Value::Null, |mb| json!(mb)); + json!({ + "action": action.tool, + "kind": action.kind, + "label": label, + "suggested_tool": "set_config", + "suggested_args": { "path": MEMORY_KEY, "value": value }, + }) + } + FixActionKind::ChangeJava => { + let value = pick_arg(action, &["path", "java_path", "binary", "value"]) + .map_or(Value::Null, |p| json!(p)); + json!({ + "action": action.tool, + "kind": action.kind, + "label": label, + "suggested_tool": "set_config", + "suggested_args": { "path": JAVA_KEY, "value": value }, + }) + } + other => json!({ + "action": action.tool, + "kind": other, + "label": label, + "suggested_tool": Value::Null, + "unavailable_reason": "P0 没有对应工具(P1 落地),请把这个建议解释给用户", + }), + } +} + +/// 动作的人话:优先 M15 给的 `label`,否则按 kind 兜底。 +fn action_label(action: &FixAction) -> String { + let label = action.label.trim(); + if !label.is_empty() { + return label.to_string(); + } + match action.kind { + FixActionKind::ChangeJava => "换一个匹配的 Java 版本".to_string(), + FixActionKind::AdjustMemory => "调整最大内存".to_string(), + FixActionKind::ChangeGraphics => "调整图形设置/驱动".to_string(), + FixActionKind::RemoveMod => "移除或替换冲突的 Mod".to_string(), + FixActionKind::Reinstall => "重装/修复对应组件".to_string(), + FixActionKind::SeekHelp => "导出诊断包并反馈".to_string(), + } +} + +/// 从动作参数里挑内存上限:先看常见键,再退化成"第一个带数字的值"。 +fn pick_memory_mb(action: &FixAction) -> Option { + if let Some(value) = pick_arg( + action, + &["value", "suggest", "mb", "max_mb", "xmx", "xmx_mb"], + ) { + if let Some(mb) = parse_mb(&value) { + return Some(mb); + } + } + action.args.values().find_map(|v| parse_mb(v)) +} + +fn pick_arg(action: &FixAction, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| action.args.get(*key).cloned()) + .filter(|v| !v.trim().is_empty()) +} + +/// `"4096"` / `"4G"` / `"6144M"` → MB。 +fn parse_mb(raw: &str) -> Option { + let text = raw.trim().to_ascii_lowercase(); + let (number, scale) = if let Some(rest) = text.strip_suffix('g') { + (rest, 1024) + } else if let Some(rest) = text.strip_suffix("gb") { + (rest, 1024) + } else if let Some(rest) = text.strip_suffix('m') { + (rest, 1) + } else if let Some(rest) = text.strip_suffix("mb") { + (rest, 1) + } else { + (text.as_str(), 1) + }; + number.trim().parse::().ok().map(|n| n * scale) +} + +fn cap_lines(lines: &[String], max_lines: usize) -> Vec { + lines + .iter() + .take(max_lines) + .map(|line| { + let masked = redact_text(line); + if masked.chars().count() <= MAX_EVIDENCE_CHARS { + masked + } else { + let head: String = masked.chars().take(MAX_EVIDENCE_CHARS).collect(); + format!("{head}…") + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::MockDiagnose; + + fn call(port: &MockDiagnose, session_id: &str) -> ToolOutput { + let report = port.diagnose(session_id).expect("mock 应返回结论"); + render(&report, DEFAULT_EVIDENCE_LINES) + } + + #[test] + fn renders_candidates_with_p0_bridge_for_reinstall() { + let out = call(&MockDiagnose::new(), "s-1"); + assert_eq!(out.content["candidates"].as_array().unwrap().len(), 1); + let confidence = out.content["top_confidence"].as_f64().unwrap(); + assert!((confidence - 0.86).abs() < 1e-6, "{confidence}"); + let steps = out.content["p0_next_steps"].as_array().unwrap(); + assert_eq!(steps[0]["kind"], json!("reinstall")); + assert_eq!(steps[0]["suggested_tool"], Value::Null); + assert!(out.summary.contains("建议:重装该版本的 Forge 组件")); + } + + #[test] + fn bridges_memory_action_to_set_config() { + let mut report = MockDiagnose::forge_missing_library(); + if let Some(candidate) = report.candidates.first_mut() { + candidate.fix_actions = vec![FixAction { + kind: FixActionKind::AdjustMemory, + tool: "memory.set".to_string(), + label: String::new(), + args: [("suggest".to_string(), "4G".to_string())] + .into_iter() + .collect(), + }]; + } + let out = render(&report, 5); + let steps = out.content["p0_next_steps"].as_array().unwrap(); + assert_eq!(steps[0]["suggested_tool"], json!("set_config")); + assert_eq!(steps[0]["suggested_args"]["path"], json!(MEMORY_KEY)); + assert_eq!(steps[0]["suggested_args"]["value"], json!(4096)); + assert_eq!(steps[0]["label"], json!("调整最大内存")); + } + + #[test] + fn parses_memory_units() { + assert_eq!(parse_mb("4096"), Some(4096)); + assert_eq!(parse_mb("4G"), Some(4096)); + assert_eq!(parse_mb("6144mb"), Some(6144)); + assert_eq!(parse_mb("abc"), None); + } + + #[test] + fn empty_candidates_says_need_more_info() { + let report = CrashDiagnosis { + session_id: "s-2".into(), + source: "rules".into(), + needs_more_info: true, + candidates: vec![], + secondary: vec![], + evidence: vec![], + exit: crate::port::ExitSummary::default(), + }; + let out = render(&report, 5); + assert!(out.summary.contains("需要更多信息")); + } +} diff --git a/crates/agent/src/tools/game.rs b/crates/agent/src/tools/game.rs new file mode 100644 index 0000000..6e5fcfd --- /dev/null +++ b/crates/agent/src/tools/game.rs @@ -0,0 +1,199 @@ +//! 实例 / 版本 / Java 三个只读工具(M39 任务 3 的读侧)。 +//! +//! 全部只读、全部走 [`crate::port::GamePort`]:agent 不自己去翻 `versions/` 目录, +//! 也不自己扫 JDK——那两件事的真相源在 M14(game)与 M33(java),重复一份必然漂。 +//! +//! 输出形状刻意做成「摘要 + 上限」:实例可能几十个、Java 候选可能十几个, +//! 一次全塞给模型等于烧 token(02 §3.2 的「单次返回上限防 token 爆炸」同理)。 + +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::port::GamePort; +use crate::registry::schema::{integer, object}; +use crate::registry::{ + RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec, +}; + +/// 实例列表工具名。 +pub const LIST_INSTANCES: &str = "list_instances"; +/// 版本列表工具名。 +pub const LIST_VERSIONS: &str = "list_versions"; +/// Java 现状工具名。 +pub const GET_JAVA_STATUS: &str = "get_java_status"; +/// 单次最多返回多少个实例/版本。 +pub const MAX_ROWS: usize = 50; + +/// 注册三个只读工具。 +/// +/// # Errors +/// 工具名重复/schema 非法时返回 [`RegistryError`]。 +pub fn register(registry: &mut ToolRegistry, port: Arc) -> Result<(), RegistryError> { + registry.register(list_instances_spec(Arc::clone(&port)))?; + registry.register(list_versions_spec(Arc::clone(&port)))?; + registry.register(java_status_spec(port)) +} + +fn limit_arg(args: &Value) -> usize { + args.get("limit") + .and_then(Value::as_u64) + .map_or(MAX_ROWS, |n| usize::try_from(n).unwrap_or(MAX_ROWS)) + .clamp(1, MAX_ROWS) +} + +fn limit_schema() -> Value { + integer( + &format!("最多返回多少条(1~{MAX_ROWS},默认 {MAX_ROWS})"), + 1, + MAX_ROWS as i64, + ) +} + +fn list_instances_spec(port: Arc) -> ToolSpec { + let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| { + let limit = limit_arg(args); + let mut instances = port.list_instances().map_err(ToolError::Port)?; + let total = instances.len(); + instances.truncate(limit); + Ok(ToolOutput::ok( + json!({ + "total": total, + "shown": instances.len(), + "truncated": total > instances.len(), + "instances": instances, + }), + format!("列出 {}/{} 个实例", instances.len(), total), + )) + }); + + ToolSpec::read_only( + LIST_INSTANCES, + "列出启动器里的游戏实例(id / 名称 / 版本 id / 载荷是否已安装 / 独立游戏目录 / 实例级 Java)。要看某个实例的日志先用 list_game_sessions。", + object(&[("limit", limit_schema())], &[]), + handler, + ) +} + +fn list_versions_spec(port: Arc) -> ToolSpec { + let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| { + let limit = limit_arg(args); + let mut versions = port.list_versions().map_err(ToolError::Port)?; + let total = versions.len(); + versions.truncate(limit); + Ok(ToolOutput::ok( + json!({ + "total": total, + "shown": versions.len(), + "truncated": total > versions.len(), + "versions": versions, + }), + format!("列出 {}/{} 个已安装版本", versions.len(), total), + )) + }); + + ToolSpec::read_only( + LIST_VERSIONS, + "列出已安装的游戏版本(`versions//` 下真实存在的载荷),带类型与被几个实例引用。判断「实例的载荷缺没缺」时配合 list_instances 的 version_present 一起看。", + object(&[("limit", limit_schema())], &[]), + handler, + ) +} + +fn java_status_spec(port: Arc) -> ToolSpec { + let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, _args: &Value| { + let status = port.java_status().map_err(ToolError::Port)?; + let majors: Vec = status + .candidates + .iter() + .filter_map(|c| c.major.map(|m| m.to_string())) + .collect(); + Ok(ToolOutput::ok( + json!({ + "default_memory_mb": status.default_memory_mb, + "managed_roots": status.managed_roots, + "candidate_count": status.candidates.len(), + "majors": majors, + "candidates": status.candidates, + }), + format!( + "Java 候选 {} 个(主版本 {})", + status.candidates.len(), + if majors.is_empty() { + "无".to_string() + } else { + majors.join("/") + } + ), + )) + }); + + ToolSpec::read_only( + GET_JAVA_STATUS, + "读取 Java 现状:托管扫描根、默认堆内存、探测到的全部运行时(路径 / 版本 / 主版本 / 发行方 / 来源)。启动报「不支持的 class 版本」「找不到 Java」时先看这里。", + object(&[], &[]), + handler, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::MockGame; + + fn registry() -> ToolRegistry { + let mut registry = ToolRegistry::new(); + register(&mut registry, Arc::new(MockGame::new())).expect("注册应成功"); + registry + } + + fn call(registry: &ToolRegistry, name: &str, args: Value) -> Value { + let spec = registry.get(name).expect("工具存在").clone(); + let ctx = ToolCtx { + agent_session_id: "s1".to_string(), + confirmed: false, + }; + (spec.handler)(&ctx, &args).expect("应成功").content + } + + #[test] + fn lists_instances_with_limit() { + let content = call(®istry(), LIST_INSTANCES, json!({ "limit": 1 })); + assert_eq!(content["total"], json!(1)); + assert_eq!(content["instances"][0]["id"], json!("inst-1")); + assert_eq!(content["truncated"], json!(false)); + } + + #[test] + fn lists_versions() { + let content = call(®istry(), LIST_VERSIONS, json!({})); + assert_eq!(content["total"], json!(2)); + assert_eq!(content["versions"][0]["id"], json!("1.20.1-forge-47.2.20")); + } + + #[test] + fn java_status_summarises_majors() { + let content = call(®istry(), GET_JAVA_STATUS, json!({})); + assert_eq!(content["candidate_count"], json!(2)); + assert_eq!(content["majors"], json!(["17", "21"])); + assert_eq!(content["default_memory_mb"], json!(2048)); + } + + #[test] + fn port_errors_are_passed_through() { + let mut registry = ToolRegistry::new(); + register( + &mut registry, + Arc::new(MockGame::failing("versions 目录不可读")), + ) + .expect("注册应成功"); + let spec = registry.get(LIST_VERSIONS).expect("工具存在").clone(); + let ctx = ToolCtx { + agent_session_id: "s1".to_string(), + confirmed: false, + }; + let err = (spec.handler)(&ctx, &json!({})).unwrap_err(); + assert_eq!(err.code(), "port_error"); + assert!(err.message().contains("versions 目录不可读")); + } +} diff --git a/crates/agent/src/tools/mod.rs b/crates/agent/src/tools/mod.rs new file mode 100644 index 0000000..87b9094 --- /dev/null +++ b/crates/agent/src/tools/mod.rs @@ -0,0 +1,39 @@ +//! 工具集 v1(02 文档 §3):配置读写 / 会话与日志 / 崩溃诊断 / 实例与 Java / 账户。 +//! +//! 分工边界(重要,评审时按这条看): +//! - **工具**:参数语义 → 端口调用 → 结构化结果;需要确认时**只报价不执行**。 +//! - **编排层**([`crate::brain::exec`]):schema 校验、确认闸门、YOLO 判定、审计、事件。 +//! - **端口**([`crate::port`]):真正的 IO 与领域校验(M12/M14/M15/M18/M31/M33 拥有)。 +//! +//! 这样「YOLO」只在一个地方(编排层),工具的测试不需要关心 policy。 +//! +//! 双投影:这里的注册表是**唯一真相源**,内置客户端投成 rig `DynamicTool`, +//! 对外 MCP server 投成 rmcp `ToolRoute`([`crate::mcp`])。工具数量与顺序对外一致。 + +pub mod accounts; +pub mod config; +pub mod diagnose; +pub mod game; +pub mod schema_tool; +pub mod session; + +use crate::port::Ports; +use crate::registry::{RegistryError, ToolRegistry}; + +/// 组装 v1 的全部工具。注册顺序 = 模型看到的顺序(只读在前,写在后)。 +/// +/// 顺序不是随便排的:模型在长工具表里更容易选中靠前的项,所以「先看再改」 +/// (`get_config*` 在 `set_config` 之前)是刻意的提示。 +/// +/// # Errors +/// 工具名重复 / schema 非法时返回 [`RegistryError`](属于编码错误,装配期就该炸)。 +pub fn build_v1_registry(ports: &Ports) -> Result { + let mut registry = ToolRegistry::new(); + config::register(&mut registry, ports.setting.clone())?; + schema_tool::register(&mut registry, ports.setting.clone())?; + game::register(&mut registry, ports.game.clone())?; + accounts::register(&mut registry, ports.accounts.clone())?; + session::register(&mut registry, ports.sessions.clone())?; + diagnose::register(&mut registry, ports.diagnose.clone())?; + Ok(registry) +} diff --git a/crates/agent/src/tools/schema_tool.rs b/crates/agent/src/tools/schema_tool.rs new file mode 100644 index 0000000..a746ac4 --- /dev/null +++ b/crates/agent/src/tools/schema_tool.rs @@ -0,0 +1,84 @@ +//! `get_config_schema`:把 M12 的配置 schema(**只含 AI 可写键**)投给模型。 +//! +//! 为什么单独一个工具而不是塞进 `get_config`:模型要能「先看有哪些键、类型是什么、 +//! 哪些是敏感键」,再决定要不要写——这是一次性的静态信息,和读当前值不是同一件事。 +//! 拆开还能让 token 预算更可控(schema 比概要大一个量级)。 +//! +//! 凭据边界:schema 由 M12 的 `SettingSchema::to_json_schema()` 生成,只遍历 +//! `ai.writable && !deprecated` 的键,`ai.api_key`(`locked`)天然不在其中。 + +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::port::SettingPort; +use crate::registry::schema::object; +use crate::registry::{ + RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec, +}; + +/// 工具名。 +pub const GET_CONFIG_SCHEMA: &str = "get_config_schema"; + +/// 注册 `get_config_schema`。 +/// +/// # Errors +/// 工具名重复/schema 非法时返回 [`RegistryError`]。 +pub fn register( + registry: &mut ToolRegistry, + port: Arc, +) -> Result<(), RegistryError> { + let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, _args: &Value| { + let schema = port.schema(); + if schema.get("properties").is_none() { + return Err(ToolError::Port( + "配置 schema 不可用:装配层没有提供 SettingPort::schema()".to_string(), + )); + } + let writable = schema + .get("properties") + .and_then(Value::as_object) + .map_or(0, serde_json::Map::len); + Ok(ToolOutput::ok( + json!({ + "scope": "ai_writable_schema", + "writable_key_count": writable, + "schema": schema, + }), + format!("读取配置 schema({writable} 个 AI 可写键)"), + )) + }); + + registry.register(ToolSpec::read_only( + GET_CONFIG_SCHEMA, + "读取启动器配置的 JSON Schema(只含 AI 可写键)。每个键带类型、取值范围、说明与 `x-ai-sensitivity` 敏感等级;凭据类键不会出现。写配置前先看它。", + object(&[], &[]), + handler, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::MockSettings; + + fn ctx() -> ToolCtx { + ToolCtx { + agent_session_id: "s1".to_string(), + confirmed: false, + } + } + + #[test] + fn returns_schema_without_credentials() { + let port: Arc = Arc::new(MockSettings::new()); + let mut registry = ToolRegistry::new(); + register(&mut registry, port).expect("注册应成功"); + let spec = registry.get(GET_CONFIG_SCHEMA).expect("工具存在").clone(); + let out = (spec.handler)(&ctx(), &json!({})).expect("应返回 schema"); + let text = out.content.to_string(); + assert!(out.content["writable_key_count"].as_u64().unwrap_or(0) > 0); + assert!(text.contains("x-ai-sensitivity")); + assert!(!text.contains("ai.api_key"), "凭据键不得进 schema:{text}"); + } +} diff --git a/crates/agent/src/tools/session.rs b/crates/agent/src/tools/session.rs new file mode 100644 index 0000000..2d1c5aa --- /dev/null +++ b/crates/agent/src/tools/session.rs @@ -0,0 +1,388 @@ +//! `list_game_sessions` / `read_game_log`(02 文档 §3.2「能读取某次游戏崩溃日志」)。 +//! +//! 数据源是 M18 的会话事件流(`DevDocs/SubAgentsTalk/launch-接口约定.md`): +//! - 列表读 `index.jsonl`(每会话一行摘要,同 id 取最后一行); +//! - 日志读 `.jsonl`,**按 `seq` 切片 + 按 `kind` 过滤**,不是按行号。 +//! +//! 两条防 token 爆炸的硬约束: +//! 1. 单次最多 [`MAX_LOG_EVENTS`] 条事件(默认 [`DEFAULT_LOG_EVENTS`]); +//! 2. 单条 payload 里的字符串超过 [`MAX_PAYLOAD_CHARS`] 截断。 +//! +//! 脱敏:launch 落盘前已经做过一遍(`` / ``),本工具再跑一遍 +//! [`crate::redact::redact_text`] 作为第二道防线(幂等,旧会话/第三方 mod 输出可能漏网)。 + +use std::sync::Arc; + +use serde_json::{json, Map, Value}; + +use crate::port::{GameSessionPort, LogEvent, LogPage, LogQuery}; +use crate::redact::redact_text; +use crate::registry::schema::{enum_string_array, integer, non_empty_string, object, string}; +use crate::registry::{ + RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec, +}; + +/// 列出游戏会话。 +pub const LIST_GAME_SESSIONS: &str = "list_game_sessions"; +/// 读一个会话的日志。 +pub const READ_GAME_LOG: &str = "read_game_log"; + +/// 单次返回的事件上限(防 token 爆炸的硬闸)。 +pub const MAX_LOG_EVENTS: usize = 500; +/// 默认返回条数。 +pub const DEFAULT_LOG_EVENTS: usize = 100; +/// 单条 payload 字符串的展示上限(字符)。 +pub const MAX_PAYLOAD_CHARS: usize = 800; +/// 列表默认/最大条数。 +pub const DEFAULT_SESSION_LIMIT: usize = 10; +/// 列表最大条数。 +pub const MAX_SESSION_LIMIT: usize = 50; + +/// `launch-接口约定.md` §3 的事件类型(schema 里给模型做枚举;未知 kind 仍可透传)。 +pub const KNOWN_KINDS: [&str; 13] = [ + "session_start", + "java_selected", + "cmd_built", + "natives_extracted", + "proc_start", + "stdout", + "stderr", + "window_detected", + "exit", + "crash_found", + "diagnosis", + "killed", + "session_end", +]; + +/// 注册两个日志工具。 +/// +/// # Errors +/// 工具名重复/schema 非法时返回 [`RegistryError`]。 +pub fn register( + registry: &mut ToolRegistry, + port: Arc, +) -> Result<(), RegistryError> { + registry.register(list_sessions_spec(Arc::clone(&port)))?; + registry.register(read_log_spec(port)) +} + +fn list_sessions_spec(port: Arc) -> ToolSpec { + let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| { + let limit = args + .get("limit") + .and_then(Value::as_u64) + .map_or(DEFAULT_SESSION_LIMIT, |n| n as usize) + .clamp(1, MAX_SESSION_LIMIT); + let instance_id = args.get("instance_id").and_then(Value::as_str); + let sessions = port + .list_sessions(limit, instance_id) + .map_err(ToolError::Port)?; + + if sessions.is_empty() { + return Ok(ToolOutput::ok( + json!({ "sessions": [], "count": 0 }), + "没有游戏会话记录(还没启动过游戏,或数据目录不对)".to_string(), + )); + } + + let crashed = sessions + .iter() + .filter(|s| s.crash_found || s.silent_failure || s.exit_code.is_some_and(|c| c != 0)) + .count(); + let summary = format!( + "最近 {} 个游戏会话,其中 {crashed} 个崩溃/异常退出;最近一次 {}({})", + sessions.len(), + sessions[0].session_id, + sessions[0].started_at.as_deref().unwrap_or("启动时间未知") + ); + Ok(ToolOutput::ok( + json!({ "sessions": sessions, "count": sessions.len(), "problem_count": crashed }), + summary, + )) + }); + + ToolSpec::read_only( + LIST_GAME_SESSIONS, + "列出最近的游戏会话(启动时间、退出码、是否崩溃、规则引擎首选结论)。用于定位要读哪一次会话的日志。", + object( + &[ + ("limit", integer("返回条数", 1, MAX_SESSION_LIMIT as i64)), + ("instance_id", string("只看某个实例的会话;省略则全部实例")), + ], + &[], + ), + handler, + ) +} + +fn read_log_spec(port: Arc) -> ToolSpec { + let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| { + let session_id = args + .get("session_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ToolError::NotAllowed("缺少参数 `session_id`".to_string()))? + .to_string(); + + let kinds: Vec = args + .get("kinds") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + + let max_events = args + .get("max_events") + .and_then(Value::as_u64) + .map_or(DEFAULT_LOG_EVENTS, |n| n as usize) + .clamp(1, MAX_LOG_EVENTS); + + let query = LogQuery { + from_seq: args.get("from_seq").and_then(Value::as_u64), + to_seq: args.get("to_seq").and_then(Value::as_u64), + kinds, + max_events, + }; + let page = port + .read_log(&session_id, &query) + .map_err(ToolError::Port)?; + Ok(render_page(&page)) + }); + + ToolSpec::read_only( + READ_GAME_LOG, + "读取某次游戏会话的事件流(stdout/stderr 按行、退出事件、crash-report 摘录)。可按 seq 区间和事件类型过滤;单次最多 500 条、单条最长 800 字符,优先用 kinds 过滤 stderr/exit/crash_found 而不是拉全量。", + object( + &[ + ("session_id", non_empty_string("会话 id,来自 list_game_sessions")), + ("from_seq", integer("起始 seq(含)", 0, 9_007_199_254_740_991)), + ("to_seq", integer("结束 seq(含)", 0, 9_007_199_254_740_991)), + ( + "kinds", + enum_string_array(&KNOWN_KINDS, "只要这些事件类型;省略则全部", 13), + ), + ("max_events", integer("最多返回多少条", 1, MAX_LOG_EVENTS as i64)), + ], + &["session_id"], + ), + handler, + ) +} + +/// 把端口返回的一页日志压成工具结果(含摘要,模型据此决定要不要继续翻页)。 +fn render_page(page: &LogPage) -> ToolOutput { + let events: Vec = page.events.iter().map(render_event).collect(); + let kind_summary = page + .kind_counts + .iter() + .map(|(kind, count)| format!("{kind}×{count}")) + .collect::>() + .join(", "); + + let (content, summary) = if events.is_empty() { + ( + json!({ + "session_id": page.session_id, + "total_events": page.total_events, + "matched_events": 0, + "truncated": false, + "kind_counts": page.kind_counts, + "first_ts": page.first_ts, + "last_ts": page.last_ts, + "events": [], + "hint": "没有符合过滤条件的事件;把 kinds/from_seq/to_seq 放宽再试", + }), + format!( + "会话 {} 没有匹配的事件(共 {} 条)", + page.session_id, page.total_events + ), + ) + } else { + let truncated_note = if page.truncated { + "(已截断)" + } else { + "" + }; + ( + json!({ + "session_id": page.session_id, + "total_events": page.total_events, + "matched_events": page.matched_events, + "truncated": page.truncated, + "kind_counts": page.kind_counts, + "first_ts": page.first_ts, + "last_ts": page.last_ts, + "events": events, + }), + format!( + "会话 {}:共 {} 条事件,返回 {} 条{truncated_note};类型分布 {kind_summary}", + page.session_id, + page.total_events, + events.len(), + ), + ) + }; + ToolOutput::ok(content, summary) +} + +fn render_event(event: &LogEvent) -> Value { + json!({ + "seq": event.seq, + "ts": event.ts, + "t_ms": event.t_ms, + "kind": event.kind, + "payload": shrink(&event.payload), + }) +} + +/// 递归脱敏 + 截断(第二道脱敏防线)。 +fn shrink(value: &Value) -> Value { + match value { + Value::String(text) => Value::String(cap(&redact_text(text))), + Value::Array(items) => Value::Array(items.iter().map(shrink).collect()), + Value::Object(map) => { + let mut out = Map::with_capacity(map.len()); + for (key, item) in map { + out.insert(key.clone(), shrink(item)); + } + Value::Object(out) + } + other => other.clone(), + } +} + +fn cap(text: &str) -> String { + let length = text.chars().count(); + if length <= MAX_PAYLOAD_CHARS { + return text.to_string(); + } + let head: String = text.chars().take(MAX_PAYLOAD_CHARS).collect(); + format!("{head}…(已截断 {} 字符)", length - MAX_PAYLOAD_CHARS) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::MockSessions; + use serde_json::json; + + const SESSION: &str = "20261029T124501-9f3c2a"; + + fn mock() -> MockSessions { + let (summary, events) = MockSessions::crashing_session(SESSION); + let (older, older_events) = MockSessions::crashing_session("20261028T100000-aaaaaa"); + let mut sessions = MockSessions::new(); + sessions.add(summary, events); + sessions.add(older, older_events); + sessions + } + + fn registry() -> ToolRegistry { + let mut registry = ToolRegistry::new(); + register(&mut registry, Arc::new(mock())).expect("注册应成功"); + registry + } + + /// 走**真实注册表 → 真实 handler**,不复制实现(否则测的是复制品)。 + fn call(tool: &str, args: Value) -> ToolOutput { + let registry = registry(); + let spec = registry.get(tool).expect("工具应存在").clone(); + (spec.handler)( + &ToolCtx { + agent_session_id: "s1".to_string(), + confirmed: false, + }, + &args, + ) + .expect("工具应成功") + } + + #[test] + fn lists_sessions_newest_first() { + let out = call(LIST_GAME_SESSIONS, json!({ "limit": 5 })); + assert_eq!(out.content["count"], json!(2)); + assert_eq!(out.content["sessions"][0]["session_id"], json!(SESSION)); + assert_eq!(out.content["problem_count"], json!(2)); + assert!(out.summary.contains("2 个崩溃/异常退出")); + } + + #[test] + fn list_respects_limit() { + let out = call(LIST_GAME_SESSIONS, json!({ "limit": 1 })); + assert_eq!(out.content["count"], json!(1)); + } + + #[test] + fn filters_by_kind_and_keeps_summary() { + let out = call( + READ_GAME_LOG, + json!({ "session_id": SESSION, "kinds": ["stderr"] }), + ); + assert_eq!(out.content["events"].as_array().unwrap().len(), 1); + assert_eq!(out.content["kind_counts"]["stdout"], json!(1)); + assert!(out.summary.contains("共 5 条事件")); + } + + #[test] + fn truncates_when_more_events_than_cap() { + let out = call( + READ_GAME_LOG, + json!({ "session_id": SESSION, "max_events": 2 }), + ); + assert_eq!(out.content["events"].as_array().unwrap().len(), 2); + assert_eq!(out.content["truncated"], json!(true)); + assert_eq!(out.content["matched_events"], json!(5)); + } + + #[test] + fn masks_user_dir_in_payload() { + let out = call( + READ_GAME_LOG, + json!({ "session_id": SESSION, "kinds": ["crash_found"] }), + ); + let payload = &out.content["events"][0]["payload"]; + assert_eq!( + payload["path"], + json!(r"C:\Users\***\.minecraft\crash-reports\crash-1.txt") + ); + } + + #[test] + fn reports_empty_result_with_hint() { + let out = call( + READ_GAME_LOG, + json!({ "session_id": SESSION, "kinds": ["window_detected"] }), + ); + assert_eq!(out.content["events"], json!([])); + assert!(out.content["hint"].is_string()); + } + + #[test] + fn caps_long_payload_text() { + let long = "x".repeat(MAX_PAYLOAD_CHARS + 10); + assert!(cap(&long).contains("已截断 10 字符")); + } + + #[test] + fn unknown_session_is_a_port_error() { + let registry = registry(); + let spec = registry.get(READ_GAME_LOG).expect("工具应存在").clone(); + let err = (spec.handler)( + &ToolCtx { + agent_session_id: "s1".to_string(), + confirmed: false, + }, + &json!({ "session_id": "nope" }), + ) + .unwrap_err(); + assert_eq!(err.code(), "port_error"); + assert!(err.message().contains("不存在")); + } +} diff --git a/crates/agent/tests/brain_loop.rs b/crates/agent/tests/brain_loop.rs new file mode 100644 index 0000000..de43011 --- /dev/null +++ b/crates/agent/tests/brain_loop.rs @@ -0,0 +1,248 @@ +//! 对话循环集成测试:mock provider 驱动**完整链路**(provider → 工具编排 → 审计 → 事件)。 +//! +//! 这里测"循环的骨架":多轮工具调用、步数上限、流式增量、禁用态、provider 失败。 +//! 安全边界(白名单/确认/YOLO/审计标记)在 `tools_security.rs`。 + +mod support; + +use std::sync::Arc; + +use serde_json::json; +use support::{ + block_on, list_args, tool_call_reply, Harness, MockProvider, AGENT_SESSION, SESSION, +}; +use unlucky_agent::audit::AuditOutcome; +use unlucky_agent::brain::{BrainError, ChatMessage, ChatReply, StopReason, ToolCall}; +use unlucky_agent::event::AgentEvent; + +#[test] +fn single_turn_without_tools_returns_text() { + let harness = Harness::new(vec![ChatReply::text("你好,需要我看看最近的崩溃吗?")]); + let mut history = Vec::new(); + + let outcome = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap(); + + assert_eq!(outcome.text, "你好,需要我看看最近的崩溃吗?"); + assert_eq!(outcome.steps, 1); + assert_eq!(outcome.tool_calls, 0); + assert_eq!(outcome.stopped_reason, StopReason::Completed); + assert_eq!(history.len(), 2, "应只有 user + assistant"); + assert!(harness.audit.is_empty(), "没调工具就不该有审计记录"); +} + +#[test] +fn system_prompt_leads_every_request_and_tools_are_projected() { + let harness = Harness::new(vec![ChatReply::text("好的")]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "看看配置"), + ) + .unwrap(); + + let requests = harness.provider.requests(); + assert_eq!(requests.len(), 1); + let request = &requests[0]; + assert!( + matches!(&request.messages[0], ChatMessage::System { content } if content.contains("Unlucky-Launcher")), + "system prompt 必须在首位" + ); + assert_eq!(request.model, "mock-model"); + let names: Vec<&str> = request.tools.iter().map(|t| t.name.as_str()).collect(); + assert_eq!( + names, + vec![ + "get_config", + "set_config", + "get_config_schema", + "list_instances", + "list_versions", + "get_java_status", + "list_accounts", + "list_game_sessions", + "read_game_log", + "diagnose_crash", + ], + "注册表的双投影载荷必须原样交给 provider" + ); + // 双投影契约:provider 收到的 schema 与注册表定义逐字节相等。 + assert_eq!(request.tools, harness.registry.definitions()); +} + +#[test] +fn tool_call_round_trip_feeds_result_back_and_finishes() { + let harness = Harness::new(vec![ + tool_call_reply("call_1", "list_game_sessions", list_args()), + ChatReply::text("最近一次会话崩溃了,要不要跑诊断?"), + ]); + let mut history = Vec::new(); + + let outcome = block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "最近崩了吗"), + ) + .unwrap(); + + assert_eq!(outcome.tool_calls, 1); + assert_eq!(outcome.steps, 2); + assert_eq!(outcome.stopped_reason, StopReason::Completed); + + let requests = harness.provider.requests(); + assert_eq!(requests.len(), 2); + let tool_message = requests[1] + .messages + .iter() + .find_map(|m| match m { + ChatMessage::Tool { + call_id, + name, + content, + } => Some((call_id.clone(), name.clone(), content.clone())), + _ => None, + }) + .expect("必须有工具结果消息"); + assert_eq!(tool_message.0, "call_1"); + assert_eq!(tool_message.1, "list_game_sessions"); + assert!( + tool_message.2.contains(SESSION), + "工具结果应包含会话 id:{}", + tool_message.2 + ); + + let audits = harness.audit.records(); + assert_eq!(audits.len(), 1); + assert_eq!(audits[0].tool, "list_game_sessions"); + assert_eq!(audits[0].outcome, AuditOutcome::Ok); + assert!(!audits[0].yolo); + assert!(!audits[0].confirmed); +} + +#[test] +fn multiple_tool_calls_in_one_step_all_execute() { + let reply = ChatReply::with_tool_calls(vec![ + ToolCall { + id: "c1".to_string(), + name: "list_game_sessions".to_string(), + arguments: list_args(), + }, + ToolCall { + id: "c2".to_string(), + name: "diagnose_crash".to_string(), + arguments: json!({ "session_id": SESSION }), + }, + ]); + let harness = Harness::new(vec![reply, ChatReply::text("诊断完成")]); + let mut history = Vec::new(); + + let outcome = block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "查一下"), + ) + .unwrap(); + assert_eq!(outcome.tool_calls, 2); + assert_eq!(harness.audit.len(), 2); + assert_eq!( + harness + .audit + .records() + .iter() + .map(|r| r.tool.as_str()) + .collect::>(), + vec!["list_game_sessions", "diagnose_crash"] + ); +} + +#[test] +fn step_limit_stops_runaway_loop() { + // 每一步都要求调工具,永远不会给最终回答。 + let script: Vec = (0..16) + .map(|i| tool_call_reply(&format!("c{i}"), "get_config", json!({}))) + .collect(); + let harness = Harness::new(script); + let mut history = Vec::new(); + + let outcome = block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "死循环"), + ) + .unwrap(); + assert_eq!(outcome.stopped_reason, StopReason::StepLimit); + assert_eq!(outcome.steps, unlucky_agent::brain::DEFAULT_MAX_STEPS); + assert_eq!(outcome.tool_calls, unlucky_agent::brain::DEFAULT_MAX_STEPS); +} + +#[test] +fn streaming_deltas_are_forwarded_as_events() { + let harness = Harness::new(vec![ChatReply::text("日志里是 NoClassDefFoundError")]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "什么错误"), + ) + .unwrap(); + + assert_eq!( + harness.events.streamed_text(), + "日志里是 NoClassDefFoundError" + ); + let events = harness.events.events(); + assert!(events + .iter() + .any(|e| matches!(e, AgentEvent::TurnStarted { .. }))); + assert!(events + .iter() + .any(|e| matches!(e, AgentEvent::AssistantText { .. }))); + assert!(events + .iter() + .any(|e| matches!(e, AgentEvent::TurnFinished { .. }))); +} + +#[test] +fn disabled_provider_fails_fast() { + let harness = Harness::from_provider(Arc::new(MockProvider::disabled())); + let mut history = Vec::new(); + + let err = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap_err(); + assert_eq!(err, BrainError::Disabled); + assert!(err.to_string().contains("规则引擎"), "要提示降级能力"); + assert!( + harness.provider.requests().is_empty(), + "禁用态不该发起任何请求" + ); + assert!(history.is_empty(), "禁用态不该改动历史"); +} + +#[test] +fn provider_error_surfaces_as_brain_error_and_event() { + let harness = Harness::from_provider(Arc::new(MockProvider::failing( + "连接 http://127.0.0.1:8080 失败", + ))); + let mut history = Vec::new(); + + let err = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap_err(); + assert_eq!( + err, + BrainError::Provider(unlucky_agent::brain::ProviderError::new( + "连接 http://127.0.0.1:8080 失败" + )) + ); + assert!(harness + .events + .events() + .iter() + .any(|e| matches!(e, AgentEvent::Error { message } if message.contains("8080")))); +} + +#[test] +fn empty_script_yields_empty_completion_turn() { + let harness = Harness::new(vec![]); + let mut history = Vec::new(); + let outcome = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap(); + assert_eq!(outcome.text, ""); + assert_eq!(outcome.stopped_reason, StopReason::Completed); +} diff --git a/crates/agent/tests/mcp_contract.rs b/crates/agent/tests/mcp_contract.rs new file mode 100644 index 0000000..6dd5306 --- /dev/null +++ b/crates/agent/tests/mcp_contract.rs @@ -0,0 +1,132 @@ +//! **双投影契约测试(MCP 侧)**:注册表 → rmcp `Tool` 必须与 rig 侧逐项相等。 +//! +//! research/02 §6.2 建议进 CI 的那条断言,M16 落的是「注册表自身是纯投影」+「rig 适配层 +//! 只做直投」,M39 补上 rmcp 侧: +//! +//! 1. `mcp::projection::tool_attrs(®istry)` 的 `(name, description, input_schema)` +//! 与 `registry.definitions()` **逐项相等**(JSON 层面,不是"看起来差不多"); +//! 2. `tools/list` 的顺序与注册顺序一致,数量等于注册表长度; +//! 3. 每个 schema 根是 `object`(rig 与 rmcp 的共同前提); +//! 4. `read_only_hint` 与 `Access` 一致(外部客户端据此决定能不能静默调用); +//! 5. 只读子集不会漏出写工具(`agent.mcp.allow_write=false` 的默认形态)。 + +use serde_json::{json, Value}; +use unlucky_agent::mcp::projection::{tool_attr, tool_attrs}; +use unlucky_agent::port::Ports; +use unlucky_agent::registry::{Access, ToolDefinition, ToolRegistry}; +use unlucky_agent::testing::mock_ports; +use unlucky_agent::tools::build_v1_registry; + +fn registry() -> ToolRegistry { + let (ports, _settings) = mock_ports(); + build_v1_registry(&ports).expect("注册表应装配成功") +} + +fn ports() -> Ports { + mock_ports().0 +} + +#[test] +fn mcp_projection_equals_registry_definition_item_by_item() { + let registry = registry(); + let definitions = registry.definitions(); + let attrs = tool_attrs(®istry); + assert_eq!(attrs.len(), definitions.len()); + + for (attr, definition) in attrs.iter().zip(definitions.iter()) { + assert_eq!(attr.name.as_ref(), definition.name, "工具名必须相等"); + assert_eq!( + attr.description.as_deref(), + Some(definition.description.as_str()), + "{} 的 description 必须逐字相等", + definition.name + ); + assert_eq!( + Value::Object((*attr.input_schema).clone()), + definition.input_schema, + "{} 的 JSON Schema 必须逐项相等", + definition.name + ); + } +} + +#[test] +fn projection_order_matches_registration_order() { + let registry = registry(); + let projected: Vec = tool_attrs(®istry) + .iter() + .map(|attr| attr.name.to_string()) + .collect(); + assert_eq!(projected, registry.names(), "两侧顺序必须一致"); +} + +#[test] +fn every_projected_schema_is_an_object_and_round_trips() { + for attr in tool_attrs(®istry()) { + let schema = Value::Object((*attr.input_schema).clone()); + assert_eq!( + schema["type"], + json!("object"), + "{} 的 schema 根必须是 object", + attr.name + ); + // rmcp 会把 Tool 序列化进 tools/list 的响应体:必须能反序列化回来。 + let json = serde_json::to_string(&attr).expect("Tool 可序列化"); + let back: rmcp::model::Tool = serde_json::from_str(&json).expect("Tool 可反序列化"); + assert_eq!(back, attr); + } +} + +#[test] +fn read_only_hint_tracks_access() { + let registry = registry(); + for (attr, spec) in tool_attrs(®istry).iter().zip(registry.specs()) { + let hint = attr + .annotations + .as_ref() + .and_then(|annotations| annotations.read_only_hint) + .expect("每个工具都应有 read_only_hint"); + assert_eq!(hint, spec.access == Access::ReadOnly, "{}", attr.name); + } +} + +#[test] +fn read_only_subset_projects_without_the_writing_tool() { + let subset = registry().read_only_subset(); + assert_eq!(subset.len(), 9); + let names: Vec = tool_attrs(&subset) + .iter() + .map(|attr| attr.name.to_string()) + .collect(); + assert!(!names.iter().any(|name| name == "set_config"), "{names:?}"); + assert!(subset.access_of("set_config").is_none()); +} + +#[test] +fn projected_attrs_are_pure_functions_of_the_definition() { + let definition = ToolDefinition { + name: "demo".to_string(), + description: "演示".to_string(), + input_schema: json!({ "type": "object", "properties": {}, "required": [] }), + }; + let first = tool_attr(&definition, Access::ReadOnly); + let second = tool_attr(&definition, Access::ReadOnly); + assert_eq!(first, second); + + // 副作用等级换一下,只有 annotations 变,name/description/schema 不变。 + let mutating = tool_attr(&definition, Access::Mutating); + assert_eq!(first.name, mutating.name); + assert_eq!(first.input_schema, mutating.input_schema); + assert_ne!(first.annotations, mutating.annotations); +} + +#[test] +fn mock_ports_cover_every_port_the_registry_needs() { + // 装配完整性:五个端口都在,注册表才有这十个工具(少一个端口 = 少工具)。 + let ports = ports(); + let registry = build_v1_registry(&ports).expect("注册表应装配成功"); + assert_eq!(registry.len(), 10); + assert!(unlucky_agent::testing::expected_v1_tool_names() + .iter() + .all(|name| registry.get(name).is_some())); +} diff --git a/crates/agent/tests/mcp_http.rs b/crates/agent/tests/mcp_http.rs new file mode 100644 index 0000000..44f62fe --- /dev/null +++ b/crates/agent/tests/mcp_http.rs @@ -0,0 +1,336 @@ +//! MCP over Streamable HTTP 的端到端测试:真起监听、打裸 JSON-RPC,断言工具返回与审计。 +//! +//! 为什么不用 MCP 客户端 crate:客户端依赖会再拖一棵 reqwest 树(且 3.8 的客户端 feature +//! 与我们的服务端 feature 不是一组)。裸 HTTP 反而更贴近「用户用 Claude Code 连上来」 +//! 的实际形状——握手、会话头、`tools/list`、`tools/call` 一个都不省。 +//! +//! 覆盖的安全语义(02 §3.1 / §4b): +//! - 只读模式下 `set_config` 根本不在工具表里,硬调 → tool-level 错误 + `unknown_tool` 审计; +//! - 普通键(`download.concurrency`,Low)直接写入,审计 `outcome=ok`; +//! - 敏感键(`java.selected_path`,High)在**没有弹窗**的外部场景被拒,文案引导去设置页/YOLO; +//! - 打开 YOLO 后同一个写入成功,审计 `yolo=true`。 + +use std::net::SocketAddr; +use std::sync::Arc; + +use serde_json::{json, Value}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use unlucky_agent::audit::{AuditOutcome, AuditRecord, JsonlAudit}; +use unlucky_agent::brain::exec::ToolExecutor; +use unlucky_agent::clock::{FixedClock, SharedClock}; +use unlucky_agent::confirm::{ConfirmPolicy, DenyGuidance, NoUiGate}; +use unlucky_agent::event::NoopSink; +use unlucky_agent::mcp::{bind, serve_http_on, McpConfig, RegistryServer}; +use unlucky_agent::testing::mock_ports; +use unlucky_agent::tools::build_v1_registry; + +const INITIALIZE: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"ull-test","version":"0.1"}}}"#; + +struct Harness { + addr: SocketAddr, + audit_dir: std::path::PathBuf, +} + +fn audit_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("ull-mcp-http-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir +} + +fn server(read_only: bool, yolo: bool, audit: Arc) -> RegistryServer { + let (ports, _settings) = mock_ports(); + let registry = Arc::new(build_v1_registry(&ports).expect("注册表应装配成功")); + let exposed = if read_only { + Arc::new(registry.read_only_subset()) + } else { + Arc::clone(®istry) + }; + let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123)); + let executor = Arc::new( + ToolExecutor::new( + exposed, + audit, + Arc::new(NoUiGate), + ConfirmPolicy { yolo }, + clock, + Arc::new(NoopSink), + ) + .with_guidance(DenyGuidance::External), + ); + RegistryServer::new(registry, executor, "mcp-http", read_only) +} + +async fn spawn(tag: &str, read_only: bool, yolo: bool) -> Harness { + let dir = audit_dir(tag); + let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123)); + let audit = Arc::new(JsonlAudit::new(&dir, clock)); + let server = server(read_only, yolo, audit); + + let cfg = McpConfig { + port: 0, + ..McpConfig::default() + }; + let (addr, listener) = bind(&cfg).await.expect("应能绑定临时端口"); + tokio::spawn(async move { + let _ = serve_http_on(server, cfg, addr, listener).await; + }); + Harness { + addr, + audit_dir: dir, + } +} + +/// 发一条 HTTP POST(`Connection: close` 让读取变成 read_to_end)。 +async fn post( + addr: SocketAddr, + body: &str, + session: Option<&str>, + token: Option<&str>, +) -> (u16, Option, String) { + let mut stream = TcpStream::connect(addr).await.expect("连接失败"); + let mut head = format!( + "POST /mcp HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\n\ + Accept: application/json, text/event-stream\r\nConnection: close\r\n\ + Content-Length: {}\r\n", + body.len() + ); + if let Some(session) = session { + head.push_str(&format!("Mcp-Session-Id: {session}\r\n")); + } + if let Some(token) = token { + head.push_str(&format!("Authorization: Bearer {token}\r\n")); + } + head.push_str("\r\n"); + stream + .write_all(format!("{head}{body}").as_bytes()) + .await + .expect("写请求失败"); + stream.flush().await.expect("刷新失败"); + + let mut raw = Vec::new(); + stream.read_to_end(&mut raw).await.expect("读响应失败"); + let text = String::from_utf8_lossy(&raw).to_string(); + let (head, body) = text.split_once("\r\n\r\n").unwrap_or((text.as_str(), "")); + let status = head + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|code| code.parse::().ok()) + .unwrap_or(0); + let session = head.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("mcp-session-id") + .then(|| value.trim().to_string()) + }); + (status, session, body.to_string()) +} + +/// 响应体可能是裸 JSON,也可能是 SSE(`data: {...}`);两种都解出第一条 JSON-RPC 响应。 +fn parse_rpc(body: &str) -> Value { + if let Ok(value) = serde_json::from_str::(body.trim()) { + return value; + } + for line in body.lines() { + let Some(data) = line.strip_prefix("data: ") else { + continue; + }; + if let Ok(value) = serde_json::from_str::(data.trim()) { + return value; + } + } + panic!("响应体既不是 JSON 也不是 SSE:{body}"); +} + +async fn initialize(addr: SocketAddr) -> Option { + let (status, session, body) = post(addr, INITIALIZE, None, None).await; + assert_eq!(status, 200, "initialize 应成功:{body}"); + let response = parse_rpc(&body); + assert_eq!( + response["result"]["serverInfo"]["name"], + json!("unlucky-launcher") + ); + // 按 MCP 规范补一条 initialized 通知(无 id、无响应体)。 + let _ = post( + addr, + r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, + session.as_deref(), + None, + ) + .await; + session +} + +async fn rpc( + addr: SocketAddr, + session: Option<&str>, + id: u32, + method: &str, + params: Value, +) -> Value { + let body = + json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string(); + let (status, _session, text) = post(addr, &body, session, None).await; + assert_eq!(status, 200, "{method} 应成功:{text}"); + parse_rpc(&text) +} + +fn audit_records(dir: &std::path::Path) -> Vec { + let mut out = Vec::new(); + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let text = std::fs::read_to_string(entry.path()).unwrap_or_default(); + for line in text.lines() { + if let Ok(record) = serde_json::from_str::(line) { + out.push(record); + } + } + } + } + out +} + +#[tokio::test(flavor = "multi_thread")] +async fn readonly_server_lists_and_serves_only_read_tools() { + let harness = spawn("readonly", true, false).await; + let session = initialize(harness.addr).await; + + let list = rpc(harness.addr, session.as_deref(), 2, "tools/list", json!({})).await; + let tools = list["result"]["tools"].as_array().expect("应有 tools 数组"); + let names: Vec<&str> = tools + .iter() + .filter_map(|tool| tool["name"].as_str()) + .collect(); + assert_eq!(names.len(), 9, "{names:?}"); + assert!(!names.contains(&"set_config"), "只读模式不得暴露写工具"); + assert!(names.contains(&"get_config")); + + // 真调一个只读工具:返回真实配置概要(mock 端口)。 + let call = rpc( + harness.addr, + session.as_deref(), + 3, + "tools/call", + json!({ "name": "get_config", "arguments": {} }), + ) + .await; + assert_ne!(call["result"]["isError"], json!(true), "{call}"); + let text = call["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default(); + assert!(text.contains("download.concurrency"), "{text}"); + + // 硬调写工具:tool-level 错误(客户端能看见文案)+ unknown_tool 审计。 + let denied = rpc( + harness.addr, + session.as_deref(), + 4, + "tools/call", + json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }), + ) + .await; + assert_eq!(denied["result"]["isError"], json!(true), "{denied}"); + + let records = audit_records(&harness.audit_dir); + assert!(records.iter().any(|r| r.tool == "get_config" + && r.outcome == AuditOutcome::Ok + && r.agent_session_id == "mcp-http")); + assert!(records + .iter() + .any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::UnknownTool)); + let _ = std::fs::remove_dir_all(&harness.audit_dir); +} + +#[tokio::test(flavor = "multi_thread")] +async fn write_server_enforces_sensitive_key_confirmation() { + let harness = spawn("write", false, false).await; + let session = initialize(harness.addr).await; + + // 普通键:直接写。 + let ok = rpc( + harness.addr, + session.as_deref(), + 2, + "tools/call", + json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }), + ) + .await; + assert_ne!(ok["result"]["isError"], json!(true), "{ok}"); + + // 敏感键:外部客户端没有弹窗 → 拒绝,文案要能指导用户下一步。 + let denied = rpc( + harness.addr, + session.as_deref(), + 3, + "tools/call", + json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }), + ) + .await; + assert_eq!(denied["result"]["isError"], json!(true), "{denied}"); + let text = denied["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default(); + assert!( + text.contains("YOLO"), + "拒绝文案应引导到 YOLO/设置页:{text}" + ); + assert!(text.contains("设置页"), "{text}"); + + let records = audit_records(&harness.audit_dir); + assert!(records + .iter() + .any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::AwaitingConfirmation)); + assert!(records + .iter() + .any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::Denied && !r.yolo)); + let _ = std::fs::remove_dir_all(&harness.audit_dir); +} + +#[tokio::test(flavor = "multi_thread")] +async fn yolo_lets_the_same_write_through_and_audits_the_flag() { + let harness = spawn("yolo", false, true).await; + let session = initialize(harness.addr).await; + + let written = rpc( + harness.addr, + session.as_deref(), + 2, + "tools/call", + json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }), + ) + .await; + assert_ne!(written["result"]["isError"], json!(true), "{written}"); + + let records = audit_records(&harness.audit_dir); + let yolo_ok = records + .iter() + .find(|r| r.tool == "set_config" && r.outcome == AuditOutcome::Ok); + let record = yolo_ok.expect("应有一条 ok 审计"); + assert!(record.yolo, "YOLO 放行必须标记 yolo=true"); + assert!(!record.confirmed, "YOLO 自动放行不算用户确认"); + let _ = std::fs::remove_dir_all(&harness.audit_dir); +} + +#[tokio::test(flavor = "multi_thread")] +async fn bearer_token_is_enforced_when_configured() { + let dir = audit_dir("token"); + let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123)); + let audit = Arc::new(JsonlAudit::new(&dir, clock)); + let server = server(true, false, audit); + let cfg = McpConfig { + port: 0, + token: Some("s3cret".to_string()), + ..McpConfig::default() + }; + let (addr, listener) = bind(&cfg).await.expect("应能绑定"); + tokio::spawn(async move { + let _ = serve_http_on(server, cfg, addr, listener).await; + }); + + let (status, _, _) = post(addr, INITIALIZE, None, None).await; + assert_eq!(status, 401, "没带令牌必须 401"); + + let (status, _, body) = post(addr, INITIALIZE, None, Some("s3cret")).await; + assert_eq!(status, 200, "带对令牌应通过:{body}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/agent/tests/schema_contract.rs b/crates/agent/tests/schema_contract.rs new file mode 100644 index 0000000..d6f31e4 --- /dev/null +++ b/crates/agent/tests/schema_contract.rs @@ -0,0 +1,163 @@ +//! **双投影契约测试**(research/02 §6.2 建议进 CI 的那一条)。 +//! +//! rig 侧(`DynamicTool::new`)与 rmcp 侧(`ToolRoute::new_dyn`)吃的都是 +//! `(name, description, serde_json::Value schema)`。本 crate 只维护一份 `ToolSpec`, +//! 所以「两侧相等」在这里等价于三条可断言的性质: +//! 1. [`unlucky_agent::registry::ToolRegistry::definitions`] 是纯投影(多次调用逐字节相等); +//! 2. 每个 schema 根是 `type: object`(两侧都要求对象入参)且拒绝多余参数; +//! 3. 名字唯一,`required` 真的被执行(缺参能被校验器抓到)。 +//! +//! 本文件只锁**投影载荷本身**;两侧的一致性各有专测: +//! - rig 侧:`tests/brain_loop.rs` 断言 `provider` 收到的 `request.tools` 与 +//! `registry.definitions()` 逐字节相等(`brain/rig.rs` 只做直投); +//! - MCP 侧:`tests/mcp_contract.rs` 断言 rmcp `Tool` 的 `(name, description, schema)` 与 +//! 注册表逐项相等(M39 补的那条)。 + +use serde_json::json; +use unlucky_agent::registry::validate::validate; +use unlucky_agent::registry::{Access, ToolDefinition}; +use unlucky_agent::testing::mock_ports; + +fn registry() -> unlucky_agent::registry::ToolRegistry { + let (ports, _settings) = mock_ports(); + unlucky_agent::tools::build_v1_registry(&ports).expect("注册表应装配成功") +} + +#[test] +fn ships_exactly_the_v1_tools() { + let registry = registry(); + assert_eq!( + registry.names(), + vec![ + "get_config", + "set_config", + "get_config_schema", + "list_instances", + "list_versions", + "get_java_status", + "list_accounts", + "list_game_sessions", + "read_game_log", + "diagnose_crash", + ] + ); + assert_eq!(registry.len(), 10); +} + +#[test] +fn definitions_are_pure_projections() { + let registry = registry(); + let first = registry.definitions(); + let second = registry.definitions(); + assert_eq!(first, second, "投影必须是纯函数"); + + for definition in &first { + // 序列化后再反序列化必须完全一样(rig 与 rmcp 都走 JSON)。 + let json = serde_json::to_string(definition).expect("可序列化"); + let round_trip: ToolDefinition = serde_json::from_str(&json).expect("可反序列化"); + assert_eq!(&round_trip, definition); + assert!(!definition.description.trim().is_empty()); + assert_eq!( + definition.input_schema["type"], + json!("object"), + "{} 的 schema 根必须是 object", + definition.name + ); + } +} + +#[test] +fn only_set_config_is_mutating() { + let registry = registry(); + for name in registry.names() { + let expected = if name == "set_config" { + Access::Mutating + } else { + Access::ReadOnly + }; + assert_eq!(registry.access_of(name), Some(expected), "{name}"); + } + let read_only: Vec = registry + .read_only_definitions() + .into_iter() + .map(|d| d.name) + .collect(); + assert_eq!( + read_only, + vec![ + "get_config", + "get_config_schema", + "list_instances", + "list_versions", + "get_java_status", + "list_accounts", + "list_game_sessions", + "read_game_log", + "diagnose_crash", + ], + "只读 MCP 模式只暴露这九个(写侧只有 set_config)" + ); +} + +#[test] +fn every_schema_rejects_unknown_arguments() { + for definition in registry().definitions() { + let err = validate(&definition.input_schema, &json!({ "__unexpected": 1 })) + .expect_err(&format!("{} 应拒绝野参数", definition.name)); + assert!( + err.iter().any(|e| e.contains("不是可接受的参数名")), + "{}: {err:?}", + definition.name + ); + } +} + +#[test] +fn required_fields_are_actually_enforced() { + for definition in registry().definitions() { + let required = definition + .input_schema + .get("required") + .and_then(|r| r.as_array()) + .cloned() + .unwrap_or_default(); + if required.is_empty() { + continue; + } + let err = validate(&definition.input_schema, &json!({})) + .expect_err(&format!("{} 应有必填参数", definition.name)); + for field in required { + let name = field.as_str().unwrap_or_default(); + assert!( + err.iter().any(|e| e.contains(name)), + "{} 的必填 `{name}` 没被校验:{err:?}", + definition.name + ); + } + } +} + +#[test] +fn integer_bounds_are_declared_and_enforced() { + let registry = registry(); + let read_log = registry.get("read_game_log").expect("工具存在"); + let max_events = &read_log.input_schema["properties"]["max_events"]; + assert_eq!(max_events["minimum"], json!(1)); + assert_eq!(max_events["maximum"], json!(500)); + + assert!(validate( + &read_log.input_schema, + &json!({ "session_id": "s", "max_events": 0 }) + ) + .is_err()); + assert!(validate( + &read_log.input_schema, + &json!({ "session_id": "s", "max_events": 501 }) + ) + .is_err()); + assert!(validate( + &read_log.input_schema, + &json!({ "session_id": "s", "max_events": 500 }) + ) + .is_ok()); +} diff --git a/crates/agent/tests/support/mod.rs b/crates/agent/tests/support/mod.rs new file mode 100644 index 0000000..ce25225 --- /dev/null +++ b/crates/agent/tests/support/mod.rs @@ -0,0 +1,225 @@ +//! 集成测试的公共脚手架:**mock provider** + 迷你 `block_on` + 装配好的 harness。 +//! +//! `block_on` 是手写的 20 行:mock provider 的 future 永远立刻 Ready,所以不需要运行时。 +//! 这样 agent 的循环测试**零 tokio 依赖**,阶段 B 换成 rig 后才需要真运行时。 + +#![allow(dead_code)] + +use std::collections::VecDeque; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, Waker}; + +use serde_json::{json, Value}; +use unlucky_agent::audit::{AuditSink, MemoryAudit}; +use unlucky_agent::brain::{Brain, BrainConfig, BrainDeps}; +use unlucky_agent::brain::{ + ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderConfig, ProviderError, + ProviderFuture, ToolCall, +}; +use unlucky_agent::clock::{FixedClock, SharedClock}; +use unlucky_agent::confirm::{AutoApproveGate, ConfirmGate, ConfirmPolicy, NoUiGate}; +use unlucky_agent::event::{AgentEventSink, MemoryEventSink}; +use unlucky_agent::registry::ToolRegistry; +use unlucky_agent::testing::{mock_ports, MockSettings}; + +/// 立刻完成的 future 直接 poll;挂起就是测试写错了。 +pub fn block_on(future: F) -> F::Output { + let mut future: Pin> = Box::pin(future); + let waker = Waker::noop(); + let mut context = Context::from_waker(waker); + match future.as_mut().poll(&mut context) { + Poll::Ready(value) => value, + Poll::Pending => panic!("mock provider 不应挂起:测试里没有执行器"), + } +} + +/// 脚本化 provider:按顺序回放 [`ChatReply`],并记录收到的请求。 +pub struct MockProvider { + config: ProviderConfig, + script: Mutex>, + requests: Mutex>, + fail_with: Option, +} + +impl MockProvider { + /// 用默认模型名(本地 llama.cpp 形态)回放脚本。 + #[must_use] + pub fn new(script: Vec) -> Self { + Self { + config: ProviderConfig::llamacpp_local("mock-model"), + script: Mutex::new(script.into()), + requests: Mutex::new(Vec::new()), + fail_with: None, + } + } + + /// 禁用态(测 `BrainError::Disabled`)。 + #[must_use] + pub fn disabled() -> Self { + Self { + config: ProviderConfig::disabled(), + script: Mutex::new(VecDeque::new()), + requests: Mutex::new(Vec::new()), + fail_with: None, + } + } + + /// 每次补全都失败(测 provider 错误透传)。 + #[must_use] + pub fn failing(message: &str) -> Self { + Self { + fail_with: Some(message.to_string()), + ..Self::new(Vec::new()) + } + } + + /// 收到的请求(断言 system prompt / 工具定义 / 回灌的工具消息)。 + #[must_use] + pub fn requests(&self) -> Vec { + self.requests.lock().map(|r| r.clone()).unwrap_or_default() + } +} + +impl ChatProvider for MockProvider { + fn config(&self) -> &ProviderConfig { + &self.config + } + + fn complete<'a>( + &'a self, + request: &'a ChatRequest, + observer: &'a dyn DeltaObserver, + ) -> ProviderFuture<'a> { + Box::pin(async move { + if let Ok(mut requests) = self.requests.lock() { + requests.push(request.clone()); + } + if let Some(message) = &self.fail_with { + return Err(ProviderError::new(message.clone())); + } + let reply = self + .script + .lock() + .ok() + .and_then(|mut script| script.pop_front()) + .unwrap_or_default(); + // 按 4 字符切块吐增量,模拟流式。 + if !reply.text.is_empty() { + for chunk in reply.text.chars().collect::>().chunks(4) { + observer.on_delta(&chunk.iter().collect::()); + } + } + Ok(reply) + }) + } +} + +/// 装配好的测试环境。 +pub struct Harness { + /// 对话循环。 + pub brain: Brain, + /// 审计出口(内存)。 + pub audit: Arc, + /// 事件出口(内存)。 + pub events: Arc, + /// mock provider(查请求)。 + pub provider: Arc, + /// mock 配置端口(查是否真的写入)。 + pub settings: Arc, + /// 工具注册表。 + pub registry: Arc, +} + +impl Harness { + /// P0 默认形态:无确认 UI(`NoUiGate`,敏感操作 fail-closed)+ 非 YOLO。 + /// 确认请求本身仍会作为 [`unlucky_agent::event::AgentEvent::ConfirmRequested`] 发出。 + #[must_use] + pub fn new(script: Vec) -> Self { + Self::with_gate(script, ConfirmPolicy::default(), |_sink| Arc::new(NoUiGate)) + } + + /// YOLO:自动放行全部(除破坏性)。 + #[must_use] + pub fn yolo(script: Vec) -> Self { + Self::with_gate(script, ConfirmPolicy { yolo: true }, |_sink| { + Arc::new(AutoApproveGate) + }) + } + + /// 用指定的(可能失效/禁用的)provider 装配,其余同 [`Harness::new`]。 + #[must_use] + pub fn from_provider(provider: Arc) -> Self { + Self::build(provider, ConfirmPolicy::default(), |_sink| { + Arc::new(NoUiGate) + }) + } + + /// 自定义闸门 + 策略。 + #[must_use] + pub fn with_gate( + script: Vec, + policy: ConfirmPolicy, + make_gate: impl FnOnce(Arc) -> Arc, + ) -> Self { + Self::build(Arc::new(MockProvider::new(script)), policy, make_gate) + } + + fn build( + provider: Arc, + policy: ConfirmPolicy, + make_gate: impl FnOnce(Arc) -> Arc, + ) -> Self { + let (ports, settings) = mock_ports(); + let registry = + Arc::new(unlucky_agent::tools::build_v1_registry(&ports).expect("注册表应装配成功")); + let audit = Arc::new(MemoryAudit::new()); + let events = Arc::new(MemoryEventSink::new()); + let clock: SharedClock = Arc::new(FixedClock::with_step(1_793_314_353_123, 3)); + let gate = make_gate(events.clone()); + let deps = BrainDeps::new( + audit.clone() as Arc, + gate, + policy, + clock, + events.clone() as Arc, + ); + let brain = Brain::new( + provider.clone(), + Arc::clone(®istry), + deps, + BrainConfig::default(), + ); + Self { + brain, + audit, + events, + provider, + settings, + registry, + } + } +} + +/// 构造一条工具调用回复。 +#[must_use] +pub fn tool_call_reply(id: &str, name: &str, arguments: Value) -> ChatReply { + ChatReply::with_tool_calls(vec![ToolCall { + id: id.to_string(), + name: name.to_string(), + arguments, + }]) +} + +/// 便捷:游戏会话 id(与 `MockSessions::crashing_session` 一致)。 +pub const SESSION: &str = "20261029T124501-9f3c2a"; + +/// 便捷:agent 对话会话 id。 +pub const AGENT_SESSION: &str = "chat-1"; + +/// 便捷:`list_game_sessions` 的最小参数。 +#[must_use] +pub fn list_args() -> Value { + json!({ "limit": 5 }) +} diff --git a/crates/agent/tests/tools_security.rs b/crates/agent/tests/tools_security.rs new file mode 100644 index 0000000..daa4d1a --- /dev/null +++ b/crates/agent/tests/tools_security.rs @@ -0,0 +1,365 @@ +//! 安全边界集成测试:白名单拦截、分级确认、YOLO、审计标记。 +//! +//! 这些是 02 文档 §3.1「安全边界」+ §4b「YOLO」与 M12 `setting-接口约定.md` §8 +//! (`Low/Medium/High` 三档 + `writable=false` 凭据)的可执行版本; +//! 判断口径对照 research/02 §7 R9(写操作的破坏半径)。 + +mod support; + +use std::sync::Arc; + +use serde_json::{json, Value}; +use support::{block_on, tool_call_reply, Harness, AGENT_SESSION}; +use unlucky_agent::audit::AuditOutcome; +use unlucky_agent::brain::ChatReply; +use unlucky_agent::confirm::{ConfirmDecision, ConfirmPolicy, ScriptedGate}; +use unlucky_agent::event::AgentEvent; + +fn set_config_reply(path: &str, value: Value) -> ChatReply { + tool_call_reply("c1", "set_config", json!({ "path": path, "value": value })) +} + +fn tool_results(harness: &Harness) -> Vec { + harness + .provider + .requests() + .last() + .map(|request| { + request + .messages + .iter() + .filter_map(|m| match m { + unlucky_agent::brain::ChatMessage::Tool { content, .. } => { + Some(content.clone()) + } + _ => None, + }) + .collect() + }) + .unwrap_or_default() +} + +fn confirm_events(harness: &Harness) -> usize { + harness + .events + .events() + .iter() + .filter(|e| matches!(e, AgentEvent::ConfirmRequested { .. })) + .count() +} + +#[test] +fn low_key_writes_silently_without_confirmation() { + let harness = Harness::new(vec![ + set_config_reply("appearance.theme_mode", json!("metro_light")), + ChatReply::text("已切换主题"), + ]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "换成浅色"), + ) + .unwrap(); + + assert_eq!(harness.settings.set_calls(), 1); + assert_eq!( + harness.settings.value_of("appearance.theme_mode"), + Some(json!("metro_light")) + ); + assert_eq!(confirm_events(&harness), 0, "Low 键不该打扰用户"); + + let records = harness.audit.records_for("set_config"); + assert_eq!(records.len(), 1); + assert_eq!(records[0].outcome, AuditOutcome::Ok); + assert!(!records[0].confirmed, "静默生效不是「用户确认」"); + assert!(!records[0].yolo); +} + +#[test] +fn medium_key_also_writes_silently() { + // M12:Medium = 静默生效但审计标记(不弹窗) + let harness = Harness::new(vec![ + set_config_reply("download.region", json!("cn")), + ChatReply::text("已切到国内源"), + ]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "换下载源"), + ) + .unwrap(); + + assert_eq!(harness.settings.set_calls(), 1); + assert_eq!(confirm_events(&harness), 0); + assert_eq!( + harness.audit.records_for("set_config")[0].outcome, + AuditOutcome::Ok + ); +} + +#[test] +fn high_key_is_fail_closed_without_ui() { + let harness = Harness::new(vec![ + set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")), + ChatReply::text("需要你确认"), + ]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "换 Java"), + ) + .unwrap(); + + assert_eq!( + harness.settings.set_calls(), + 0, + "P0 无 UI 时必须 fail-closed" + ); + assert_eq!(harness.settings.value_of("java.selected_path"), None); + assert_eq!(confirm_events(&harness), 1, "确认请求必须作为事件发出"); + + let outcomes: Vec = harness + .audit + .records_for("set_config") + .iter() + .map(|r| r.outcome) + .collect(); + assert_eq!( + outcomes, + vec![AuditOutcome::AwaitingConfirmation, AuditOutcome::Denied], + "先记「AI 想改」,再记「被拒绝」" + ); + assert!(tool_results(&harness) + .iter() + .any(|content| content.contains("denied"))); + assert!(harness + .events + .events() + .iter() + .any(|e| matches!(e, AgentEvent::ToolDenied { .. }))); +} + +#[test] +fn user_approval_writes_and_marks_confirmed() { + let harness = Harness::with_gate( + vec![ + set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")), + ChatReply::text("已更新"), + ], + ConfirmPolicy::default(), + |_sink| { + Arc::new(ScriptedGate::new( + [ConfirmDecision::Approved], + ConfirmDecision::Denied, + )) + }, + ); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "换 Java"), + ) + .unwrap(); + + assert_eq!(harness.settings.set_calls(), 1, "确认后只写一次"); + let records = harness.audit.records_for("set_config"); + let last = records.last().expect("必须有审计记录"); + assert_eq!(last.outcome, AuditOutcome::Ok); + assert!(last.confirmed, "用户点了允许"); + assert!(!last.yolo); +} + +#[test] +fn yolo_skips_confirm_but_audits_flag() { + let harness = Harness::yolo(vec![ + set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")), + ChatReply::text("已更新"), + ]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "换 Java"), + ) + .unwrap(); + + assert_eq!(harness.settings.set_calls(), 1); + assert_eq!(confirm_events(&harness), 0, "YOLO 不打扰用户"); + let records = harness.audit.records_for("set_config"); + assert_eq!(records.len(), 1, "YOLO 只留结局记录"); + assert!(records[0].yolo, "审计必须标记 yolo"); + assert!(!records[0].confirmed, "自动放行不是用户确认"); + assert_eq!(records[0].outcome, AuditOutcome::Ok); +} + +#[test] +fn whitelist_violation_is_rejected_and_audited() { + let harness = Harness::new(vec![ + set_config_reply("java.home.mystery", json!("x")), + ChatReply::text("换个方案"), + ]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "改个不存在的键"), + ) + .unwrap(); + + assert_eq!(harness.settings.set_calls(), 0); + let records = harness.audit.records_for("set_config"); + assert_eq!(records.len(), 1); + assert_eq!(records[0].outcome, AuditOutcome::ToolError); + let results = tool_results(&harness); + assert!( + results.iter().any(|c| c.contains("白名单")), + "模型需要知道为什么被拒:{results:?}" + ); +} + +#[test] +fn invalid_args_are_rejected_before_any_side_effect() { + // 缺 value(schema required) + let harness = Harness::new(vec![ + tool_call_reply( + "c1", + "set_config", + json!({ "path": "appearance.theme_mode" }), + ), + ChatReply::text("参数补上"), + ]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "改主题"), + ) + .unwrap(); + + assert_eq!(harness.settings.set_calls(), 0); + let records = harness.audit.records_for("set_config"); + assert_eq!(records[0].outcome, AuditOutcome::InvalidArgs); + assert!(tool_results(&harness) + .iter() + .any(|c| c.contains("缺少必填参数"))); + + // 多余参数(additionalProperties: false) + let harness = Harness::new(vec![ + tool_call_reply( + "c2", + "read_game_log", + json!({ "session_id": "x", "path": "evil" }), + ), + ChatReply::text("好"), + ]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "读日志"), + ) + .unwrap(); + let records = harness.audit.records_for("read_game_log"); + assert_eq!(records[0].outcome, AuditOutcome::InvalidArgs); +} + +#[test] +fn unknown_tool_lists_available_names() { + let harness = Harness::new(vec![ + tool_call_reply("c1", "delete_everything", json!({})), + ChatReply::text("抱歉"), + ]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "删库跑路"), + ) + .unwrap(); + + let records = harness.audit.records_for("delete_everything"); + assert_eq!(records[0].outcome, AuditOutcome::UnknownTool); + let results = tool_results(&harness); + assert!( + results.iter().any(|c| c.contains("get_config")), + "{results:?}" + ); +} + +#[test] +fn locked_credential_key_is_refused_even_under_yolo() { + // M12:ai.api_key 是 writable=false(不在白名单)→ YOLO 也不能放宽可写范围 + let harness = Harness::yolo(vec![ + set_config_reply("ai.api_key", json!("sk-super-secret-value")), + ChatReply::text("换不了"), + ]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "换 key"), + ) + .unwrap(); + + assert_eq!(harness.settings.set_calls(), 0); + let records = harness.audit.records_for("set_config"); + assert_eq!(records[0].outcome, AuditOutcome::ToolError); + + let args = serde_json::to_string(&records[0].args).unwrap(); + assert!( + !args.contains("sk-super-secret-value"), + "审计绝不能落明文密钥:{args}" + ); + assert!(args.contains("***"), "{args}"); +} + +#[test] +fn get_config_refuses_credential_keys() { + // F1:模型可自由给 path,凭据键必须读不到 —— 否则值会进模型上下文发往远端 LLM。 + let harness = Harness::new(vec![ + tool_call_reply("c1", "get_config", json!({ "path": "ai.api_key" })), + ChatReply::text("读不到"), + ]); + // 模拟 M12 真实实现:凭据键在文件里确实有值(writable=false,不在白名单也要能防住读) + harness + .settings + .seed_value("ai.api_key", json!("sk-live-credential-value")); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "看看我的 key"), + ) + .unwrap(); + + // 1) 工具报错并被审计 + let records = harness.audit.records_for("get_config"); + assert_eq!(records[0].outcome, AuditOutcome::ToolError); + // 2) 模型拿到的是拒绝文案,不是值 + let results = tool_results(&harness); + assert!( + results.iter().any(|c| c.contains("凭据")), + "应给出可读拒绝:{results:?}" + ); + assert!( + !results + .iter() + .any(|c| c.contains("sk-live-credential-value")), + "工具结果绝不能带凭据:{results:?}" + ); + // 3) 事件流与对话历史里也不能出现(P1 事件会落盘 / 历史会持久化) + let leaked_in_events = harness + .events + .events() + .iter() + .any(|e| format!("{e:?}").contains("sk-live-credential-value")); + assert!(!leaked_in_events, "事件流不能带凭据明文"); + let leaked_in_history = history + .iter() + .any(|m| format!("{m:?}").contains("sk-live-credential-value")); + assert!(!leaked_in_history, "对话历史不能带凭据明文"); +} diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml new file mode 100644 index 0000000..2d13068 --- /dev/null +++ b/crates/app/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "unlucky-app" +description = "Unlucky-Launcher 桌面壳:Slint + Metro 视觉(唯一允许依赖 UI 框架的 crate)" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +publish.workspace = true + +[[bin]] +name = "unlucky-launcher" +path = "src/main.rs" + +[dependencies] +slint.workspace = true + +# 边界(01c §5 / D3):app 只经 DTO 消费 core/agent,**不 import 内部类型**。 +# P0 的 host 是 src/mock.rs 的假后端;这两个依赖在这里登记,是为了后续把真实 host +# (core 的快照/事件流翻译成 src/dto 的类型)落位时不必再动 workspace 契约。 +unlucky-core = { path = "../core", version = "0.1" } +unlucky-agent = { path = "../agent", version = "0.1" } + +[build-dependencies] +slint-build.workspace = true + +[lints] +workspace = true diff --git a/crates/app/build.rs b/crates/app/build.rs new file mode 100644 index 0000000..a67a158 --- /dev/null +++ b/crates/app/build.rs @@ -0,0 +1,7 @@ +//! Slint 编译入口:把 ui/main.slint 编译成 Rust 代码,供 src/main.rs 的 `slint::include_modules!()` 引入。 +//! 保持极简——UI 结构演进(多文件拆分、主题文件)在 M17 阶段 B 里扩展本脚本。 + +/// 编译 `ui/main.slint`;失败时让构建直接报错(UI 语法错误不该被静默忽略)。 +fn main() { + slint_build::compile("ui/main.slint").expect("Slint UI 编译失败(ui/main.slint)"); +} diff --git a/crates/app/src/appearance_bind.rs b/crates/app/src/appearance_bind.rs new file mode 100644 index 0000000..1e47787 --- /dev/null +++ b/crates/app/src/appearance_bind.rs @@ -0,0 +1,124 @@ +//! 「外观(高级)」四件的回调接线(从 `callbacks.rs` 拆出,守住单文件 ≤400 行)。 +//! +//! 与 `wire_settings` 的分工:这个文件只负责 09 §101-104 的四个高级特性 +//! (背景图 / 面板半透明度 / 玻璃拟态 / 强调色)+ 主题三态。 +//! 每个回调都是同一套动作:改 `state.prefs` → 下发到 Slint(`apply_*`)→ 落盘(`persist_settings`)→ +//! `sync()`。出厂全部关闭,所以「不碰设置页」时这里一次都不会触发。 + +use std::rc::Weak; + +use crate::conv; +use crate::ui_bind::Inner; + +/// 接主题三态与外观四件的回调。 +pub(crate) fn wire(ui: &crate::MainWindow, weak: &Weak) { + { + let weak = weak.clone(); + ui.on_theme_changed(move |mode| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().prefs.theme = conv::theme_mode_from(mode); + this.apply_theme(); + this.persist_settings(); + this.sync(); + }); + } + // ── 09 §101-104「外观(高级)」四件(出厂全关,改动即刻落盘)──────── + { + let weak = weak.clone(); + ui.on_bg_image_enabled_changed(move |enabled| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().prefs.appearance.bg_image_enabled = enabled; + this.apply_appearance(); + this.persist_settings(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_bg_image_changed(move |path| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().prefs.appearance.bg_image = path.to_string(); + this.apply_appearance(); + this.persist_settings(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_bg_fill_changed(move |fill| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().prefs.appearance.bg_fill = conv::bg_fill_from(fill); + this.apply_appearance(); + this.persist_settings(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_bg_dim_changed(move |value| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().prefs.appearance.bg_dim = value; + this.apply_appearance(); + this.persist_settings(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_panel_alpha_changed(move |value| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().prefs.appearance.panel_alpha = value; + this.apply_appearance(); + this.persist_settings(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_glass_changed(move |enabled| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().prefs.appearance.glass = enabled; + this.apply_appearance(); + this.persist_settings(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_accent_color_changed(move |text| { + let Some(this) = weak.upgrade() else { return }; + let accepted = this.state.borrow_mut().prefs.appearance.set_accent(&text); + // 非法输入:只亮设置页的 warn 行,不改现状、不落盘 + this.ui.set_accent_invalid(!accepted); + if accepted { + this.apply_appearance(); + this.persist_settings(); + } + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_accent_swatch_picked(move |index| { + let Some(this) = weak.upgrade() else { return }; + let Ok(index) = usize::try_from(index) else { + return; + }; + let accepted = this + .state + .borrow_mut() + .prefs + .appearance + .set_accent_preset(index); + if !accepted { + return; + } + let hex = this.state.borrow().prefs.appearance.accent_color.clone(); + this.ui.set_accent_color(conv::accent_color(&hex)); + this.ui.set_accent_invalid(false); + this.apply_appearance(); + this.persist_settings(); + this.sync(); + }); + } +} diff --git a/crates/app/src/bg_image.rs b/crates/app/src/bg_image.rs new file mode 100644 index 0000000..0dca80e --- /dev/null +++ b/crates/app/src/bg_image.rs @@ -0,0 +1,370 @@ +//! 背景图与玻璃拟态模糊层的准备(09 §101/§103 的「预生成」手法,纯 Rust、无新依赖)。 +//! +//! 为什么要自己算:Slint 没有 `backdrop-filter`,也无法在渲染管线里做高斯模糊。可用的只有 +//! `Image::load_from_path` + 像素缓冲(`to_rgba8` / `from_rgba8`)。于是: +//! +//! - **模糊层** = 把原图按面积平均降到极小的尺寸(默认宽 48px),渲染时被平滑放大 → 天然模糊 +//! (面积平均本身就是低通滤波,比在满分辨率上跑 box blur 快几个数量级,且一次只在设置变更时算); +//! - **tile 模式** = Slint 的 `image-fit` 没有 `repeat`,所以在 Rust 侧按窗口尺寸平铺成一张画布, +//! 交给 `image-fit: cover`(画布与窗口同尺寸,等价于 1:1 铺设)。 +//! +//! 解码格式:Slint 默认只编译 PNG 解码器(`image-default-formats` 特性未开),因此这里只保证 PNG; +//! 其它格式 `load_from_path` 直接失败,UI 侧给出提示而不是静默无效果。 + +use slint::{Image, Rgba8Pixel, SharedPixelBuffer}; + +use crate::prefs::BgFill; + +/// 模糊层目标宽度(像素)。越小越糊;96 在 1180px 宽窗口上 ≈ 24px 半径高斯的效果。 +const BLUR_WIDTH: u32 = 96; +/// 小图上再跑几轮 box blur(3×3):把降采样的块状边缘抹平。 +const BLUR_PASSES: usize = 3; +/// tile 画布上限(防超大窗口吃爆内存:4096×2560×4 ≈ 40MB)。 +const TILE_MAX_WIDTH: u32 = 4096; +const TILE_MAX_HEIGHT: u32 = 2560; + +/// 一次准备出来的两张图:原图(按填充模式处理)+ 模糊层。 +#[derive(Debug, Clone, Default)] +pub struct BackgroundLayers { + /// 铺在窗口底的图(tile 模式下已平铺成整窗画布) + pub image: Image, + /// 玻璃拟态用的模糊层 + pub blur: Image, +} + +/// 载入并加工背景图。 +/// +/// `viewport` 是窗口的**物理像素**尺寸(tile 模式平铺用;拿不到时传 `(0, 0)` 退回只载入)。 +/// 返回 `None` 表示路径无效 / 解码失败 / 读了像素但为空——调用方据此提示用户。 +#[must_use] +pub fn prepare(path: &str, fill: BgFill, viewport: (u32, u32)) -> Option { + let path = path.trim(); + if path.is_empty() { + return None; + } + let loaded = Image::load_from_path(std::path::Path::new(path)).ok()?; + let source = loaded.to_rgba8()?; + if source.width() == 0 || source.height() == 0 { + return None; + } + + let image = match fill { + BgFill::Tile => { + let (width, height) = tile_canvas_size(source.width(), source.height(), viewport); + Image::from_rgba8(tile(&source, width, height)) + } + // cover / contain 交给 Slint 的 image-fit,这里保留原图 + BgFill::Cover | BgFill::Contain => loaded, + }; + + let blurred = box_blur(&downscale(&source, BLUR_WIDTH), BLUR_PASSES); + // 放大回窗口物理尺寸再做 1:1 贴图:软件渲染器对图片缩放不做平滑插值(最近邻), + // 直接把 96px 的小图丢给它会出现明显方块;这里自己双线性放大,观感才是「磨砂」。 + let blur = match viewport { + (0, 0) => Image::from_rgba8(blurred), + (width, height) => Image::from_rgba8(upscale(&blurred, width, height)), + }; + + Some(BackgroundLayers { image, blur }) +} + +/// 3×3 均值模糊,`passes` 轮(小图上跑,代价可忽略)。 +fn box_blur( + source: &SharedPixelBuffer, + passes: usize, +) -> SharedPixelBuffer { + let width = source.width() as usize; + let height = source.height() as usize; + if width < 3 || height < 3 || passes == 0 { + return source.clone(); + } + let mut current: Vec = source.as_slice().to_vec(); + let mut next = current.clone(); + for _ in 0..passes { + for y in 0..height { + for x in 0..width { + let mut sum = [0u32; 4]; + let mut count = 0u32; + for dy in -1i32..=1 { + for dx in -1i32..=1 { + let sy = (y as i32 + dy).clamp(0, height as i32 - 1) as usize; + let sx = (x as i32 + dx).clamp(0, width as i32 - 1) as usize; + let pixel = current[sy * width + sx]; + sum[0] += u32::from(pixel.r); + sum[1] += u32::from(pixel.g); + sum[2] += u32::from(pixel.b); + sum[3] += u32::from(pixel.a); + count += 1; + } + } + next[y * width + x] = Rgba8Pixel { + r: (sum[0] / count) as u8, + g: (sum[1] / count) as u8, + b: (sum[2] / count) as u8, + a: (sum[3] / count) as u8, + }; + } + } + std::mem::swap(&mut current, &mut next); + } + let mut out = SharedPixelBuffer::::new(source.width(), source.height()); + out.make_mut_slice().copy_from_slice(¤t); + out +} + +/// 双线性放大到目标尺寸。 +fn upscale( + source: &SharedPixelBuffer, + width: u32, + height: u32, +) -> SharedPixelBuffer { + let src_w = source.width(); + let src_h = source.height(); + if src_w == 0 || src_h == 0 || width == 0 || height == 0 { + return SharedPixelBuffer::new(width.max(1), height.max(1)); + } + let pixels = source.as_slice(); + let mut out = SharedPixelBuffer::::new(width, height); + let dst = out.make_mut_slice(); + let sample = |fx: f32, fy: f32| -> Rgba8Pixel { + let x = (fx * (src_w - 1) as f32).clamp(0.0, (src_w - 1) as f32); + let y = (fy * (src_h - 1) as f32).clamp(0.0, (src_h - 1) as f32); + let x0 = x.floor() as usize; + let y0 = y.floor() as usize; + let x1 = (x0 + 1).min(src_w as usize - 1); + let y1 = (y0 + 1).min(src_h as usize - 1); + let tx = x - x0 as f32; + let ty = y - y0 as f32; + let at = |px: usize, py: usize| pixels[py * src_w as usize + px]; + let blend = |a: u8, b: u8, c: u8, d: u8| -> u8 { + let top = f32::from(a) * (1.0 - tx) + f32::from(b) * tx; + let bottom = f32::from(c) * (1.0 - tx) + f32::from(d) * tx; + (top * (1.0 - ty) + bottom * ty).round().clamp(0.0, 255.0) as u8 + }; + let (p00, p10, p01, p11) = (at(x0, y0), at(x1, y0), at(x0, y1), at(x1, y1)); + Rgba8Pixel { + r: blend(p00.r, p10.r, p01.r, p11.r), + g: blend(p00.g, p10.g, p01.g, p11.g), + b: blend(p00.b, p10.b, p01.b, p11.b), + a: blend(p00.a, p10.a, p01.a, p11.a), + } + }; + for y in 0..height as usize { + let fy = y as f32 / height as f32; + for x in 0..width as usize { + dst[y * width as usize + x] = sample(x as f32 / width as f32, fy); + } + } + out +} + +/// 平铺画布尺寸:覆盖整个视口、且是原图尺寸的整数倍(超上限就截断)。 +fn tile_canvas_size(source_width: u32, source_height: u32, viewport: (u32, u32)) -> (u32, u32) { + if viewport.0 == 0 || viewport.1 == 0 { + return (source_width, source_height); + } + let repeat_x = viewport.0.div_ceil(source_width).max(1); + let repeat_y = viewport.1.div_ceil(source_height).max(1); + let width = (repeat_x * source_width).min(TILE_MAX_WIDTH); + let height = (repeat_y * source_height).min(TILE_MAX_HEIGHT); + (width.max(source_width), height.max(source_height)) +} + +/// 按整数格平铺(源图超出画布的部分裁掉)。 +fn tile( + source: &SharedPixelBuffer, + width: u32, + height: u32, +) -> SharedPixelBuffer { + let mut canvas = SharedPixelBuffer::::new(width, height); + let src_w = source.width() as usize; + let src_h = source.height() as usize; + let src = source.as_slice(); + let dst = canvas.make_mut_slice(); + for y in 0..height as usize { + let src_row = (y % src_h) * src_w; + let dst_row = y * width as usize; + for x in 0..width as usize { + dst[dst_row + x] = src[src_row + (x % src_w)]; + } + } + canvas +} + +/// 面积平均降采样(保留宽高比,最窄 1px)。 +fn downscale( + source: &SharedPixelBuffer, + target_width: u32, +) -> SharedPixelBuffer { + let src_w = source.width(); + let src_h = source.height(); + let target_width = target_width.clamp(1, src_w); + let target_height = ((src_h as u64 * target_width as u64) / src_w as u64).max(1) as u32; + + let mut out = SharedPixelBuffer::::new(target_width, target_height); + let src = source.as_slice(); + let dst = out.make_mut_slice(); + + for ty in 0..target_height as usize { + // 每个目标像素覆盖的源矩形(按比例分块,块内取平均) + let y0 = ty * src_h as usize / target_height as usize; + let y1 = ((ty + 1) * src_h as usize / target_height as usize).max(y0 + 1); + for tx in 0..target_width as usize { + let x0 = tx * src_w as usize / target_width as usize; + let x1 = ((tx + 1) * src_w as usize / target_width as usize).max(x0 + 1); + let mut sum = [0u32; 4]; + let mut count = 0u32; + for y in y0..y1.min(src_h as usize) { + for x in x0..x1.min(src_w as usize) { + let pixel = src[y * src_w as usize + x]; + sum[0] += u32::from(pixel.r); + sum[1] += u32::from(pixel.g); + sum[2] += u32::from(pixel.b); + sum[3] += u32::from(pixel.a); + count += 1; + } + } + let count = count.max(1); + dst[ty * target_width as usize + tx] = Rgba8Pixel { + r: (sum[0] / count) as u8, + g: (sum[1] / count) as u8, + b: (sum[2] / count) as u8, + a: (sum[3] / count) as u8, + }; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn solid(width: u32, height: u32, pixel: Rgba8Pixel) -> SharedPixelBuffer { + let mut buffer = SharedPixelBuffer::::new(width, height); + buffer.make_mut_slice().fill(pixel); + buffer + } + + #[test] + fn downscale_keeps_aspect_and_averages_area() { + let mut source = SharedPixelBuffer::::new(96, 48); + // 左半黑、右半白:降采样后左右两侧应各自保持纯色 + for y in 0..48 { + for x in 0..96 { + let value = if x < 48 { 0 } else { 255 }; + source.make_mut_slice()[y * 96 + x] = Rgba8Pixel { + r: value, + g: value, + b: value, + a: 255, + }; + } + } + let small = downscale(&source, 24); + assert_eq!(small.width(), 24); + assert_eq!(small.height(), 12, "宽高比保持 2:1"); + let pixels = small.as_slice(); + assert_eq!(pixels[0].r, 0, "左侧仍是黑"); + assert_eq!(pixels[23].r, 255, "右侧仍是白"); + } + + #[test] + fn downscale_never_produces_zero_height() { + let source = solid( + 400, + 3, + Rgba8Pixel { + r: 1, + g: 2, + b: 3, + a: 255, + }, + ); + let small = downscale(&source, 2); + assert_eq!(small.width(), 2); + assert!(small.height() >= 1); + } + + #[test] + fn tile_canvas_covers_viewport_in_whole_repeats() { + assert_eq!(tile_canvas_size(120, 100, (1180, 760)), (1200, 800)); + // 视口未知(取不到窗口尺寸)时退回单块 + assert_eq!(tile_canvas_size(120, 100, (0, 0)), (120, 100)); + // 超大视口被上限截断 + let (width, height) = tile_canvas_size(64, 64, (9000, 9000)); + assert!(width <= TILE_MAX_WIDTH && height <= TILE_MAX_HEIGHT); + } + + #[test] + fn tile_repeats_source_pixels() { + let source = solid( + 2, + 2, + Rgba8Pixel { + r: 9, + g: 9, + b: 9, + a: 255, + }, + ); + let canvas = tile(&source, 5, 3); + assert_eq!(canvas.width(), 5); + assert_eq!(canvas.height(), 3); + assert!(canvas.as_slice().iter().all(|pixel| pixel.r == 9)); + } + + #[test] + fn prepare_rejects_bad_input() { + assert!(prepare("", BgFill::Cover, (100, 100)).is_none()); + assert!(prepare("D:/definitely/not/here.png", BgFill::Cover, (100, 100)).is_none()); + } + + #[test] + fn box_blur_smooths_a_hard_edge() { + // 左黑右白:模糊后交界处应出现中间灰(硬边被抹平) + let mut source = SharedPixelBuffer::::new(9, 3); + for y in 0..3 { + for x in 0..9 { + let value = if x < 4 { 0 } else { 255 }; + source.make_mut_slice()[y * 9 + x] = Rgba8Pixel { + r: value, + g: value, + b: value, + a: 255, + }; + } + } + let blurred = box_blur(&source, 2); + let middle = blurred.as_slice()[9 + 4]; // 第 1 行(共 9 列)的第 4 个像素 + assert!( + middle.r > 20 && middle.r < 235, + "交界处应被抹成中间灰,实际 {}", + middle.r + ); + } + + #[test] + fn upscale_matches_requested_size_and_interpolates() { + let mut source = SharedPixelBuffer::::new(2, 1); + let pixels = source.make_mut_slice(); + pixels[0] = Rgba8Pixel { + r: 0, + g: 0, + b: 0, + a: 255, + }; + pixels[1] = Rgba8Pixel { + r: 255, + g: 255, + b: 255, + a: 255, + }; + let big = upscale(&source, 8, 2); + assert_eq!(big.width(), 8); + assert_eq!(big.height(), 2); + let row = big.as_slice(); + // 双线性:中间像素应是灰阶过渡而不是 0/255 二值 + assert!(row[3].r > 60 && row[3].r < 200, "实际 {}", row[3].r); + } +} diff --git a/crates/app/src/callbacks.rs b/crates/app/src/callbacks.rs new file mode 100644 index 0000000..35e7e92 --- /dev/null +++ b/crates/app/src/callbacks.rs @@ -0,0 +1,354 @@ +//! 回调接线:把 `ui/main.slint` 暴露的每个回调翻译成「状态变更 + AppCommand + 同步」。 +//! +//! 逐条列出而不做通用转发,是为了让「用户能做的每一件事」在这里一目了然; +//! 新增交互时只改这一处 + `.slint` 的 callback 声明。 + +use std::rc::Weak; + +use slint::ComponentHandle; + +use crate::conv; +use crate::dto::AppCommand; +use crate::router::Route; +use crate::toast::ToastKind; +use crate::ui_bind::Inner; + +/// 接全部回调;闭包持弱引用,避免 `MainWindow` 与闭包互相持有导致窗口永不释放。 +pub(crate) fn wire(weak: Weak) { + let Some(inner) = weak.upgrade() else { return }; + // 只在注册期间持有强引用;闭包内一律持弱引用(否则窗口与闭包互相持有、永不释放)。 + let ui = &inner.ui; + + wire_window(ui, &weak); + wire_navigation(ui, &weak); + wire_instances(ui, &weak); + wire_download(ui, &weak); + wire_logs(ui, &weak); + wire_settings(ui, &weak); + wire_overlays(ui, &weak); +} + +fn wire_window(ui: &crate::MainWindow, weak: &Weak) { + { + let weak = weak.clone(); + ui.on_toggle_maximize(move || { + let Some(this) = weak.upgrade() else { return }; + let window = this.ui.window(); + window.set_maximized(!window.is_maximized()); + }); + } + { + let weak = weak.clone(); + ui.on_minimize(move || { + if let Some(this) = weak.upgrade() { + this.ui.window().set_minimized(true); + } + }); + } + { + let weak = weak.clone(); + ui.on_request_close(move || { + if let Some(this) = weak.upgrade() { + let _ = this.ui.hide(); + } + let _ = slint::quit_event_loop(); + }); + } +} + +fn wire_navigation(ui: &crate::MainWindow, weak: &Weak) { + { + let weak = weak.clone(); + ui.on_navigate(move |route| { + let Some(this) = weak.upgrade() else { return }; + if let Some(route) = conv::route_from(route) { + this.state.borrow_mut().navigate(route); + this.sync(); + } + }); + } + { + let weak = weak.clone(); + ui.on_toggle_nav(move || { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().toggle_nav(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_toggle_ai(move || { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().toggle_ai(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_expand_ai(move || { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().navigate(Route::Agent); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_dock_ai(move || { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().back(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_toggle_yolo(move || { + let Some(this) = weak.upgrade() else { return }; + let enabled = { + let mut state = this.state.borrow_mut(); + state.ai.yolo = !state.ai.yolo; + state.ai.yolo + }; + this.dispatch(AppCommand::SetYolo(enabled)); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_ai_send(move |text| { + let Some(this) = weak.upgrade() else { return }; + let text = text.to_string(); + if text.trim().is_empty() { + return; + } + // 不做本地回显:user 消息由 host 的 MessageAppended 事件带回(契约 §3) + this.state.borrow_mut().ai.draft.clear(); + this.ui.set_ai_draft("".into()); + this.ask_agent(&text); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_ai_stop(move || { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().ai.finish_stream(); + this.dispatch(AppCommand::StopAgent); + this.sync(); + }); + } +} + +fn wire_instances(ui: &crate::MainWindow, weak: &Weak) { + { + let weak = weak.clone(); + ui.on_instance_launch(move |id| { + let Some(this) = weak.upgrade() else { return }; + this.dispatch(AppCommand::LaunchInstance(id.to_string())); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_instance_configure(move |id| { + let Some(this) = weak.upgrade() else { return }; + // 齿轮键进实例详情(G4 占位页),实例级设置后续挂在该页 tab 上 + this.state + .borrow_mut() + .navigate(Route::Detail(id.to_string())); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_instance_stop(move |id| { + let Some(this) = weak.upgrade() else { return }; + this.dispatch(AppCommand::StopInstance(id.to_string())); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_instance_open(move |id| { + let Some(this) = weak.upgrade() else { return }; + this.state + .borrow_mut() + .navigate(Route::Detail(id.to_string())); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_new_instance(move || { + let Some(this) = weak.upgrade() else { return }; + this.state + .borrow_mut() + .toasts + .push(ToastKind::Info, "新建实例向导(G5)在 P1 落地"); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_instance_view_changed(move |view| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().prefs.instance_view = conv::instance_view_from(view); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_tile_size_changed(move |size| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().prefs.tile_size = conv::tile_size_from(size); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_instance_query_changed(move |query| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().instance_query = query.to_string(); + this.sync(); + }); + } +} + +fn wire_download(ui: &crate::MainWindow, weak: &Weak) { + { + let weak = weak.clone(); + ui.on_download_category_changed(move |category| { + let Some(this) = weak.upgrade() else { return }; + let category = conv::version_category(category); + this.state.borrow_mut().prefs.download_category = category; + this.dispatch(AppCommand::ListVersions(category)); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_download_install(move |id| { + let Some(this) = weak.upgrade() else { return }; + this.dispatch(AppCommand::InstallVersion(id.to_string())); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_installed_only_changed(move |only| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().installed_only = only; + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_download_query_changed(move |query| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().download_query = query.to_string(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_ancient_help(move || { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().toasts.push( + ToastKind::Info, + "远古版(classic/alpha/beta)需旧版 Java 与旧启动参数;完整说明页 P1 接入", + ); + this.sync(); + }); + } +} + +fn wire_logs(ui: &crate::MainWindow, weak: &Weak) { + { + let weak = weak.clone(); + ui.on_log_select(move |id| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().selected_session = Some(id.to_string()); + this.dispatch(AppCommand::OpenLogSession(id.to_string())); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_log_ask_ai(move |id| { + let Some(this) = weak.upgrade() else { return }; + let question = format!("帮我看下会话 {id} 为什么崩溃"); + this.state.borrow_mut().navigate(Route::Agent); + this.ask_agent(&question); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_log_apply_fix(move || { + let Some(this) = weak.upgrade() else { return }; + this.state + .borrow_mut() + .toasts + .push(ToastKind::Warn, "一键修复在 P1 接 diagnose 的 fix_action"); + this.sync(); + }); + } +} + +fn wire_settings(ui: &crate::MainWindow, weak: &Weak) { + // 主题三态 + 外观四件在 appearance_bind.rs(拆文件守 400 行) + crate::appearance_bind::wire(ui, weak); + { + let weak = weak.clone(); + ui.on_yolo_changed(move |enabled| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().ai.yolo = enabled; + this.dispatch(AppCommand::SetYolo(enabled)); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_mcp_changed(move |_enabled| { + let Some(this) = weak.upgrade() else { return }; + this.state + .borrow_mut() + .toasts + .push(ToastKind::Info, "MCP server 开关在 P1 接 agent"); + this.sync(); + }); + } + // P0 只保留入口:设置分组与 AI 端点暂不落状态(P1 接 setting schema)。 + ui.on_settings_section_changed(|_section| {}); + ui.on_ai_endpoint_changed(|_index| {}); +} + +fn wire_overlays(ui: &crate::MainWindow, weak: &Weak) { + { + let weak = weak.clone(); + ui.on_dialog_primary(move || close_current_dialog(&weak)); + } + { + let weak = weak.clone(); + ui.on_dialog_secondary(move || close_current_dialog(&weak)); + } + { + let weak = weak.clone(); + ui.on_dialog_dismissed(move || close_current_dialog(&weak)); + } + { + let weak = weak.clone(); + ui.on_toast_dismissed(move |id| { + let Some(this) = weak.upgrade() else { return }; + if let Ok(id) = id.parse::() { + this.state.borrow_mut().toasts.dismiss(id); + this.sync(); + } + }); + } +} + +fn close_current_dialog(weak: &Weak) { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().dialogs.close_current(); + this.sync(); +} diff --git a/crates/app/src/conv/agent.rs b/crates/app/src/conv/agent.rs new file mode 100644 index 0000000..bb1ed4a --- /dev/null +++ b/crates/app/src/conv/agent.rs @@ -0,0 +1,103 @@ +//! AI 页与覆盖层:会话消息、对话框(一次一条)、Toast。 + +use crate::dialogs as dlg; +use crate::dto::ChatRole as DtoChatRole; +use crate::state::AppState; +use crate::toast as toast_mod; +use crate::{ChatRole, DialogKind, UiChatMessage, UiDialog, UiToast}; + +/// AI 会话消息。 +pub fn chat(state: &AppState) -> Vec { + state + .ai + .messages + .iter() + .map(|message| UiChatMessage { + role: match message.role { + DtoChatRole::User => ChatRole::User, + DtoChatRole::Assistant => ChatRole::Assistant, + DtoChatRole::Tool => ChatRole::Tool, + DtoChatRole::System => ChatRole::System, + }, + text: message.text.clone().into(), + tool_name: message + .tool + .as_ref() + .map(|tool| tool.name.clone()) + .unwrap_or_default() + .into(), + tool_ok: message.tool.as_ref().is_none_or(|tool| tool.ok), + streaming: message.streaming, + }) + .collect() +} + +/// 当前对话框(一次一条)+ 是否显示 + 队列剩余条数。 +pub fn dialog(state: &AppState) -> (UiDialog, bool, i32) { + let queued = i32::try_from(state.dialogs.pending_len()).unwrap_or(0); + match state.dialogs.current() { + None => (UiDialog::default(), false, queued), + Some(request) => { + let (kind, primary, secondary) = match &request.kind { + dlg::DialogKind::Confirm { primary, secondary } => { + (DialogKind::Confirm, primary.clone(), secondary.clone()) + } + dlg::DialogKind::Input { primary, .. } => { + (DialogKind::Input, primary.clone(), String::new()) + } + dlg::DialogKind::Progress { .. } => { + (DialogKind::Progress, "取消".to_string(), String::new()) + } + dlg::DialogKind::Error { primary, .. } => { + (DialogKind::Error, primary.clone(), "忽略".to_string()) + } + }; + let progress = match &request.kind { + dlg::DialogKind::Progress { progress, .. } => *progress, + _ => 0.0, + }; + let input_label = match &request.kind { + dlg::DialogKind::Input { label, .. } => label.clone(), + _ => String::new(), + }; + let input_value = match &request.kind { + dlg::DialogKind::Input { value, .. } => value.clone(), + _ => String::new(), + }; + ( + UiDialog { + id: request.id.to_string().into(), + kind, + title: request.title.clone().into(), + body: request.body.clone().into(), + primary: primary.into(), + secondary: secondary.into(), + progress, + input_label: input_label.into(), + input_value: input_value.into(), + }, + true, + queued, + ) + } + } +} + +/// Toast 列表。 +pub fn toasts(state: &AppState) -> Vec { + state + .toasts + .items() + .iter() + .map(|toast| UiToast { + id: toast.id.to_string().into(), + kind: match toast.kind { + toast_mod::ToastKind::Info => crate::ToastKind::Info, + toast_mod::ToastKind::Success => crate::ToastKind::Success, + toast_mod::ToastKind::Warn => crate::ToastKind::Warn, + toast_mod::ToastKind::Error => crate::ToastKind::Error, + }, + text: toast.text.clone().into(), + }) + .collect() +} diff --git a/crates/app/src/conv/instances.rs b/crates/app/src/conv/instances.rs new file mode 100644 index 0000000..1d47a93 --- /dev/null +++ b/crates/app/src/conv/instances.rs @@ -0,0 +1,162 @@ +//! 实例相关:磁贴外观(相位/进度/徽标/占位色)、运行中列表。 + +use slint::Color; + +use crate::dto::{self, InstanceRuntime}; +use crate::state::{AppState, TileCell}; +use crate::{LoaderKind, RuntimePhase, UiTile}; + +/// 头像/图标占位的底色池(Metro 强调色系,按 id 哈希稳定取色)。 +const HUES: [u32; 8] = [ + 0x0078D4, 0x8764B0, 0xD13438, 0xCA5010, 0x498205, 0x038387, 0x5C2D91, 0x0050EF, +]; + +/// 加载器枚举。 +pub fn loader(kind: dto::LoaderKind) -> LoaderKind { + match kind { + dto::LoaderKind::Vanilla => LoaderKind::Vanilla, + dto::LoaderKind::Fabric => LoaderKind::Fabric, + dto::LoaderKind::Quilt => LoaderKind::Quilt, + dto::LoaderKind::Forge => LoaderKind::Forge, + dto::LoaderKind::NeoForge => LoaderKind::Neoforge, + dto::LoaderKind::OptiFine => LoaderKind::Optifine, + dto::LoaderKind::LiteLoader => LoaderKind::Liteloader, + } +} + +/// 运行时状态 → (磁贴外观相位, 进度 0~1, 阶段文案)。 +pub fn phase(runtime: &InstanceRuntime) -> (RuntimePhase, f32, String) { + match runtime { + InstanceRuntime::Idle => (RuntimePhase::Idle, 0.0, String::new()), + InstanceRuntime::Busy { stage, progress } => { + let phase = match stage { + dto::LaunchStage::Download => RuntimePhase::Downloading, + dto::LaunchStage::Verify => RuntimePhase::Verifying, + dto::LaunchStage::Launch => RuntimePhase::Launching, + }; + (phase, progress.ratio(), stage.label().to_string()) + } + InstanceRuntime::Running { .. } => (RuntimePhase::Running, 1.0, "运行中".to_string()), + InstanceRuntime::Failed { reason } => (RuntimePhase::Failed, 0.0, reason.clone()), + } +} + +/// 名字首字(无图标时的占位字母)。 +pub fn initial_of(name: &str) -> String { + name.chars() + .next() + .map(|c| c.to_string()) + .unwrap_or_else(|| "?".to_string()) +} + +/// id → 稳定底色(同一实例每次启动同色)。 +pub fn hue_of(id: &str) -> Color { + let hash = id.bytes().fold(0u32, |acc, byte| { + acc.wrapping_mul(31).wrapping_add(u32::from(byte)) + }); + Color::from_argb_encoded(0xFF00_0000 | HUES[(hash as usize) % HUES.len()]) +} + +/// ISO-8601 UTC → 逗号少的本地展示串(`2026-10-29 19:22`);None/空 → 从未启动。 +pub fn last_played(raw: &Option) -> String { + match raw { + None => "从未启动".to_string(), + Some(value) if value.is_empty() => "从未启动".to_string(), + Some(value) => { + let trimmed = value.trim_end_matches('Z'); + let mut out = String::with_capacity(trimmed.len()); + let mut seen_time = false; + for ch in trimmed.chars() { + match ch { + 'T' => { + seen_time = true; + out.push(' '); + } + ':' if seen_time && out.ends_with(|c: char| c.is_ascii_digit()) => { + // 只保留到分钟:秒(第三段冒号之后)直接截断 + if out.matches(':').count() >= 1 { + break; + } + out.push(ch); + } + _ => out.push(ch), + } + } + out + } + } +} + +/// 单个磁贴。 +pub fn tile(cell: &TileCell<'_>) -> UiTile { + let instance = cell.instance; + let (phase, progress, label) = phase(&instance.runtime); + UiTile { + id: instance.id.clone().into(), + name: instance.name.clone().into(), + mc_version: instance.mc_version.clone().into(), + loader: loader(instance.loader.kind), + loader_version: instance.loader.version.clone().into(), + loader_label: instance.loader.badge().into(), + last_played: last_played(&instance.last_played).into(), + phase, + phase_label: label.into(), + progress, + icon: slint::Image::default(), + hue: hue_of(&instance.id), + initial: initial_of(&instance.name).into(), + row: i32::try_from(cell.row).unwrap_or(0), + col: i32::try_from(cell.col).unwrap_or(0), + } +} + +/// 磁贴网格(已按当前尺寸算好 row/col)。 +pub fn tiles(state: &AppState) -> Vec { + state.tiles().iter().map(tile).collect() +} + +/// 运行中实例(顶部迷你状态条)。 +pub fn running_tiles(state: &AppState) -> Vec { + state + .running() + .iter() + .enumerate() + .map(|(index, instance)| { + let cell = TileCell { + instance, + row: 0, + col: index, + }; + tile(&cell) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{hue_of, initial_of, last_played}; + + #[test] + fn initial_is_first_grapheme_or_question_mark() { + assert_eq!(initial_of("生存整合包"), "生"); + assert_eq!(initial_of("Vanilla"), "V"); + assert_eq!(initial_of(""), "?"); + } + + #[test] + fn hue_is_stable_per_id() { + assert_eq!(hue_of("survival"), hue_of("survival")); + // 不同 id 大体不同色(哈希池 8 色,这里只验证确定性 + 不 panic) + let _ = hue_of("another-instance"); + } + + #[test] + fn last_played_formats_iso_to_minutes() { + assert_eq!( + last_played(&Some("2026-10-29T19:22:45Z".to_string())), + "2026-10-29 19:22" + ); + assert_eq!(last_played(&None), "从未启动"); + assert_eq!(last_played(&Some(String::new())), "从未启动"); + } +} diff --git a/crates/app/src/conv/logs.rs b/crates/app/src/conv/logs.rs new file mode 100644 index 0000000..ba72410 --- /dev/null +++ b/crates/app/src/conv/logs.rs @@ -0,0 +1,84 @@ +//! 日志页:会话列表、日志行、D8 诊断卡片。 + +use crate::dto::{LogLevel as DtoLogLevel, LogOutcome as DtoLogOutcome}; +use crate::state::AppState; +use crate::{LogLevel, LogOutcome, UiDiagnosis, UiLogLine, UiLogSession}; + +/// 日志会话列表。 +pub fn log_sessions(state: &AppState) -> Vec { + state + .log_sessions + .iter() + .map(|session| UiLogSession { + id: session.id.clone().into(), + when: session.when.clone().into(), + instance: session.instance.clone().into(), + outcome: match session.outcome { + DtoLogOutcome::Ok => LogOutcome::Ok, + DtoLogOutcome::Crashed => LogOutcome::Crashed, + DtoLogOutcome::SilentFail => LogOutcome::SilentFail, + }, + duration: duration(session.duration_secs).into(), + }) + .collect() +} + +/// 秒 → `1h03m` / `62s`。 +pub fn duration(secs: u64) -> String { + if secs >= 3_600 { + format!("{}h{:02}m", secs / 3_600, (secs % 3_600) / 60) + } else if secs >= 60 { + format!("{}m{:02}s", secs / 60, secs % 60) + } else { + format!("{secs}s") + } +} + +/// 当前会话日志行。 +pub fn log_lines(state: &AppState) -> Vec { + state + .log_lines + .iter() + .map(|line| UiLogLine { + text: line.text.clone().into(), + level: match line.level { + DtoLogLevel::Debug => LogLevel::Debug, + DtoLogLevel::Info => LogLevel::Info, + DtoLogLevel::Warn => LogLevel::Warn, + DtoLogLevel::Error => LogLevel::Error, + }, + }) + .collect() +} + +/// 诊断卡片内容(无结论时返回默认值 + has=false)。 +pub fn diagnosis(state: &AppState) -> (UiDiagnosis, bool) { + match &state.diagnosis { + None => (UiDiagnosis::default(), false), + Some(report) => { + let diagnosis = &report.diagnosis; + ( + UiDiagnosis { + rule: diagnosis.rule.clone().into(), + title: diagnosis.title.clone().into(), + confidence: i32::from(diagnosis.confidence), + hint: diagnosis.hint.clone().into(), + has_fix: diagnosis.fix.is_some(), + }, + true, + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::duration; + + #[test] + fn duration_is_human_readable() { + assert_eq!(duration(8), "8s"); + assert_eq!(duration(62), "1m02s"); + assert_eq!(duration(3_780), "1h03m"); + } +} diff --git a/crates/app/src/conv/mod.rs b/crates/app/src/conv/mod.rs new file mode 100644 index 0000000..d556a50 --- /dev/null +++ b/crates/app/src/conv/mod.rs @@ -0,0 +1,21 @@ +//! 状态层 → Slint 视图模型的单向转换(DTO 值 → `models.slint` 结构体)。 +//! +//! 只做「领域值 → 展示值」的搬运与格式化,不含业务判断; +//! 这样 `ui_bind.rs` 里的同步逻辑就是纯粹的「状态灌进属性」。 +//! 按领域拆子模块:instances / versions / logs / agent / ui,全量 `pub use`, +//! 调用方统一写 `crate::conv::tiles(...)` 这类路径。 + +mod agent; +mod instances; +mod logs; +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 ui::{ + accent_color, bg_fill, bg_fill_from, category_from, instance_view, instance_view_from, route, + route_from, theme_mode, theme_mode_from, tile_size, tile_size_from, version_category, +}; +pub use versions::versions; diff --git a/crates/app/src/conv/ui.rs b/crates/app/src/conv/ui.rs new file mode 100644 index 0000000..22c6d20 --- /dev/null +++ b/crates/app/src/conv/ui.rs @@ -0,0 +1,136 @@ +//! UI 偏好/路由枚举的双向映射(Slint 枚举 ↔ 状态层枚举)。 + +use crate::dto::{self, VersionCategory}; +use crate::prefs; +use crate::router; +use crate::{BgFillKind, DownloadCategory, InstanceView, RouteKind, ThemeMode, TileSize}; + +/// 路由 → 导航高亮/页面选择。 +pub fn route(route: &router::Route) -> RouteKind { + match route { + router::Route::Instances => RouteKind::Instances, + router::Route::Download => RouteKind::Download, + router::Route::Logs => RouteKind::Logs, + router::Route::Settings => RouteKind::Settings, + router::Route::Detail(_) => RouteKind::Detail, + router::Route::Agent => RouteKind::Agent, + } +} + +/// Slint 侧导航点击 → 路由(详情页保持当前实例,不在导航里触发)。 +pub fn route_from(kind: RouteKind) -> Option { + match kind { + RouteKind::Instances => Some(router::Route::Instances), + RouteKind::Download => Some(router::Route::Download), + RouteKind::Logs => Some(router::Route::Logs), + RouteKind::Settings => Some(router::Route::Settings), + RouteKind::Agent => Some(router::Route::Agent), + RouteKind::Detail => None, + } +} + +/// 磁贴尺寸。 +pub fn tile_size(size: prefs::TileSize) -> TileSize { + match size { + prefs::TileSize::Small => TileSize::Small, + prefs::TileSize::Medium => TileSize::Medium, + prefs::TileSize::Large => TileSize::Large, + } +} + +/// 视图模式。 +pub fn instance_view(view: prefs::InstanceView) -> InstanceView { + match view { + prefs::InstanceView::Tiles => InstanceView::Tiles, + prefs::InstanceView::List => InstanceView::List, + } +} + +/// 主题三态。 +pub fn theme_mode(mode: prefs::ThemeMode) -> ThemeMode { + match mode { + prefs::ThemeMode::Auto => ThemeMode::Auto, + prefs::ThemeMode::Light => ThemeMode::Light, + prefs::ThemeMode::Dark => ThemeMode::Dark, + } +} + +/// Slint 主题三态 → 偏好枚举。 +pub fn theme_mode_from(mode: ThemeMode) -> prefs::ThemeMode { + match mode { + ThemeMode::Auto => prefs::ThemeMode::Auto, + ThemeMode::Light => prefs::ThemeMode::Light, + ThemeMode::Dark => prefs::ThemeMode::Dark, + } +} + +/// Slint 磁贴尺寸 → 偏好枚举。 +pub fn tile_size_from(size: TileSize) -> prefs::TileSize { + match size { + TileSize::Small => prefs::TileSize::Small, + TileSize::Medium => prefs::TileSize::Medium, + TileSize::Large => prefs::TileSize::Large, + } +} + +/// Slint 视图模式 → 偏好枚举。 +pub fn instance_view_from(view: InstanceView) -> prefs::InstanceView { + match view { + InstanceView::Tiles => prefs::InstanceView::Tiles, + InstanceView::List => prefs::InstanceView::List, + } +} + +/// 下载分类 → core 侧分类枚举。 +pub fn version_category(category: DownloadCategory) -> VersionCategory { + match category { + DownloadCategory::Release => VersionCategory::Release, + DownloadCategory::Snapshot => VersionCategory::Snapshot, + DownloadCategory::AncientClassic => VersionCategory::Ancient(dto::AncientKind::Classic), + DownloadCategory::AncientAlpha => VersionCategory::Ancient(dto::AncientKind::OldAlpha), + DownloadCategory::AncientBeta => VersionCategory::Ancient(dto::AncientKind::OldBeta), + DownloadCategory::AncientUnlisted => VersionCategory::Ancient(dto::AncientKind::Unlisted), + DownloadCategory::Modpack => VersionCategory::Modpack, + DownloadCategory::Mod => VersionCategory::Mod, + } +} + +/// 下载分类(偏好 → Slint)——供启动时回填。 +pub fn category_from(category: VersionCategory) -> DownloadCategory { + match category { + VersionCategory::Release => DownloadCategory::Release, + VersionCategory::Snapshot => DownloadCategory::Snapshot, + VersionCategory::Ancient(dto::AncientKind::Classic) => DownloadCategory::AncientClassic, + VersionCategory::Ancient(dto::AncientKind::OldAlpha) => DownloadCategory::AncientAlpha, + VersionCategory::Ancient(dto::AncientKind::OldBeta) => DownloadCategory::AncientBeta, + VersionCategory::Ancient(dto::AncientKind::Unlisted) => DownloadCategory::AncientUnlisted, + VersionCategory::Modpack => DownloadCategory::Modpack, + VersionCategory::Mod => DownloadCategory::Mod, + } +} + +/// 背景图填充模式(core 侧枚举 → Slint)。 +pub fn bg_fill(fill: unlucky_core::setting::appearance::BgFill) -> BgFillKind { + match fill { + unlucky_core::setting::appearance::BgFill::Cover => BgFillKind::Cover, + unlucky_core::setting::appearance::BgFill::Contain => BgFillKind::Contain, + unlucky_core::setting::appearance::BgFill::Tile => BgFillKind::Tile, + } +} + +/// Slint 填充模式 → core 侧枚举。 +pub fn bg_fill_from(fill: BgFillKind) -> unlucky_core::setting::appearance::BgFill { + match fill { + BgFillKind::Cover => unlucky_core::setting::appearance::BgFill::Cover, + BgFillKind::Contain => unlucky_core::setting::appearance::BgFill::Contain, + BgFillKind::Tile => unlucky_core::setting::appearance::BgFill::Tile, + } +} + +/// 强调色十六进制串 → Slint 颜色;空串/非法值给透明(= 用出厂 accent)。 +pub fn accent_color(hex: &str) -> slint::Color { + match unlucky_core::setting::appearance::hex_to_rgb(hex) { + Some((r, g, b)) => slint::Color::from_rgb_u8(r, g, b), + None => slint::Color::from_argb_u8(0, 0, 0, 0), + } +} diff --git a/crates/app/src/conv/versions.rs b/crates/app/src/conv/versions.rs new file mode 100644 index 0000000..35e5667 --- /dev/null +++ b/crates/app/src/conv/versions.rs @@ -0,0 +1,81 @@ +//! 版本列表与下载分类映射(下载页)。 + +use crate::dto::{AncientKind, VersionKind as DtoVersionKind}; +use crate::state::AppState; +use crate::{UiVersion, VersionKind}; + +/// 下载页版本行(已按「仅已装」+ 版本号筛选词过滤)。 +pub fn versions(state: &AppState) -> Vec { + let query = state.download_query.trim().to_lowercase(); + state + .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), + released: version.released.clone().into(), + installed: version.installed, + ancient: version.kind.is_ancient(), + }) + .collect() +} + +fn version_kind(kind: DtoVersionKind) -> VersionKind { + match kind { + DtoVersionKind::Release => VersionKind::Release, + DtoVersionKind::Snapshot => VersionKind::Snapshot, + DtoVersionKind::Ancient(AncientKind::Classic) => VersionKind::AncientClassic, + DtoVersionKind::Ancient(AncientKind::OldAlpha) => VersionKind::AncientAlpha, + DtoVersionKind::Ancient(AncientKind::OldBeta) => VersionKind::AncientBeta, + DtoVersionKind::Ancient(AncientKind::Unlisted) => VersionKind::AncientUnlisted, + } +} + +#[cfg(test)] +mod tests { + use super::versions; + use crate::dto::{VersionKind, VersionSummary}; + use crate::state::AppState; + + fn state_with_versions() -> AppState { + let mut state = AppState::new(); + let row = |id: &str, installed: bool| VersionSummary { + id: id.to_string(), + kind: VersionKind::Release, + released: "2026-01-01".to_string(), + installed, + }; + state.versions = vec![ + row("1.21.9", false), + row("1.20.4", true), + row("1.20.1", true), + ]; + state + } + + #[test] + fn installed_only_filter_hides_uninstalled_versions() { + let mut state = state_with_versions(); + assert_eq!(versions(&state).len(), 3); + + state.installed_only = true; + let filtered = versions(&state); + assert_eq!(filtered.len(), 2); + assert!(filtered.iter().all(|version| version.installed)); + } + + #[test] + fn query_filters_by_version_id_substring() { + let mut state = state_with_versions(); + state.download_query = "1.20".to_string(); + let filtered = versions(&state); + assert_eq!(filtered.len(), 2); + + state.download_query = "1.21".to_string(); + state.installed_only = true; + // 「仅已装」优先:1.21.9 未安装 → 空列表 + assert!(versions(&state).is_empty()); + } +} diff --git a/crates/app/src/dialogs.rs b/crates/app/src/dialogs.rs new file mode 100644 index 0000000..7097d57 --- /dev/null +++ b/crates/app/src/dialogs.rs @@ -0,0 +1,303 @@ +//! 对话框队列(HMCL 队列语义 + Metro 化,09 §3)。 +//! +//! 语义:**一次只显示一条**。新请求按来源插队(用户操作 > 后台任务 > 提示),同来源 FIFO; +//! 当前条被确认/取消后由 [`DialogQueue::advance`] 推进到下一条,UI 只渲染 `current`。 +//! 非阻塞提示走 [`crate::toast`],不占模态队列。 + +/// 对话框标识。 +pub type DialogId = u64; + +/// 对话框来源:决定插队优先级。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum DialogSource { + /// 提示类(教程、首次使用说明) + Hint = 0, + /// 后台任务(下载完成、需要选择镜像源) + BackgroundTask = 1, + /// 用户操作触发(AI 敏感写操作的二次确认) + UserAction = 2, +} + +impl DialogSource { + /// 数值越大越优先。 + pub fn priority(self) -> u8 { + self as u8 + } +} + +/// 对话框类型(09 §3:确认 / 进度 / 输入 / 错误)。 +#[derive(Debug, Clone, PartialEq)] +pub enum DialogKind { + /// 确认:主按钮 + 次按钮 + Confirm { + /// 主按钮文字(如「允许」) + primary: String, + /// 次按钮文字(如「拒绝」) + secondary: String, + }, + /// 输入:单行文本 + Input { + /// 输入框标签 + label: String, + /// 初始值 + value: String, + /// 主按钮文字 + primary: String, + }, + /// 进度:不可点主按钮(任务结束后由 [`crate::dto::AppEvent::DialogClosed`] 收起) + Progress { + /// 阶段文案 + label: String, + /// 0.0~1.0 + progress: f32, + }, + /// 错误:含诊断入口提示 + Error { + /// 主按钮文字 + primary: String, + /// 诊断/修复提示 + hint: Option, + }, +} + +impl DialogKind { + /// 主按钮文字。 + pub fn primary_label(&self) -> &str { + match self { + Self::Confirm { primary, .. } + | Self::Input { primary, .. } + | Self::Error { primary, .. } => primary, + Self::Progress { .. } => "取消", + } + } + + /// 次按钮文字(None 表示不显示次按钮)。 + pub fn secondary_label(&self) -> Option<&str> { + match self { + Self::Confirm { secondary, .. } => Some(secondary), + Self::Error { .. } => Some("忽略"), + Self::Input { .. } | Self::Progress { .. } => None, + } + } + + /// 是否可交互(进度条不可点主按钮)。 + pub fn is_blocking_progress(&self) -> bool { + matches!(self, Self::Progress { .. }) + } +} + +/// 一条对话框请求。 +#[derive(Debug, Clone, PartialEq)] +pub struct DialogRequest { + /// 队列内唯一 id(由 [`DialogQueue::push_request`] 重新编号) + pub id: DialogId, + /// 来源(决定插队优先级) + pub source: DialogSource, + /// 标题 + pub title: String, + /// 正文 + pub body: String, + /// 类型与按钮 + pub kind: DialogKind, +} + +/// 模态队列:一次一条 + 来源插队。 +#[derive(Debug, Default)] +pub struct DialogQueue { + current: Option, + pending: Vec, + next_id: DialogId, +} + +impl DialogQueue { + /// 新建空队列。 + pub fn new() -> Self { + Self::default() + } + + /// 当前应显示的对话框。 + pub fn current(&self) -> Option<&DialogRequest> { + self.current.as_ref() + } + + /// 排队中(不含当前)条数。 + pub fn pending_len(&self) -> usize { + self.pending.len() + } + + /// 是否完全没有对话框(当前 + 排队)。 + pub fn is_empty(&self) -> bool { + self.current.is_none() && self.pending.is_empty() + } + + /// 入队一条请求(UI 内部调用方用,id 由队列自增分配)。没有当前条时立即成为当前条。 + /// + /// 返回值用于后续 [`Self::close`] / 进度更新。 + pub fn push( + &mut self, + source: DialogSource, + title: impl Into, + body: impl Into, + kind: DialogKind, + ) -> DialogId { + self.next_id += 1; + self.push_request(DialogRequest { + id: self.next_id, + source, + title: title.into(), + body: body.into(), + kind, + }) + } + + /// 入队一条已经组装好的请求(事件通道用)。 + /// + /// **id 归生产者**:这里原样保留 `request.id`(不重编号),因为关闭事件 + /// [`crate::dto::AppEvent::DialogClosed`] 携带的是生产者 id;重编号会让 + /// 「任务完成 → 自动收起进度框」在多来源弹框时静默失配。生产者负责自身 id 唯一。 + pub fn push_request(&mut self, request: DialogRequest) -> DialogId { + let id = request.id; + let source = request.source; + if self.current.is_none() { + self.current = Some(request); + } else { + let at = self + .pending + .iter() + .position(|queued| queued.source < source) + .unwrap_or(self.pending.len()); + self.pending.insert(at, request); + } + id + } + + /// 关闭指定对话框:若是当前条则推进到下一条,否则从队列移除。 + /// + /// 返回 `true` 表示确实移除了。 + pub fn close(&mut self, id: DialogId) -> bool { + if self + .current + .as_ref() + .is_some_and(|current| current.id == id) + { + self.advance(); + return true; + } + if let Some(at) = self.pending.iter().position(|queued| queued.id == id) { + self.pending.remove(at); + return true; + } + false + } + + /// 推进到下一条排队请求(当前条已被处理)。 + pub fn advance(&mut self) { + self.current = if self.pending.is_empty() { + None + } else { + Some(self.pending.remove(0)) + }; + } + + /// 关闭当前条并推进到下一条(确认 / 取消 / 点遮罩都走这里)。 + /// + /// 返回 `false` 表示本来就没有当前条(幂等,双击不会误推进队列)。 + pub fn close_current(&mut self) -> bool { + if self.current.is_none() { + return false; + } + self.advance(); + true + } + + /// 更新进度对话框的进度(任务回调直接喂进来)。 + pub fn update_progress(&mut self, id: DialogId, progress: f32) { + let Some(current) = &mut self.current else { + return; + }; + if current.id != id { + return; + } + if let DialogKind::Progress { + progress: value, .. + } = &mut current.kind + { + *value = progress.clamp(0.0, 1.0); + } + } +} + +#[cfg(test)] +mod tests { + use super::{DialogKind, DialogQueue, DialogSource}; + + fn confirm() -> DialogKind { + DialogKind::Confirm { + primary: "确定".into(), + secondary: "取消".into(), + } + } + + #[test] + fn shows_one_dialog_at_a_time() { + let mut queue = DialogQueue::new(); + let first = queue.push(DialogSource::BackgroundTask, "A", "", confirm()); + let second = queue.push(DialogSource::BackgroundTask, "B", "", confirm()); + + assert_eq!(queue.current().map(|d| d.id), Some(first)); + assert_eq!(queue.pending_len(), 1); + + queue.advance(); + assert_eq!(queue.current().map(|d| d.id), Some(second)); + assert_eq!(queue.pending_len(), 0); + + queue.advance(); + assert!(queue.is_empty()); + } + + #[test] + fn user_action_jumps_the_queue() { + let mut queue = DialogQueue::new(); + queue.push(DialogSource::Hint, "提示", "", confirm()); + queue.push(DialogSource::Hint, "提示2", "", confirm()); + queue.push(DialogSource::BackgroundTask, "后台", "", confirm()); + let urgent = queue.push(DialogSource::UserAction, "确认删除", "", confirm()); + + // 当前仍是队首(一次一条),但排队里用户操作插到最前 → 关掉当前条后立刻轮到它 + queue.advance(); + assert_eq!(queue.current().map(|d| d.id), Some(urgent)); + } + + #[test] + fn closing_non_current_dialog_removes_it_from_pending() { + let mut queue = DialogQueue::new(); + let first = queue.push(DialogSource::Hint, "A", "", confirm()); + let second = queue.push(DialogSource::Hint, "B", "", confirm()); + + assert!(queue.close(second)); + assert_eq!(queue.pending_len(), 0); + assert_eq!(queue.current().map(|d| d.id), Some(first)); + assert!(!queue.close(999)); + } + + #[test] + fn progress_dialog_can_be_updated() { + let mut queue = DialogQueue::new(); + let id = queue.push( + DialogSource::BackgroundTask, + "下载中", + "", + DialogKind::Progress { + label: "资源".into(), + progress: 0.0, + }, + ); + queue.update_progress(id, 1.7); + match queue.current().map(|d| &d.kind) { + Some(DialogKind::Progress { progress, .. }) => { + assert!((*progress - 1.0).abs() < f32::EPSILON); + } + other => panic!("unexpected dialog kind: {other:?}"), + } + } +} diff --git a/crates/app/src/dto/agent.rs b/crates/app/src/dto/agent.rs new file mode 100644 index 0000000..1ecfd29 --- /dev/null +++ b/crates/app/src/dto/agent.rs @@ -0,0 +1,95 @@ +//! AI 会话 DTO:消息、工具调用可视化与 agent 事件。 + +/// 会话消息角色。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatRole { + /// 用户 + User, + /// 助手(可流式) + Assistant, + /// 工具调用(会话里可见,D4 要求) + Tool, + /// 系统提示(弱化展示) + System, +} + +/// 工具调用可视化条目。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolCallView { + /// 工具名(与 ToolSpec 注册表同名) + pub name: String, + /// 是否成功 + pub ok: bool, + /// 一行结果摘要 + pub summary: String, +} + +/// 一条会话消息。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatMessage { + /// 角色 + pub role: ChatRole, + /// 正文(流式时持续追加) + pub text: String, + /// role == Tool 时带上工具调用详情 + pub tool: Option, + /// 流式未收敛 + pub streaming: bool, +} + +impl ChatMessage { + /// 用户消息。 + pub fn user(text: impl Into) -> Self { + Self { + role: ChatRole::User, + text: text.into(), + tool: None, + streaming: false, + } + } + + /// 助手流式消息起点(空文本 + streaming)。 + pub fn assistant_stream() -> Self { + Self { + role: ChatRole::Assistant, + text: String::new(), + tool: None, + streaming: true, + } + } +} + +/// agent 侧事件。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentEvent { + /// 追加一条完整消息 + MessageAppended(ChatMessage), + /// 给指定序号的流式消息追加增量 + MessageDelta { + /// 目标消息在会话里的序号 + index: usize, + /// 增量文本 + delta: String, + }, + /// 流式收敛 + MessageFinished { + /// 目标消息序号 + index: usize, + }, + /// 开始一次工具调用(会话里插入工具条目) + ToolCallStarted { + /// 工具名 + name: String, + /// 调用参数摘要 + summary: String, + }, + /// 工具调用结束 + ToolCallFinished { + /// 工具名 + name: String, + /// 是否成功 + ok: bool, + /// 结果摘要 + summary: String, + }, +} diff --git a/crates/app/src/dto/events.rs b/crates/app/src/dto/events.rs new file mode 100644 index 0000000..2de323e --- /dev/null +++ b/crates/app/src/dto/events.rs @@ -0,0 +1,86 @@ +//! 事件与指令:core/agent → app 的事件队列,app → core/agent 的用户意图。 + +use crate::dialogs::{DialogId, DialogRequest}; +use crate::dto::{AgentEvent, DiagnosisReport, InstanceId, InstanceRuntime, InstanceSummary}; +use crate::dto::{LogLine, LogSession, SessionId, VersionCategory, VersionSummary}; +use crate::toast::Toast; + +/// core 侧事件(不可变快照发布 + 事件流,01c §5 的三条通道之二)。 +#[derive(Debug, Clone, PartialEq)] +pub enum CoreEvent { + /// 实例列表整体刷新 + InstancesChanged(Vec), + /// 单个实例运行时状态变化(启动进度/运行中/失败) + InstanceRuntimeChanged { + /// 实例 id + id: InstanceId, + /// 新状态 + runtime: InstanceRuntime, + }, + /// 下载页版本列表刷新 + VersionListChanged(Vec), + /// 日志会话列表刷新 + LogSessionsChanged(Vec), + /// 当前会话日志行刷新(P0 整批替换;P1 改增量追加) + LogLinesChanged(Vec), + /// 诊断结论产出 + DiagnosisReady(DiagnosisReport), +} + +/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路, +/// 这样 [`crate::state::AppState::apply`] 是唯一的状态变更入口。 +#[derive(Debug, Clone, PartialEq)] +pub enum AppEvent { + /// 来自 core + Core(CoreEvent), + /// 来自 agent + Agent(AgentEvent), + /// core/agent 请求弹对话框(会进队列,一次一条) + DialogRequested(DialogRequest), + /// 关闭指定对话框(任务完成收起进度条) + DialogClosed(DialogId), + /// 右下角 Toast + Toast(Toast), +} + +/// app → core/agent 的用户意图。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppCommand { + /// 重新扫描实例目录 + RefreshInstances, + /// 启动实例 + LaunchInstance(InstanceId), + /// 停止实例(含取消启动中的管线) + StopInstance(InstanceId), + /// 拉取某分类的版本列表 + ListVersions(VersionCategory), + /// 安装指定版本 + InstallVersion(String), + /// 刷新日志会话列表 + RefreshLogSessions, + /// 打开某个会话(拉取日志行 + 触发诊断) + OpenLogSession(SessionId), + /// 向 AI 提问(会话状态在 app 侧,agent 只回事件) + AskAgent(String), + /// 中断当前 AI 流 + StopAgent, + /// YOLO 开关(跳过确认,审计照记) + SetYolo(bool), +} + +/// 后端通道:core/agent host 实现它;P0 用 [`crate::mock::MockBackend`] 验证通道语义。 +/// +/// 轮询模型(而非回调)是为了让 Slint 的定时器成为唯一调度者, +/// 避免跨线程回调里碰 Slint 模型。 +pub trait Backend { + /// 取走自上次调用以来积压的事件(FIFO,不可丢序号)。 + fn drain_events(&mut self) -> Vec; + + /// 下发一条用户意图。 + fn dispatch(&mut self, command: AppCommand); + + /// 时间推进(mock 用脚本推进进度;真实后端可以是空实现)。 + fn tick(&mut self, elapsed: std::time::Duration) { + let _ = elapsed; + } +} diff --git a/crates/app/src/dto/instances.rs b/crates/app/src/dto/instances.rs new file mode 100644 index 0000000..41e0ec8 --- /dev/null +++ b/crates/app/src/dto/instances.rs @@ -0,0 +1,151 @@ +//! 实例相关 DTO:磁贴与列表视图的唯一数据源。 + +use std::path::PathBuf; + +/// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。 +pub type InstanceId = String; + +/// 加载器种类(磁贴徽标 + 详情页版本链)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoaderKind { + /// 原版(无加载器) + Vanilla, + /// Fabric + Fabric, + /// Quilt + Quilt, + /// Forge + Forge, + /// NeoForge + NeoForge, + /// OptiFine + OptiFine, + /// LiteLoader + LiteLoader, +} + +impl LoaderKind { + /// 徽标显示文字。 + pub fn label(self) -> &'static str { + match self { + Self::Vanilla => "原版", + Self::Fabric => "Fabric", + Self::Quilt => "Quilt", + Self::Forge => "Forge", + Self::NeoForge => "NeoForge", + Self::OptiFine => "OptiFine", + Self::LiteLoader => "LiteLoader", + } + } +} + +/// 加载器 + 版本(例如 Fabric 0.16.5)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoaderInfo { + /// 加载器种类 + pub kind: LoaderKind, + /// 加载器版本号;原版为空串 + pub version: String, +} + +impl LoaderInfo { + /// 徽标文字:原版只写「原版」,装加载器时写「Fabric 0.16.5」。 + pub fn badge(&self) -> String { + if self.kind == LoaderKind::Vanilla || self.version.is_empty() { + self.kind.label().to_string() + } else { + format!("{} {}", self.kind.label(), self.version) + } + } +} + +/// 字节进度;`total == 0` 表示总量未知(UI 转不确定态)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Progress { + /// 已完成量 + pub done: u64, + /// 总量;0 = 未知 + pub total: u64, +} + +impl Progress { + /// 0.0~1.0;未知总量返回 0.0。 + pub fn ratio(self) -> f32 { + if self.total == 0 { + 0.0 + } else { + (self.done as f32 / self.total as f32).clamp(0.0, 1.0) + } + } + + /// 总量是否已知。 + pub fn is_known(self) -> bool { + self.total > 0 + } +} + +/// 启动管线阶段(磁贴进度环的文案来源;D8 的 10 阶段在 P1 细化)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LaunchStage { + /// 下载资源 + Download, + /// 校验文件 + Verify, + /// 组装命令行并拉起进程 + Launch, +} + +impl LaunchStage { + /// 磁贴上的阶段文案。 + pub fn label(self) -> &'static str { + match self { + Self::Download => "下载资源", + Self::Verify => "校验文件", + Self::Launch => "启动中", + } + } +} + +/// 实例运行时状态(磁贴的四种外观)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum InstanceRuntime { + /// 空闲 + #[default] + Idle, + /// 启动管线进行中 + Busy { + /// 当前阶段 + stage: LaunchStage, + /// 该阶段进度 + progress: Progress, + }, + /// 游戏进程存活 + Running { + /// 进程号(停止/诊断用) + pid: u32, + }, + /// 上次启动失败(磁贴显示「重试」) + Failed { + /// 失败原因(用户可见文案) + reason: String, + }, +} + +/// 实例摘要:磁贴/列表视图唯一数据源。 +#[derive(Debug, Clone, PartialEq)] +pub struct InstanceSummary { + /// 稳定 id + pub id: InstanceId, + /// 显示名 + pub name: String, + /// 游戏版本(如 1.20.4) + pub mc_version: String, + /// 加载器信息 + pub loader: LoaderInfo, + /// ISO-8601 UTC 最后游玩时间;None = 从未启动 + pub last_played: Option, + /// 运行时状态 + pub runtime: InstanceRuntime, + /// 实例图标路径(P0 直接给路径;P1 换成 core 提供的图像句柄) + pub icon: Option, +} diff --git a/crates/app/src/dto/logs.rs b/crates/app/src/dto/logs.rs new file mode 100644 index 0000000..f73a75f --- /dev/null +++ b/crates/app/src/dto/logs.rs @@ -0,0 +1,76 @@ +//! 日志页 DTO:会话列表、日志行与 D8 诊断结论。 + +/// 一次启动会话(日志页左栏单位)标识。 +pub type SessionId = String; + +/// 一次运行的结局(G3 左栏图标:✓ 正常 / ✗ 崩溃 / ⚠ 静默失败)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogOutcome { + /// 正常退出 + Ok, + /// 崩溃(非零退出码或 hs_err_pid) + Crashed, + /// exit0 但游戏没起来(D8 新增检测) + SilentFail, +} + +/// 会话摘要。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogSession { + /// 会话 id + pub id: SessionId, + /// 实例名(会话属于哪个实例) + pub instance: String, + /// 展示用时间(`10-29 19:22`) + pub when: String, + /// 结局 + pub outcome: LogOutcome, + /// 时长(秒) + pub duration_secs: u64, +} + +/// 日志行等级(错误行高亮)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogLevel { + /// debug + Debug, + /// info + Info, + /// warn + Warn, + /// error + Error, +} + +/// 一行日志。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogLine { + /// 原始文本(含时间戳前缀) + pub text: String, + /// 等级 + pub level: LogLevel, +} + +/// D8 规则引擎结论(日志页崩溃诊断卡片)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Diagnosis { + /// 命中规则 id(`gpu.driver_old`) + pub rule: String, + /// 结论标题 + pub title: String, + /// 0~100 置信度 + pub confidence: u8, + /// 建议文案 + pub hint: String, + /// 可一键执行的修复动作(无则只给建议) + pub fix: Option, +} + +/// 诊断结论 + 归属会话(UI 用它决定卡片显隐)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiagnosisReport { + /// 归属会话 + pub session: SessionId, + /// 诊断内容 + pub diagnosis: Diagnosis, +} diff --git a/crates/app/src/dto/mod.rs b/crates/app/src/dto/mod.rs new file mode 100644 index 0000000..28c9c4a --- /dev/null +++ b/crates/app/src/dto/mod.rs @@ -0,0 +1,23 @@ +//! app ↔ core/agent 的 IPC 边界(01c §5 纪律)。 +//! +//! 这里只允许出现「扁平 DTO / 事件 / 指令」:`crates/app` 任何模块都不得 `use unlucky_core::*` +//! 或 `use unlucky_agent::*` 的内部类型。core/agent 侧的 host 层负责把自身类型翻译成本模块的类型, +//! 反向的 [`AppCommand`] 也一样。P0 阶段由 [`crate::mock`] 的假后端实现 [`Backend`] 驱动骨架。 +//! +//! 结构:**按领域拆子模块**(instances / versions / logs / agent / events), +//! 全量 `pub use` 出去,调用方依旧写 `crate::dto::InstanceSummary` 这类路径。 +//! 契约说明(字段口径、事件顺序、错误语义)见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`。 + +mod agent; +mod events; +mod instances; +mod logs; +mod versions; + +pub use agent::{AgentEvent, ChatMessage, ChatRole, ToolCallView}; +pub use events::{AppCommand, AppEvent, Backend, CoreEvent}; +pub use instances::{ + InstanceId, InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo, LoaderKind, Progress, +}; +pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId}; +pub use versions::{AncientKind, VersionCategory, VersionKind, VersionSummary}; diff --git a/crates/app/src/dto/versions.rs b/crates/app/src/dto/versions.rs new file mode 100644 index 0000000..eb2218f --- /dev/null +++ b/crates/app/src/dto/versions.rs @@ -0,0 +1,60 @@ +//! 下载页 DTO:版本列表与分类(含远古版子类,D16/Q24-4)。 + +/// 版本类型(远古版按 unlisted-versions 的分类拆开)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VersionKind { + /// 正式版 + Release, + /// 预览版(snapshot) + Snapshot, + /// 远古版(带子类) + Ancient(AncientKind), +} + +/// 远古版子类。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AncientKind { + /// classic(rd-*) + Classic, + /// old_alpha(a1.*) + OldAlpha, + /// old_beta(b1.*) + OldBeta, + /// 未列入官方清单的远古版汇总 + Unlisted, +} + +impl VersionKind { + /// 是否属于「远古版」——列表行要加警示徽标。 + pub fn is_ancient(self) -> bool { + matches!(self, Self::Ancient(_)) + } +} + +/// 下载页版本行。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionSummary { + /// 版本 id(如 `1.20.4` / `rd-132211`) + pub id: String, + /// 版本类型 + pub kind: VersionKind, + /// 发布日期(ISO-8601 日期部分) + pub released: String, + /// 本地是否已安装(行尾显示「已装」) + pub installed: bool, +} + +/// 下载页左侧分类(页内常驻侧栏的选中项)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VersionCategory { + /// 正式版 + Release, + /// 预览版 + Snapshot, + /// 远古版某子类 + Ancient(AncientKind), + /// 整合包(P1) + Modpack, + /// Mod(P1) + Mod, +} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs new file mode 100644 index 0000000..bea5596 --- /dev/null +++ b/crates/app/src/main.rs @@ -0,0 +1,94 @@ +//! Unlucky-Launcher 桌面壳(`unlucky_app`):Slint UI + Metro 视觉。 +//! +//! 边界(决策 D3 / D16):本 crate 是**唯一**允许依赖 UI 框架的 crate; +//! 它只经「快照 + 事件流 + 注入的显示服务」消费 core/agent,不直接触碰其内部类型 +//! (P0 的 host 是 `mock` 假后端,`crate::dto` 是唯一跨界类型集合)。 +//! +//! 渲染策略(`DevDocs/PlanDoc/01c-native-gui-options.md` §6): +//! GL 优先(femtovg),启动参数 `--software-renderer` 或 Win7 老机探测失败时切 CPU 软渲染。 +//! +//! 主题与外观(D17 / 09 §101-113): +//! - 主题三态 `appearance.theme_mode ∈ {auto, light, dark}`(出厂 auto); +//! - 「外观(高级)」四件(背景图 / 面板半透明度 / 玻璃拟态 / 强调色)出厂全关; +//! - 全部走 `core::setting` 的 schema(`crate::settings_bridge`),AI 可写并且**热切** +//! (tick 里 1s 一次 mtime 比对);系统主题由 `core::platform::theme_probe` 对齐 + 兜底轮询。 +//! +//! 架构分层(本文件只有一个职责:把窗口拉起来): +//! - `state.rs` / `prefs.rs` / `router.rs` / `dialogs.rs` / `toast.rs`:纯 Rust 状态(可单测,无 Slint) +//! - `dto/`:跨边界 DTO 与事件/指令 +//! - `conv.rs`:状态 → Slint 视图模型 +//! - `ui_bind.rs` + `callbacks.rs`:窗口/状态/后端缝合 + 回调接线 +//! - `settings_bridge.rs`:`config/launcher.json` 的读写与热切(core::setting 的 schema API) +//! - `bg_image.rs` + `png.rs`:背景图/模糊层加工与截图编码(09 §101/§103 的预生成手法) +//! - `mock.rs` + `mock_data.rs`:P0 假后端(脚本化事件) +//! +//! 关于 unsafe:本 crate 不写 unsafe,workspace lint 已设 `unsafe_code = "deny"`。 +//! 这里不能升级成 `#![forbid(unsafe_code)]`——Slint 生成的代码自带 `#[allow(unsafe_code)]`, +//! 而 forbid 无法被内层 allow 覆盖(会直接编译失败)。同理 Slint 生成类型不带文档注释, +//! 只能在 crate 级放开 `missing_docs`(库 crate 仍由 workspace lint 保持 warn)。 +// 关于 dead_code:P0 骨架里有一批「已定义、等接线」的公共 API——事件通道 / 偏好键登记 / +// 诊断文案 / mock 数据构造器等,它们被单测覆盖但业务侧要 P1 才用到。逐条 `#[allow]` +// 会把噪音藏进实现细节,这里统一放开并在 P1 接线时逐个转正(届时删掉本行即可暴露真死码)。 +#![allow(missing_docs, dead_code)] + +mod appearance_bind; +mod bg_image; +mod callbacks; +mod conv; +mod dialogs; +mod dto; +mod mock; +mod mock_data; +mod png; +mod prefs; +mod router; +mod settings_bridge; +mod state; +mod theme_runtime; +mod toast; +mod ui_bind; + +slint::include_modules!(); + +fn main() -> Result<(), slint::PlatformError> { + apply_renderer_flag(); + + let app = ui_bind::AppUi::new(MainWindow::new()?); + + // 截图验收通道(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)?; + return Ok(()); + } + + app.start_timer(); + app.run() +} + +/// Win7 老机 / 无可用 OpenGL 驱动时的软渲染兜底开关(01c §6 的 Slint 核心卖点)。 +fn apply_renderer_flag() { + let software = std::env::args() + .skip(1) + .any(|arg| arg == "--software-renderer"); + if software { + std::env::set_var("SLINT_BACKEND", "winit-software"); + } +} + +/// `--screenshot ` / `--screenshot=`:渲染一帧并写 PNG 后退出。 +fn screenshot_path() -> Option { + 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 +} diff --git a/crates/app/src/mock.rs b/crates/app/src/mock.rs new file mode 100644 index 0000000..1d66b10 --- /dev/null +++ b/crates/app/src/mock.rs @@ -0,0 +1,366 @@ +//! P0 假后端:用脚本化事件驱动骨架,验证 app ↔ core/agent 通道语义。 +//! +//! 覆盖四条通道:实例运行状态(磁贴进度环)、AI 流式会话(含工具调用)、 +//! 对话框队列(进度对话框)、Toast。阶段 B 里由真正的 core/agent host 替换 +//! [`MockBackend`],[`crate::dto::Backend`] 之上的 state/reducer 不动。 +//! 假数据本体在 [`crate::mock_data`]。 + +use std::collections::VecDeque; +use std::time::Duration; + +use crate::dialogs::{DialogKind, DialogRequest, DialogSource}; +use crate::dto::{ + AgentEvent, AppCommand, AppEvent, Backend, ChatMessage, ChatRole, CoreEvent, InstanceId, + InstanceRuntime, LaunchStage, Progress, +}; +use crate::mock_data::{mock_instances, mock_log_lines, mock_log_sessions, mock_versions}; +use crate::toast::{Toast, ToastKind}; + +/// 启动脚本:按时间推进 download → verify → launch → running。 +#[derive(Debug)] +struct LaunchScript { + id: InstanceId, + elapsed_ms: u64, +} + +/// AI 流式脚本:每条提问回一段可切分的话 + 一次工具调用。 +#[derive(Debug)] +struct ChatScript { + elapsed_ms: u64, + chunks: VecDeque, + /// 流式助手消息在 app 会话里的序号 + message_index: usize, +} + +/// 假后端。 +#[derive(Debug, Default)] +pub struct MockBackend { + events: VecDeque, + launch: Option, + chat: Option, + /// 已投递给 app 的会话消息条数(用来给 MessageDelta 算序号) + emitted_messages: usize, + next_dialog: u64, +} + +impl MockBackend { + /// 预置一批假数据事件(实例/版本/日志会话)。 + pub fn new() -> Self { + let mut backend = Self::default(); + backend.seed(); + backend + } + + fn seed(&mut self) { + self.events.push_back(AppEvent::Core( + CoreEvent::InstancesChanged(mock_instances()), + )); + self.events + .push_back(AppEvent::Core(CoreEvent::VersionListChanged( + mock_versions(), + ))); + self.events + .push_back(AppEvent::Core(CoreEvent::LogSessionsChanged( + mock_log_sessions(), + ))); + self.events + .push_back(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines()))); + } + + fn emit(&mut self, event: AppEvent) { + self.events.push_back(event); + } + + fn next_dialog_id(&mut self) -> u64 { + self.next_dialog += 1; + self.next_dialog + } + + fn script_launch(&mut self, id: InstanceId) { + self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged { + id: id.clone(), + runtime: InstanceRuntime::Busy { + stage: LaunchStage::Download, + progress: Progress { + done: 0, + total: 1_500, + }, + }, + })); + self.launch = Some(LaunchScript { id, elapsed_ms: 0 }); + } + + fn step_launch(&mut self, elapsed: Duration) { + let Some((id, elapsed_ms)) = self.launch.as_mut().map(|script| { + script.elapsed_ms += u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX); + (script.id.clone(), script.elapsed_ms) + }) else { + return; + }; + + // 0~1.5s 下载、1.5~2.5s 校验、2.5~3s 启动、3s 后转为运行中 + let runtime = if elapsed_ms < 1_500 { + InstanceRuntime::Busy { + stage: LaunchStage::Download, + progress: Progress { + done: elapsed_ms, + total: 1_500, + }, + } + } else if elapsed_ms < 2_500 { + InstanceRuntime::Busy { + stage: LaunchStage::Verify, + progress: Progress { + done: elapsed_ms - 1_500, + total: 1_000, + }, + } + } else if elapsed_ms < 3_000 { + InstanceRuntime::Busy { + stage: LaunchStage::Launch, + progress: Progress { + done: elapsed_ms - 2_500, + total: 500, + }, + } + } else { + self.launch = None; + self.emit(AppEvent::Toast(Toast { + id: 0, + kind: ToastKind::Success, + text: "游戏已启动".to_string(), + remaining_ms: 0, + })); + InstanceRuntime::Running { pid: 12_345 } + }; + + self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged { + id, + runtime, + })); + } + + fn script_chat(&mut self, text: String) { + let user_index = self.emitted_messages; + self.emit(AppEvent::Agent(AgentEvent::MessageAppended( + ChatMessage::user(text), + ))); + self.emit(AppEvent::Agent(AgentEvent::MessageAppended( + ChatMessage::assistant_stream(), + ))); + self.emitted_messages += 2; + self.chat = Some(ChatScript { + elapsed_ms: 0, + chunks: VecDeque::from(vec![ + "先确认版本:".to_string(), + "官方 1.20.4 是正式版,".to_string(), + "装完再用 Fabric 加加载器。".to_string(), + ]), + message_index: user_index + 1, + }); + } + + fn step_chat(&mut self, elapsed: Duration) { + let Some(chat) = self.chat.as_mut() else { + return; + }; + chat.elapsed_ms += u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX); + if chat.elapsed_ms < 300 { + return; + } + chat.elapsed_ms = 0; + let index = chat.message_index; + let chunk = chat.chunks.pop_front(); + + if let Some(delta) = chunk { + self.emit(AppEvent::Agent(AgentEvent::MessageDelta { index, delta })); + return; + } + + // 流结束后补一次工具调用(工具执行在会话里可见)+ 收尾消息 + self.emit(AppEvent::Agent(AgentEvent::ToolCallStarted { + name: "read_game_log".to_string(), + summary: "读取最近一次崩溃日志".to_string(), + })); + self.emit(AppEvent::Agent(AgentEvent::ToolCallFinished { + name: "read_game_log".to_string(), + ok: true, + summary: "读到 128 行,无 ERROR".to_string(), + })); + self.emit(AppEvent::Agent(AgentEvent::MessageFinished { index })); + self.emit(AppEvent::Agent(AgentEvent::MessageAppended(ChatMessage { + role: ChatRole::Assistant, + text: "日志没有 ERROR,问题更可能在 Java 版本。".to_string(), + tool: None, + streaming: false, + }))); + self.emitted_messages += 2; + self.chat = None; + } + + fn script_install(&mut self, version: String) { + let id = self.next_dialog_id(); + self.emit(AppEvent::DialogRequested(DialogRequest { + id, + source: DialogSource::BackgroundTask, + title: format!("正在安装 {version}"), + body: String::new(), + kind: DialogKind::Progress { + label: "下载资源".to_string(), + progress: 0.0, + }, + })); + self.emit(AppEvent::DialogClosed(id)); + self.emit(AppEvent::Toast(Toast { + id: 0, + kind: ToastKind::Success, + text: format!("{version} 安装完成"), + remaining_ms: 0, + })); + } +} + +impl Backend for MockBackend { + fn drain_events(&mut self) -> Vec { + self.events.drain(..).collect() + } + + fn dispatch(&mut self, command: AppCommand) { + match command { + AppCommand::LaunchInstance(id) => self.script_launch(id), + AppCommand::StopInstance(id) => { + self.launch = None; + self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged { + id, + runtime: InstanceRuntime::Idle, + })); + } + AppCommand::AskAgent(text) => self.script_chat(text), + AppCommand::InstallVersion(version) => self.script_install(version), + AppCommand::SetYolo(enabled) => self.emit(AppEvent::Toast(Toast { + id: 0, + kind: ToastKind::Info, + text: if enabled { + "YOLO 已开启(审计照记)" + } else { + "YOLO 已关闭" + } + .to_string(), + remaining_ms: 0, + })), + AppCommand::ListVersions(category) => { + self.emit(AppEvent::Core(CoreEvent::VersionListChanged( + crate::mock_data::mock_versions_for(category), + ))); + } + AppCommand::OpenLogSession(id) => { + // P0:换会话 = 换一批假日志行 + 一条诊断结论(日志页诊断卡片可见) + self.emit(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines()))); + self.emit(AppEvent::Core(CoreEvent::DiagnosisReady( + crate::mock_data::mock_diagnosis(id), + ))); + } + AppCommand::RefreshInstances + | AppCommand::RefreshLogSessions + | AppCommand::StopAgent => {} + } + } + + fn tick(&mut self, elapsed: Duration) { + self.step_launch(elapsed); + self.step_chat(elapsed); + } +} + +#[cfg(test)] +mod tests { + use super::MockBackend; + use crate::dto::{AgentEvent, AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime}; + + #[test] + fn seeds_instances_and_versions() { + let mut backend = MockBackend::new(); + let events = backend.drain_events(); + assert_eq!(events.len(), 4); + assert!(events + .iter() + .all(|event| matches!(event, AppEvent::Core(_)))); + assert!(backend.drain_events().is_empty()); + } + + #[test] + fn launching_emits_progress_then_running() { + let mut backend = MockBackend::new(); + backend.drain_events(); + backend.dispatch(AppCommand::LaunchInstance("survival".to_string())); + + let first = backend.drain_events(); + assert!(matches!( + first[0], + AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. }) + )); + + backend.tick(std::time::Duration::from_millis(1_600)); + let verify = backend.drain_events(); + match &verify[0] { + AppEvent::Core(CoreEvent::InstanceRuntimeChanged { + runtime: InstanceRuntime::Busy { stage, .. }, + .. + }) => assert_eq!(*stage, crate::dto::LaunchStage::Verify), + other => panic!("unexpected event: {other:?}"), + } + + backend.tick(std::time::Duration::from_millis(2_000)); + let done = backend.drain_events(); + assert!(done.iter().any(|event| matches!( + event, + AppEvent::Core(CoreEvent::InstanceRuntimeChanged { + runtime: InstanceRuntime::Running { .. }, + .. + }) + ))); + } + + #[test] + fn stopping_cancels_the_launch_script() { + let mut backend = MockBackend::new(); + backend.drain_events(); + backend.dispatch(AppCommand::LaunchInstance("a".to_string())); + backend.drain_events(); + + backend.dispatch(AppCommand::StopInstance("a".to_string())); + let events = backend.drain_events(); + assert!(matches!( + events[0], + AppEvent::Core(CoreEvent::InstanceRuntimeChanged { + runtime: InstanceRuntime::Idle, + .. + }) + )); + + // 脚本已取消:再推进时间也不会再产出事件 + backend.tick(std::time::Duration::from_millis(5_000)); + assert!(backend.drain_events().is_empty()); + } + + #[test] + fn asking_the_agent_streams_and_finishes() { + let mut backend = MockBackend::new(); + backend.drain_events(); + backend.dispatch(AppCommand::AskAgent("装哪个版本".to_string())); + + let start = backend.drain_events(); + assert_eq!(start.len(), 2); + + let mut deltas = 0; + for _ in 0..6 { + backend.tick(std::time::Duration::from_millis(400)); + let events = backend.drain_events(); + deltas += events + .iter() + .filter(|event| matches!(event, AppEvent::Agent(AgentEvent::MessageDelta { .. }))) + .count(); + } + assert_eq!(deltas, 3); + } +} diff --git a/crates/app/src/mock_data.rs b/crates/app/src/mock_data.rs new file mode 100644 index 0000000..9257f0a --- /dev/null +++ b/crates/app/src/mock_data.rs @@ -0,0 +1,226 @@ +//! P0 假数据:实例 / 版本 / 日志 / 诊断 / 工具调用样例。 +//! +//! 只被 [`crate::mock`] 与阶段 B 的 main.rs 使用;真实后端接入后整文件可删。 + +use crate::dto::{ + AncientKind, ChatMessage, ChatRole, Diagnosis, DiagnosisReport, InstanceRuntime, + InstanceSummary, LoaderInfo, LoaderKind, LogLevel, LogLine, LogOutcome, LogSession, SessionId, + ToolCallView, VersionCategory, VersionKind, VersionSummary, +}; + +/// P0 演示实例:覆盖空闲 / 失败两种磁贴状态(运行中由启动脚本产生)。 +pub fn mock_instances() -> Vec { + vec![ + InstanceSummary { + id: "survival".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-29T19:22:00Z".to_string()), + runtime: InstanceRuntime::Idle, + icon: None, + }, + InstanceSummary { + id: "vanilla-latest".to_string(), + name: "原版最新".to_string(), + mc_version: "1.21.9".to_string(), + loader: LoaderInfo { + kind: LoaderKind::Vanilla, + version: String::new(), + }, + last_played: Some("2026-10-28T21:03:00Z".to_string()), + runtime: InstanceRuntime::Idle, + icon: None, + }, + InstanceSummary { + id: "ancient-1.7.10".to_string(), + name: "远古 1.7.10".to_string(), + mc_version: "1.7.10".to_string(), + loader: LoaderInfo { + kind: LoaderKind::Forge, + version: "10.13.4.1614".to_string(), + }, + last_played: None, + runtime: InstanceRuntime::Idle, + icon: None, + }, + InstanceSummary { + id: "modded-dev".to_string(), + name: "开发测试".to_string(), + mc_version: "1.21.1".to_string(), + loader: LoaderInfo { + kind: LoaderKind::NeoForge, + version: "21.1.72".to_string(), + }, + last_played: Some("2026-10-27T12:40:00Z".to_string()), + runtime: InstanceRuntime::Failed { + reason: "exit0 静默失败".to_string(), + }, + icon: None, + }, + ] +} + +/// P0 演示版本列表(含远古版三子类,验证分类侧栏与警示徽标)。 +pub fn mock_versions() -> Vec { + fn row(id: &str, kind: VersionKind, released: &str, installed: bool) -> VersionSummary { + VersionSummary { + id: id.to_string(), + kind, + released: released.to_string(), + installed, + } + } + vec![ + row("1.21.9", VersionKind::Release, "2026-09-20", false), + row("1.21.8", VersionKind::Release, "2026-07-11", true), + row("1.20.4", VersionKind::Release, "2023-12-07", true), + row("1.21.9-pre3", VersionKind::Snapshot, "2026-09-01", false), + row( + "rd-132211", + VersionKind::Ancient(AncientKind::Classic), + "2009-05-13", + false, + ), + row( + "a1.2.6", + VersionKind::Ancient(AncientKind::OldAlpha), + "2010-08-11", + false, + ), + row( + "b1.7.3", + VersionKind::Ancient(AncientKind::OldBeta), + "2011-07-08", + false, + ), + ] +} + +/// 日志会话样例(✓ / ✗ / ⚠ 三种结局各一条)。 +pub fn mock_log_sessions() -> Vec { + vec![ + LogSession { + id: "s-1029-1922".to_string(), + instance: "生存整合包".to_string(), + when: "10-29 19:22".to_string(), + outcome: LogOutcome::Ok, + duration_secs: 3_780, + }, + LogSession { + id: "s-1029-1840".to_string(), + instance: "开发测试".to_string(), + when: "10-29 18:40".to_string(), + outcome: LogOutcome::Crashed, + duration_secs: 62, + }, + LogSession { + id: "s-1029-1755".to_string(), + instance: "原版最新".to_string(), + when: "10-29 17:55".to_string(), + outcome: LogOutcome::SilentFail, + duration_secs: 8, + }, + ] +} + +/// 日志行样例(含一条 ERROR 供高亮验证)。 +pub fn mock_log_lines() -> Vec { + fn line(text: &str, level: LogLevel) -> LogLine { + LogLine { + text: text.to_string(), + level, + } + } + vec![ + line( + "[19:22:01] [main/INFO] Launching Minecraft 1.20.4", + LogLevel::Info, + ), + line("[19:22:02] [main/INFO] Loading 42 mods", LogLevel::Info), + line( + "[19:22:03] [Render thread/WARN] GL vendor: unknown", + LogLevel::Warn, + ), + line( + "[19:22:05] [Render thread/ERROR] OpenGL 2.1 not supported", + LogLevel::Error, + ), + line("[19:22:05] [main/INFO] Stopping!", LogLevel::Info), + ] +} + +/// 崩溃诊断样例(验证日志页诊断卡片 + 「让 AI 详解」入口)。 +pub fn mock_diagnosis(session: SessionId) -> DiagnosisReport { + DiagnosisReport { + session, + diagnosis: Diagnosis { + rule: "gpu.driver_old".to_string(), + title: "显卡驱动过旧(OpenGL 版本不足)".to_string(), + confidence: 92, + hint: "升级显卡驱动,或在启动参数里改用软件渲染".to_string(), + fix: Some("switch_software_renderer".to_string()), + }, + } +} + +/// 工具调用条目样例(AI 面板静态填充用)。 +pub fn mock_tool_message() -> ChatMessage { + ChatMessage { + role: ChatRole::Tool, + text: "读到 128 行,无 ERROR".to_string(), + tool: Some(ToolCallView { + name: "read_game_log".to_string(), + ok: true, + summary: "读到 128 行,无 ERROR".to_string(), + }), + streaming: false, + } +} + +/// 分类 → 该分类下的假版本(下载页切换分类时用)。 +pub fn mock_versions_for(category: VersionCategory) -> Vec { + mock_versions() + .into_iter() + .filter(|version| match category { + VersionCategory::Release => version.kind == VersionKind::Release, + VersionCategory::Snapshot => version.kind == VersionKind::Snapshot, + VersionCategory::Ancient(kind) => version.kind == VersionKind::Ancient(kind), + VersionCategory::Modpack | VersionCategory::Mod => false, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use crate::dto::{AncientKind, VersionCategory, VersionKind}; + use crate::mock_data::{mock_instances, mock_versions_for}; + + #[test] + fn mock_instances_cover_the_tile_states() { + let instances = mock_instances(); + assert_eq!(instances.len(), 4); + assert!(instances.iter().any(|instance| matches!( + instance.runtime, + crate::dto::InstanceRuntime::Failed { .. } + ))); + } + + #[test] + fn versions_are_filtered_by_category() { + let release = mock_versions_for(VersionCategory::Release); + assert!(release + .iter() + .all(|version| version.kind == VersionKind::Release)); + assert_eq!(release.len(), 3); + + let classic = mock_versions_for(VersionCategory::Ancient(AncientKind::Classic)); + assert_eq!(classic.len(), 1); + assert_eq!(classic[0].id, "rd-132211"); + + assert!(mock_versions_for(VersionCategory::Mod).is_empty()); + } +} diff --git a/crates/app/src/png.rs b/crates/app/src/png.rs new file mode 100644 index 0000000..94aff5b --- /dev/null +++ b/crates/app/src/png.rs @@ -0,0 +1,140 @@ +//! 极简 PNG 编码器(只做「无压缩 stored deflate」),给截图验收用。 +//! +//! 为什么不引 `image` / `png` crate:截屏只是开发期验收工具(`--screenshot`), +//! 为它加一个依赖违反「新依赖过 deny.toml 白名单」的成本收益。PNG 规范里 zlib 流允许 +//! **stored(未压缩)块**,于是我们只需要 CRC32 + Adler32 两个校验和就能产出合法 PNG—— +//! 文件大一些,但任何看图工具都认。 +//! +//! 输出:8bit RGBA(color type 6),逐行 filter=0(None)。 + +use slint::{Rgba8Pixel, SharedPixelBuffer}; + +/// 把 RGBA8 像素缓冲写成 PNG 字节流。 +#[must_use] +pub fn encode_rgba8(buffer: &SharedPixelBuffer) -> Vec { + let width = buffer.width(); + let height = buffer.height(); + let pixels = buffer.as_slice(); + + // 原始扫描线:每行前面加一个 filter 字节 0 + let stride = width as usize * 4; + let mut raw = Vec::with_capacity((stride + 1) * height as usize); + for y in 0..height as usize { + raw.push(0u8); + for x in 0..width as usize { + let pixel = pixels[y * width as usize + x]; + raw.extend_from_slice(&[pixel.r, pixel.g, pixel.b, pixel.a]); + } + } + + let mut out = Vec::new(); + out.extend_from_slice(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]); + + let mut ihdr = Vec::with_capacity(13); + ihdr.extend_from_slice(&width.to_be_bytes()); + ihdr.extend_from_slice(&height.to_be_bytes()); + ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); // 位深 8、RGBA、deflate、无交错 + push_chunk(&mut out, b"IHDR", &ihdr); + push_chunk(&mut out, b"IDAT", &zlib_stored(&raw)); + push_chunk(&mut out, b"IEND", &[]); + out +} + +fn push_chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(kind); + out.extend_from_slice(data); + let mut crc_input = Vec::with_capacity(4 + data.len()); + crc_input.extend_from_slice(kind); + crc_input.extend_from_slice(data); + out.extend_from_slice(&crc32(&crc_input).to_be_bytes()); +} + +/// zlib 容器 + 全 stored deflate 块(每块上限 65535 字节)。 +fn zlib_stored(raw: &[u8]) -> Vec { + let mut out = Vec::with_capacity(raw.len() + raw.len() / 65_535 * 5 + 16); + out.extend_from_slice(&[0x78, 0x01]); // CMF/FLG:deflate、32K 窗口、无字典、最快 + let mut offset = 0usize; + while offset < raw.len() { + let len = (raw.len() - offset).min(65_535); + let last = offset + len >= raw.len(); + out.push(u8::from(last)); // BFINAL + BTYPE=00 + let len_u16 = len as u16; + out.extend_from_slice(&len_u16.to_le_bytes()); + out.extend_from_slice(&(!len_u16).to_le_bytes()); + out.extend_from_slice(&raw[offset..offset + len]); + offset += len; + } + if raw.is_empty() { + out.push(1); + out.extend_from_slice(&[0, 0, 0xff, 0xff]); + } + out.extend_from_slice(&adler32(raw).to_be_bytes()); + out +} + +fn crc32(data: &[u8]) -> u32 { + let mut crc = 0xffff_ffffu32; + for byte in data { + crc ^= u32::from(*byte); + for _ in 0..8 { + let mask = (crc & 1).wrapping_neg(); + crc = (crc >> 1) ^ (0xedb8_8320 & mask); + } + } + !crc +} + +fn adler32(data: &[u8]) -> u32 { + let mut a: u32 = 1; + let mut b: u32 = 0; + for byte in data { + a = (a + u32::from(*byte)) % 65_521; + b = (b + a) % 65_521; + } + (b << 16) | a +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pixel(r: u8, g: u8, b: u8) -> Rgba8Pixel { + Rgba8Pixel { r, g, b, a: 255 } + } + + #[test] + fn header_and_chunks_are_well_formed() { + let mut buffer = SharedPixelBuffer::::new(3, 2); + buffer.make_mut_slice().fill(pixel(1, 2, 3)); + let bytes = encode_rgba8(&buffer); + assert_eq!( + &bytes[..8], + &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a] + ); + // IHDR:宽 3 高 2、8bit、RGBA(6) + assert_eq!(&bytes[8..12], &13u32.to_be_bytes()); + assert_eq!(&bytes[12..16], b"IHDR"); + assert_eq!(&bytes[16..20], &3u32.to_be_bytes()); + assert_eq!(&bytes[20..24], &2u32.to_be_bytes()); + assert_eq!(bytes[24], 8); + assert_eq!(bytes[25], 6); + // 结尾是 IEND 块 + assert_eq!(&bytes[bytes.len() - 8..bytes.len() - 4], b"IEND"); + } + + #[test] + fn checksums_match_known_vectors() { + // CRC32("IEND") 是公认值 0xAE426082(写进 IEND 块尾) + assert_eq!(crc32(b"IEND"), 0xae42_6082); + // Adler32("Wikipedia") = 0x11E60398 + assert_eq!(adler32(b"Wikipedia"), 0x11e6_0398); + } + + #[test] + fn empty_buffer_still_produces_valid_stream() { + let buffer = SharedPixelBuffer::::new(0, 0); + let bytes = encode_rgba8(&buffer); + assert!(bytes.windows(4).any(|window| window == b"IDAT")); + } +} diff --git a/crates/app/src/prefs.rs b/crates/app/src/prefs.rs new file mode 100644 index 0000000..2649907 --- /dev/null +++ b/crates/app/src/prefs.rs @@ -0,0 +1,288 @@ +//! UI 偏好项与持久化键登记(导航折叠、AI 栏折叠、磁贴尺寸、视图、主题三态与外观四件、下载分类)。 +//! +//! 偏好不自己写盘:core::setting 的 schema 按 [`keys::ALL`] 登记这些键, +//! app 启动时读一次、变更时写回(跨模块约定见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`)。 +//! 落盘实现是 [`crate::settings_bridge`](同一份 `config/launcher.json`,与 `ull config set` 同源), +//! 主题三态 + `appearance.*` 高级键已接(M41);其余 UI 偏好键仍只在内存里(后续 mission 接)。 + +/// 背景图填充模式(09 §101)。直接复用 core 的枚举:取值与落盘词表只有一处真相。 +pub use unlucky_core::setting::appearance::BgFill; + +use crate::dto::VersionCategory; + +/// 预设强调色板(09 §104)。**必须**与 `ui/pages/settings-appearance.slint` 的 `swatch-colors` +/// 同序同值:Slint 侧只回传索引,十六进制串由这张表给出(一处真相)。 +pub const ACCENT_PRESETS: [&str; 8] = [ + "#0078D4", "#0F6CBD", "#107C10", "#9D5D00", "#C42B1C", "#8764B8", "#C239B3", "#00B7C3", +]; + +/// 磁贴尺寸(Q24-3:小 6 列 / 中 4 列 / 大 3 列)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TileSize { + /// 小:6 列,只留图标/名字/启动键 + Small, + /// 中:4 列(D16 默认) + #[default] + Medium, + /// 大:3 列,显示完整徽标与最后游玩时间 + Large, +} + +impl TileSize { + /// 该尺寸对应的网格列数。 + pub fn columns(self) -> usize { + match self { + Self::Small => 6, + Self::Medium => 4, + Self::Large => 3, + } + } + + /// 设置页/实例页分段控件的文案。 + pub fn label(self) -> &'static str { + match self { + Self::Small => "小", + Self::Medium => "中", + Self::Large => "大", + } + } +} + +/// 实例页视图模式。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum InstanceView { + /// 磁贴网格(默认) + #[default] + Tiles, + /// 紧凑列表 + List, +} + +/// 主题三态(D17,P0 交付):`appearance.theme_mode ∈ {light, dark, auto}`,出厂 auto。 +/// +/// auto 由 `theme/tokens.slint` 读 Slint 原生 `Palette.color-scheme` 跟随系统(后端随 OS 热更); +/// light/dark 由 `Theme.theme-mode` 钉死**自绘体系**;std-widgets 内部件(ListView 滚动条 / +/// ProgressIndicator / AboutSlint)目前仍跟随系统色,`Palette.color-scheme` 的钉死留 P1。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ThemeMode { + /// 跟随系统(默认) + #[default] + Auto, + /// 强制浅色 + Light, + /// 强制深色 + Dark, +} + +impl ThemeMode { + /// 落盘取值(`appearance.theme_mode`):`auto` / `light` / `dark`。 + pub fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Light => "light", + Self::Dark => "dark", + } + } + + /// 从落盘取值解析;未知值按 `auto` 兜底(配置被手改坏也不崩)。 + pub fn from_key_value(value: &str) -> Self { + match value { + "light" => Self::Light, + "dark" => Self::Dark, + _ => Self::Auto, + } + } +} + +/// 09 §101-104「外观(高级)」四件的取值快照(出厂全部关闭)。 +/// +/// 与 `unlucky_core::setting::appearance::Appearance` 一一对应,但字段类型是 UI 侧好用的 +/// `f32`/`String`;落盘由 [`crate::settings_bridge`] 转成 schema 键。 +#[derive(Debug, Clone, PartialEq, Default)] +pub struct AppearancePrefs { + /// 背景图总开关 + pub bg_image_enabled: bool, + /// 背景图路径(PNG;空 = 未设置) + pub bg_image: String, + /// 填充模式 + pub bg_fill: BgFill, + /// 暗化层强度 0.0~0.9 + pub bg_dim: f32, + /// 面板半透明度 0.4~1.0 + pub panel_alpha: f32, + /// 玻璃拟态卡片 + pub glass: bool, + /// 自定义强调色 `#RRGGBB`;空 = 出厂 [#0078D4] + pub accent_color: String, +} + +impl AppearancePrefs { + /// 出厂态:Metro 纯色,四个高级特性全关。 + pub fn factory() -> Self { + Self { + bg_image_enabled: false, + bg_image: String::new(), + bg_fill: BgFill::Cover, + bg_dim: 0.35, + panel_alpha: 1.0, + glass: false, + accent_color: String::new(), + } + } + + /// 背景图是否真的生效(开关打开且路径非空)。 + pub fn bg_active(&self) -> bool { + self.bg_image_enabled && !self.bg_image.trim().is_empty() + } + + /// 规范化写入强调色:`#RRGGBB` 才接受(空串 = 恢复出厂)。 + /// + /// 返回 `false` 表示输入不合法(调用方据此亮设置页的 warn 行,不改现状)。 + pub fn set_accent(&mut self, text: &str) -> bool { + let trimmed = text.trim(); + if trimmed.is_empty() { + self.accent_color.clear(); + return true; + } + match unlucky_core::setting::appearance::normalize_hex(trimmed) { + Some(hex) => { + self.accent_color = hex; + true + } + None => false, + } + } + + /// 用预设色板第 `index` 个颜色(越界忽略)。 + pub fn set_accent_preset(&mut self, index: usize) -> bool { + match ACCENT_PRESETS.get(index) { + Some(hex) => self.set_accent(hex), + None => false, + } + } +} + +/// 需要跨会话记忆的 UI 偏好。 +/// +/// 注意:`Eq` 不成立([`AppearancePrefs`] 带 `f32` 滑条值),用到的地方按 `PartialEq` 比。 +#[derive(Debug, Clone, PartialEq)] +pub struct UiPrefs { + /// 左侧导航栏折叠成图标条 + pub nav_collapsed: bool, + /// 右侧 AI 栏折叠成 icon 轨 + pub ai_collapsed: bool, + /// 磁贴尺寸 + pub tile_size: TileSize, + /// 实例页视图 + pub instance_view: InstanceView, + /// 主题模式 + pub theme: ThemeMode, + /// 外观(高级)四件(09 §101-104) + pub appearance: AppearancePrefs, + /// 下载页选中的分类 + pub download_category: VersionCategory, +} + +impl Default for UiPrefs { + fn default() -> Self { + Self { + nav_collapsed: false, + ai_collapsed: false, + // D16 默认中磁贴(4 列) + tile_size: TileSize::Medium, + instance_view: InstanceView::Tiles, + theme: ThemeMode::Auto, + appearance: AppearancePrefs::factory(), + download_category: VersionCategory::Release, + } + } +} + +/// 持久化键名登记(core::setting schema 按此注册;改名等于迁移,慎动)。 +pub mod keys { + /// 导航栏折叠 + pub const NAV_COLLAPSED: &str = "ui.nav.collapsed"; + /// AI 栏折叠 + pub const AI_COLLAPSED: &str = "ui.ai.collapsed"; + /// 磁贴尺寸 + pub const TILE_SIZE: &str = "ui.instances.tile_size"; + /// 实例页视图(磁贴/列表) + pub const INSTANCE_VIEW: &str = "ui.instances.view"; + /// 主题三态(D17;与设置页「外观」分组同名,取值见 [`super::ThemeMode::as_str`]) + pub const THEME_MODE: &str = "appearance.theme_mode"; + /// 背景图开关(09 §101) + pub const BG_IMAGE_ENABLED: &str = "appearance.bg_image_enabled"; + /// 背景图路径 + pub const BG_IMAGE: &str = "appearance.bg_image"; + /// 背景图填充模式 + pub const BG_FILL: &str = "appearance.bg_fill"; + /// 暗化层强度 + pub const BG_DIM: &str = "appearance.bg_dim"; + /// 面板半透明度(09 §102) + pub const PANEL_ALPHA: &str = "appearance.panel_alpha"; + /// 玻璃拟态卡片(09 §103) + pub const GLASS: &str = "appearance.glass"; + /// 自定义强调色(09 §104) + pub const ACCENT_COLOR: &str = "appearance.accent_color"; + /// 下载页分类 + pub const DOWNLOAD_CATEGORY: &str = "ui.download.category"; + + /// 本模块登记的全部键(自检/迁移用)。 + pub const ALL: [&str; 13] = [ + NAV_COLLAPSED, + AI_COLLAPSED, + TILE_SIZE, + INSTANCE_VIEW, + THEME_MODE, + BG_IMAGE_ENABLED, + BG_IMAGE, + BG_FILL, + BG_DIM, + PANEL_ALPHA, + GLASS, + ACCENT_COLOR, + DOWNLOAD_CATEGORY, + ]; +} + +#[cfg(test)] +mod tests { + use super::{keys, InstanceView, ThemeMode, TileSize, UiPrefs}; + + #[test] + fn defaults_match_d16() { + let prefs = UiPrefs::default(); + assert_eq!(prefs.tile_size, TileSize::Medium); + assert_eq!(prefs.tile_size.columns(), 4); + assert_eq!(prefs.instance_view, InstanceView::Tiles); + assert_eq!(prefs.theme, ThemeMode::Auto); + assert_eq!(prefs.theme.as_str(), "auto"); + assert!(!prefs.nav_collapsed); + assert!(!prefs.ai_collapsed); + } + + #[test] + fn tile_size_columns_follow_q24() { + assert_eq!(TileSize::Small.columns(), 6); + assert_eq!(TileSize::Medium.columns(), 4); + assert_eq!(TileSize::Large.columns(), 3); + } + + #[test] + fn theme_mode_round_trips_through_key_values() { + for mode in [ThemeMode::Auto, ThemeMode::Light, ThemeMode::Dark] { + assert_eq!(ThemeMode::from_key_value(mode.as_str()), mode); + } + // 坏值兜底 + assert_eq!(ThemeMode::from_key_value("solarized"), ThemeMode::Auto); + } + + #[test] + fn pref_keys_are_unique() { + let mut keys = keys::ALL.to_vec(); + let total = keys.len(); + keys.sort_unstable(); + keys.dedup(); + assert_eq!(keys.len(), total); + } +} diff --git a/crates/app/src/router.rs b/crates/app/src/router.rs new file mode 100644 index 0000000..b65f6d7 --- /dev/null +++ b/crates/app/src/router.rs @@ -0,0 +1,184 @@ +//! 页面路由与 AI 助手栏形态(09 §1 / D16)。 +//! +//! 栈语义抄 HMCL 的导航栈:**顶层页面互相替换**(实例/下载/日志/设置), +//! 子页面(实例详情、AI 独立页)压栈,`back()` 逐层回退。 +//! 本模块刻意不依赖 Slint 类型,便于单元测试;UI 侧枚举映射在 main.rs 的转换层做。 + +use crate::dto::InstanceId; + +/// 一级导航项(左侧导航栏高亮单位)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NavSection { + /// 实例 + Instances, + /// 下载 + Download, + /// 日志 + Logs, + /// 设置 + Settings, +} + +/// 页面路由。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Route { + /// G1 实例页(主页) + Instances, + /// G2 下载页 + Download, + /// G3 日志页 + Logs, + /// G6 设置页 + Settings, + /// 实例详情(G4):从磁贴点进来,返回时回到实例页 + Detail(InstanceId), + /// AI 助手独立页(D16 双形态之一) + Agent, +} + +impl Route { + /// 是否是一级导航页(互相替换,不进栈)。 + pub fn is_top_level(&self) -> bool { + matches!( + self, + Self::Instances | Self::Download | Self::Logs | Self::Settings + ) + } + + /// 该路由高亮哪个导航项(None = 没有导航项高亮,如 AI 独立页)。 + pub fn nav(&self) -> Option { + match self { + Self::Instances | Self::Detail(_) => Some(NavSection::Instances), + Self::Download => Some(NavSection::Download), + Self::Logs => Some(NavSection::Logs), + Self::Settings => Some(NavSection::Settings), + Self::Agent => None, + } + } +} + +/// AI 栏形态:右侧栏 / 独立全宽页(两形态共享同一份会话状态)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AiPanelMode { + /// 右侧常驻栏 + Docked, + /// 拆出的独立全宽页 + Page, +} + +/// 导航栈。 +#[derive(Debug, Clone)] +pub struct Router { + stack: Vec, +} + +impl Default for Router { + fn default() -> Self { + Self::new() + } +} + +impl Router { + /// 启动时停在实例页。 + pub fn new() -> Self { + Self { + stack: vec![Route::Instances], + } + } + + /// 当前页面。 + pub fn route(&self) -> &Route { + // 栈不变量:构造后永远非空(navigate/back 都不允许清空) + self.stack.last().expect("router stack is never empty") + } + + /// 栈深度(1 表示没有可回退的子页面)。 + pub fn depth(&self) -> usize { + self.stack.len() + } + + /// 是否能回退。 + pub fn can_go_back(&self) -> bool { + self.stack.len() > 1 + } + + /// 跳转到某路由:一级页替换整栈,子页面压栈(重复目标不重复压栈)。 + pub fn navigate(&mut self, route: Route) { + if route.is_top_level() { + self.stack.clear(); + self.stack.push(route); + return; + } + if self.stack.last() == Some(&route) { + return; + } + self.stack.push(route); + } + + /// 回退一层;已在根页面时返回 `false`。 + pub fn back(&mut self) -> bool { + if self.stack.len() <= 1 { + return false; + } + self.stack.pop(); + true + } + + /// 当前 AI 栏形态(栈顶是 Agent 页即为独立页形态)。 + pub fn ai_mode(&self) -> AiPanelMode { + if self.route() == &Route::Agent { + AiPanelMode::Page + } else { + AiPanelMode::Docked + } + } +} + +#[cfg(test)] +mod tests { + use super::{AiPanelMode, NavSection, Route, Router}; + + #[test] + fn top_level_navigation_replaces_the_stack() { + let mut router = Router::new(); + router.navigate(Route::Detail("a".into())); + assert_eq!(router.depth(), 2); + + router.navigate(Route::Download); + assert_eq!(router.depth(), 1); + assert_eq!(router.route(), &Route::Download); + } + + #[test] + fn detail_pushes_and_back_pops_to_instances() { + let mut router = Router::new(); + router.navigate(Route::Detail("survival".into())); + assert!(router.can_go_back()); + assert_eq!(router.route().nav(), Some(NavSection::Instances)); + + assert!(router.back()); + assert_eq!(router.route(), &Route::Instances); + assert!(!router.back()); + } + + #[test] + fn agent_page_switches_ai_panel_mode() { + let mut router = Router::new(); + assert_eq!(router.ai_mode(), AiPanelMode::Docked); + + router.navigate(Route::Agent); + assert_eq!(router.ai_mode(), AiPanelMode::Page); + + router.back(); + assert_eq!(router.ai_mode(), AiPanelMode::Docked); + } + + #[test] + fn navigating_to_the_same_sub_route_does_not_grow_the_stack() { + let mut router = Router::new(); + router.navigate(Route::Agent); + router.navigate(Route::Agent); + assert_eq!(router.depth(), 2); + assert_eq!(router.route().nav(), None); + } +} diff --git a/crates/app/src/settings_bridge.rs b/crates/app/src/settings_bridge.rs new file mode 100644 index 0000000..644d060 --- /dev/null +++ b/crates/app/src/settings_bridge.rs @@ -0,0 +1,294 @@ +//! 设置持久化桥:把 UI 偏好落到 `core::setting` 的 schema 文件里,并支持**外部热改热切**。 +//! +//! 三条纪律(`DevDocs/SubAgentsTalk/app-DTO通道约定.md` §6 / AGENTS §6): +//! +//! 1. **不另立配置文件**:读写的就是 `ull config set/get` 与 AI `set_config` 用的那一份 +//! `/config/launcher.json`(`core::platform::data_dir` 解析数据根、`layout` 给路径); +//! 2. **不自己拼 JSON**:一律过 `core::setting::store::SettingStore`(schema 校验 + 原子写); +//! 3. **不 import 领域类型**:跨边界只看「键名 + 取值」,领域模型留在 core。 +//! +//! 热切:文件 `mtime` 变了就整份重读(AI / CLI 改完下一拍即生效),见 [`SettingsBridge::reload_if_changed`]。 + +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, Resolver}; +use unlucky_core::platform::layout::LauncherLayout; +use unlucky_core::platform::paths::Platform; +use unlucky_core::setting::appearance::Appearance; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +use crate::prefs::{AppearancePrefs, ThemeMode}; + +/// 数据根配置键(决定 `launcher.json` 落在哪个数据目录;与 CLI 的 `DATA_ROOT_KEYS` 同源同义)。 +const DATA_ROOT_KEYS: [&str; 4] = [ + "core.data_mode", + "core.data_custom_root", + "core.launcher_home", + "core.game_home", +]; + +/// 设置文件桥(一个进程一份)。 +#[derive(Debug)] +pub struct SettingsBridge { + /// `config/launcher.json`;`None` = 数据根解析不出来(内存态运行,不落盘) + path: Option, + store: Option, + /// 上次读/写之后的文件 `mtime`(热切探测用) + last_mtime: Option, +} + +impl SettingsBridge { + /// 按三模式数据根打开真实配置文件(启动时调用一次)。 + #[must_use] + pub fn open() -> Self { + match resolve_config_path() { + Some(path) => Self::with_path(path), + None => Self::memory_only(), + } + } + + /// 指定路径打开(测试与特殊数据根用)。 + #[must_use] + pub fn with_path(path: impl Into) -> Self { + let path = path.into(); + let store = SettingStore::load(&path, launcher_schema()); + let last_mtime = mtime(&path); + Self { + path: Some(path), + store: Some(store), + last_mtime, + } + } + + /// 不落盘(数据根解析失败 / 单测)。 + #[must_use] + pub fn memory_only() -> Self { + Self { + path: None, + store: None, + last_mtime: None, + } + } + + /// 配置文件路径(诊断/日志用)。 + #[must_use] + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } + + /// 当前生效的外观配置与主题模式。 + #[must_use] + pub fn load(&self) -> (AppearancePrefs, ThemeMode) { + let Some(store) = &self.store else { + return (AppearancePrefs::factory(), ThemeMode::Auto); + }; + let appearance = Appearance::from_store(store); + let theme = ThemeMode::from_key_value(&appearance.theme_mode); + ( + AppearancePrefs { + bg_image_enabled: appearance.bg_image_enabled, + bg_image: appearance.bg_image, + bg_fill: appearance.bg_fill, + bg_dim: appearance.bg_dim as f32, + panel_alpha: appearance.panel_alpha as f32, + glass: appearance.glass, + accent_color: appearance.accent_color, + }, + theme, + ) + } + + /// 落盘(只写出现过的键;失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。 + pub fn persist(&mut self, appearance: &AppearancePrefs, theme: ThemeMode) { + let Some(store) = self.store.as_mut() else { + return; + }; + let mut snapshot = Appearance::from_store(store); + snapshot.theme_mode = theme.as_str().to_owned(); + snapshot.bg_image_enabled = appearance.bg_image_enabled; + snapshot.bg_image = appearance.bg_image.clone(); + snapshot.bg_fill = appearance.bg_fill; + snapshot.bg_dim = f64::from(appearance.bg_dim); + snapshot.panel_alpha = f64::from(appearance.panel_alpha); + snapshot.glass = appearance.glass; + snapshot.accent_color = appearance.accent_color.clone(); + if snapshot.write(store).is_err() { + return; + } + let _ = store.save(); + if let Some(path) = self.path() { + self.last_mtime = mtime(path); + } + } + + /// 文件被外部(AI / `ull config set`)改过就重读;返回新的生效值。 + /// + /// `mtime` 采样不到(某些文件系统精度/权限)时返回 `None`,退化为「只在启动时读一次」。 + pub fn reload_if_changed(&mut self) -> Option<(AppearancePrefs, ThemeMode)> { + let path = self.path.clone()?; + let current = mtime(&path); + if current.is_none() || current == self.last_mtime { + return None; + } + self.last_mtime = current; + let store = self.store.as_mut()?; + store.reload(); + Some(self.load()) + } +} + +/// 数据根解析 → `config/launcher.json`。 +/// +/// 与 CLI 同一套语义:先读便携目录那份 `launcher.json` 的 `core.*`(数据根决定键), +/// 再交 `Resolver` 解析(含 `redirect.json` 跟随)。任何一步失败都退回内存态。 +fn resolve_config_path() -> Option { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf)); + let exe_dir = exe_dir?; + let request = request_from_portable(&exe_dir); + let resolver = Resolver::new(Platform::current(), Some(exe_dir.clone())); + match resolver.resolve_default(&request) { + Ok(resolved) => Some(resolved.layout().launcher_config()), + // 连 exe 目录都解析不了(极端情况):仍旧用便携落点,至少设置能存下来 + Err(_) => Some(LauncherLayout::new(exe_dir.join(".ULL")).launcher_config()), + } +} + +/// 便携目录那份 `launcher.json` → 三模式请求(只读 `core.*` 四个键)。 +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 +} + +/// `/.ULL/config/launcher.json`。 +fn portable_config_path(exe_dir: &Path) -> PathBuf { + LauncherLayout::new(exe_dir.join(".ULL")).launcher_config() +} + +fn mtime(path: &Path) -> Option { + std::fs::metadata(path).ok()?.modified().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use unlucky_core::setting::appearance::BgFill; + + fn temp_config(label: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-bridge-{label}-{}-{}", + std::process::id(), + line!() + )); + std::fs::create_dir_all(dir.join("config")).expect("临时目录"); + let path = dir.join("config/launcher.json"); + let _ = std::fs::remove_file(&path); + path + } + + #[test] + fn factory_defaults_when_file_missing() { + let path = temp_config("factory"); + let bridge = SettingsBridge::with_path(&path); + let (appearance, theme) = bridge.load(); + assert_eq!(appearance, AppearancePrefs::factory()); + assert_eq!(theme, ThemeMode::Auto); + assert!(!path.exists(), "只读不写:缺文件不该被创建"); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); + } + + #[test] + fn persist_then_reload_round_trips() { + let path = temp_config("roundtrip"); + let mut bridge = SettingsBridge::with_path(&path); + let mut appearance = AppearancePrefs::factory(); + appearance.bg_image_enabled = true; + appearance.bg_image = "D:/wall/p.png".to_owned(); + appearance.bg_fill = BgFill::Contain; + appearance.bg_dim = 0.5; + appearance.panel_alpha = 0.8; + appearance.glass = true; + assert!(appearance.set_accent("#0f6cbd")); + bridge.persist(&appearance, ThemeMode::Dark); + + let reloaded = SettingsBridge::with_path(&path); + let (value, theme) = reloaded.load(); + assert_eq!(value, appearance); + assert_eq!(theme, ThemeMode::Dark); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); + } + + #[test] + fn external_edit_is_picked_up_by_reload() { + let path = temp_config("hotreload"); + let mut bridge = SettingsBridge::with_path(&path); + // 先落一份(建立 mtime 基线) + bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light); + assert!(bridge.reload_if_changed().is_none(), "自己写完不该报告变更"); + + // 模拟外部写入(AI / `ull config set`):改主题 + 打开背景图 + let mut external = SettingsBridge::with_path(&path); + let mut appearance = AppearancePrefs::factory(); + appearance.bg_image_enabled = true; + appearance.bg_image = "D:/wall/x.png".to_owned(); + external.persist(&appearance, ThemeMode::Dark); + + // mtime 精度:有的文件系统 1s 粒度,这里显式等到时间戳变化 + for _ in 0..50 { + if let Some((value, theme)) = bridge.reload_if_changed() { + assert_eq!(theme, ThemeMode::Dark); + assert!(value.bg_image_enabled); + assert_eq!(value.bg_image, "D:/wall/x.png"); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); + return; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("外部修改没有被 mtime 热切捕获"); + } + + #[test] + fn accent_preset_and_validation() { + let mut appearance = AppearancePrefs::factory(); + assert_eq!(appearance.accent_color, ""); + assert!(appearance.set_accent_preset(1)); + assert_eq!(appearance.accent_color, "#0F6CBD"); + assert!(!appearance.set_accent("not-a-color"), "非法输入不改现状"); + assert_eq!(appearance.accent_color, "#0F6CBD"); + assert!(appearance.set_accent(""), "空串恢复出厂"); + assert_eq!(appearance.accent_color, ""); + assert!(!appearance.set_accent_preset(99)); + } + + #[test] + fn bg_active_needs_both_switch_and_path() { + let mut appearance = AppearancePrefs::factory(); + appearance.bg_image = "D:/wall/p.png".to_owned(); + assert!(!appearance.bg_active(), "开关没开不算生效"); + appearance.bg_image_enabled = true; + assert!(appearance.bg_active()); + appearance.bg_image.clear(); + assert!(!appearance.bg_active(), "路径空也不算生效"); + } +} diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs new file mode 100644 index 0000000..3294358 --- /dev/null +++ b/crates/app/src/state.rs @@ -0,0 +1,304 @@ +//! UI 状态与 reducer:Slint 只负责渲染,所有状态变更都汇聚到 [`AppState::apply`]。 +//! +//! 数据流向:mock/host 后端 → [`crate::dto::AppEvent`] 队列 → `apply` → 状态字段 → Slint 属性。 +//! 偏好项在 [`crate::prefs`] 里登记了持久化键名,core::setting 的 schema 落地后按这些键读写。 + +use crate::dialogs::DialogQueue; +use crate::dto::{ + AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, DiagnosisReport, InstanceRuntime, + InstanceSummary, LogLine, LogSession, Progress, ToolCallView, VersionSummary, +}; +use crate::prefs::UiPrefs; +use crate::router::{AiPanelMode, Route, Router}; +use crate::toast::ToastCenter; + +/// AI 会话状态(右侧栏与独立页共享同一份,切换不丢)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AiSession { + /// 会话消息(user / assistant / tool / system) + pub messages: Vec, + /// 是否有流式消息在跑 + pub streaming: bool, + /// YOLO 开关(跳过确认,审计照记) + pub yolo: bool, + /// 输入框草稿(切形态不丢) + pub draft: String, + /// 端点标签(显示在栏顶) + pub endpoint_label: String, +} + +impl Default for AiSession { + fn default() -> Self { + Self::new() + } +} + +impl AiSession { + /// 默认端点标签(P0 是本地 llama.cpp;P1 由设置页决定)。 + pub fn new() -> Self { + Self { + messages: Vec::new(), + streaming: false, + yolo: false, + draft: String::new(), + endpoint_label: "llama.cpp · 本地".to_string(), + } + } + + /// 流式收敛:清掉全部流式标记。 + pub fn finish_stream(&mut self) { + self.streaming = false; + for message in &mut self.messages { + message.streaming = false; + } + } +} + +/// 磁贴网格单元:位置由 [`grid_positions`] 算好,Slint 只消费 row/col。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TileCell<'a> { + /// 实例数据 + pub instance: &'a InstanceSummary, + /// 网格行号(0 起) + pub row: usize, + /// 网格列号(0 起) + pub col: usize, +} + +/// 纯函数:把 n 个磁贴摊到 `columns` 列的网格上,返回 `(row, col)`。 +/// +/// 特意放在 Rust 侧:Slint DSL 里做整数除法会引入隐式浮点,且这段逻辑可以单测。 +pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> { + let columns = columns.max(1); + (0..count) + .map(|index| (index / columns, index % columns)) + .collect() +} + +/// 应用全局状态。 +#[derive(Debug)] +pub struct AppState { + /// UI 偏好(记忆项) + pub prefs: UiPrefs, + /// 导航栈 + pub router: Router, + /// 实例列表快照 + pub instances: Vec, + /// 当前分类的版本列表 + pub versions: Vec, + /// 日志会话列表 + pub log_sessions: Vec, + /// 当前会话日志行 + pub log_lines: Vec, + /// 最近一次诊断结论 + pub diagnosis: Option, + /// 选中的日志会话 + pub selected_session: Option, + /// AI 会话状态 + pub ai: AiSession, + /// 对话框队列(一次一条) + pub dialogs: DialogQueue, + /// Toast 中心 + pub toasts: ToastCenter, + /// 实例页搜索词 + pub instance_query: String, + /// 下载页「仅已装」过滤 + pub installed_only: bool, + /// 下载页版本号筛选词 + pub download_query: String, +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} + +impl AppState { + /// 空状态(数据由后端事件填充,P0 是 mock)。 + pub fn new() -> Self { + Self { + prefs: UiPrefs::default(), + router: Router::new(), + instances: Vec::new(), + versions: Vec::new(), + log_sessions: Vec::new(), + log_lines: Vec::new(), + diagnosis: None, + selected_session: None, + ai: AiSession::new(), + dialogs: DialogQueue::new(), + toasts: ToastCenter::new(), + instance_query: String::new(), + installed_only: false, + download_query: String::new(), + } + } + + /// 当前页面。 + pub fn route(&self) -> &Route { + self.router.route() + } + + /// 跳转(一级页替换、子页面压栈;去 AI 独立页时自动展开 AI 栏偏好)。 + pub fn navigate(&mut self, route: Route) { + if route == Route::Agent { + self.prefs.ai_collapsed = false; + } + self.router.navigate(route); + } + + /// 回退一层。 + pub fn back(&mut self) -> bool { + self.router.back() + } + + /// AI 栏当前形态(跟着路由走)。 + pub fn ai_mode(&self) -> AiPanelMode { + self.router.ai_mode() + } + + /// 应用一条事件(唯一状态变更入口)。 + pub fn apply(&mut self, event: AppEvent) { + match event { + AppEvent::Core(core) => self.apply_core(core), + AppEvent::Agent(agent) => self.apply_agent(agent), + AppEvent::DialogRequested(request) => { + self.dialogs.push_request(request); + } + AppEvent::DialogClosed(id) => { + self.dialogs.close(id); + } + AppEvent::Toast(toast) => { + self.toasts.push_request(toast); + } + } + } + + /// core 事件。 + pub fn apply_core(&mut self, event: CoreEvent) { + match event { + CoreEvent::InstancesChanged(instances) => self.instances = instances, + CoreEvent::InstanceRuntimeChanged { id, runtime } => { + if let Some(instance) = self.instances.iter_mut().find(|item| item.id == id) { + instance.runtime = runtime; + } + } + CoreEvent::VersionListChanged(versions) => self.versions = versions, + CoreEvent::LogSessionsChanged(sessions) => self.log_sessions = sessions, + CoreEvent::LogLinesChanged(lines) => self.log_lines = lines, + CoreEvent::DiagnosisReady(report) => self.diagnosis = Some(report), + } + } + + /// agent 事件。 + pub fn apply_agent(&mut self, event: AgentEvent) { + match event { + AgentEvent::MessageAppended(message) => { + // 流式占位到达即视为「一轮回复进行中」:UI 的发送键/停止键据此切换 + if message.streaming { + self.ai.streaming = true; + } + self.ai.messages.push(message); + } + AgentEvent::MessageDelta { index, delta } => { + if let Some(message) = self.ai.messages.get_mut(index) { + message.text.push_str(&delta); + } + } + AgentEvent::MessageFinished { index } => { + if let Some(message) = self.ai.messages.get_mut(index) { + message.streaming = false; + } + self.ai.streaming = false; + } + AgentEvent::ToolCallStarted { name, summary } => { + self.ai.streaming = true; + self.ai.messages.push(ChatMessage { + role: ChatRole::Tool, + text: summary.clone(), + tool: Some(ToolCallView { + name, + ok: false, + summary, + }), + streaming: true, + }); + } + AgentEvent::ToolCallFinished { name, ok, summary } => { + let target = + self.ai.messages.iter_mut().rev().find(|message| { + message.tool.as_ref().is_some_and(|tool| tool.name == name) + }); + if let Some(message) = target { + message.text.clone_from(&summary); + message.streaming = false; + if let Some(tool) = &mut message.tool { + tool.ok = ok; + tool.summary = summary; + } + } + } + } + } + + /// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。 + pub fn tiles(&self) -> Vec> { + let query = self.instance_query.trim().to_lowercase(); + let filtered: Vec<&InstanceSummary> = self + .instances + .iter() + .filter(|instance| query.is_empty() || instance.name.to_lowercase().contains(&query)) + .collect(); + let positions = grid_positions(filtered.len(), self.prefs.tile_size.columns()); + filtered + .into_iter() + .zip(positions) + .map(|(instance, (row, col))| TileCell { instance, row, col }) + .collect() + } + + /// 运行中的实例(实例页顶部迷你状态条)。 + pub fn running(&self) -> Vec<&InstanceSummary> { + self.instances + .iter() + .filter(|instance| { + matches!( + instance.runtime, + InstanceRuntime::Running { .. } | InstanceRuntime::Busy { .. } + ) + }) + .collect() + } + + /// 覆盖某实例的启动进度(mock / 真实后端共用同一入口)。 + pub fn set_progress(&mut self, id: &str, progress: Progress) { + if let Some(instance) = self.instances.iter_mut().find(|item| item.id == id) { + if let InstanceRuntime::Busy { + progress: current, .. + } = &mut instance.runtime + { + *current = progress; + } + } + } + + /// 折叠/展开导航栏。 + pub fn toggle_nav(&mut self) { + self.prefs.nav_collapsed = !self.prefs.nav_collapsed; + } + + /// 折叠/展开 AI 栏;若正处在独立页形态则先收回侧栏。 + pub fn toggle_ai(&mut self) { + if self.route() == &Route::Agent { + self.router.back(); + self.prefs.ai_collapsed = false; + return; + } + self.prefs.ai_collapsed = !self.prefs.ai_collapsed; + } +} + +#[cfg(test)] +#[path = "state_tests.rs"] +mod tests; diff --git a/crates/app/src/state_tests.rs b/crates/app/src/state_tests.rs new file mode 100644 index 0000000..9928b12 --- /dev/null +++ b/crates/app/src/state_tests.rs @@ -0,0 +1,262 @@ +//! state.rs 的单元测试(拆文件保持单文件 ≤400 行)。 + +use super::{grid_positions, AiSession, AppState}; +use crate::dialogs::{DialogKind, DialogRequest, DialogSource}; +use crate::dto::{ + AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, Diagnosis, DiagnosisReport, + InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, +}; +use crate::prefs::TileSize; +use crate::router::Route; +use crate::toast::Toast; +use crate::toast::ToastKind; + +fn instance(id: &str, name: &str) -> InstanceSummary { + InstanceSummary { + id: id.to_string(), + name: name.to_string(), + mc_version: "1.20.4".to_string(), + loader: LoaderInfo { + kind: LoaderKind::Fabric, + version: "0.16.5".to_string(), + }, + last_played: None, + runtime: InstanceRuntime::Idle, + icon: None, + } +} + +#[test] +fn grid_positions_wrap_by_column_count() { + assert_eq!(grid_positions(0, 4), vec![]); + assert_eq!(grid_positions(3, 2), vec![(0, 0), (0, 1), (1, 0)]); + // columns == 0 时按 1 列兜底,不允许除零 + assert_eq!(grid_positions(2, 0), vec![(0, 0), (1, 0)]); +} + +#[test] +fn tiles_follow_tile_size_and_search() { + let mut state = AppState::new(); + state.instances = vec![ + instance("a", "生存"), + instance("b", "创造"), + instance("c", "Demo"), + ]; + + assert_eq!(state.tiles().len(), 3); + assert_eq!((state.tiles()[2].row, state.tiles()[2].col), (0, 2)); + + state.prefs.tile_size = TileSize::Large; + assert_eq!((state.tiles()[2].row, state.tiles()[2].col), (0, 2)); + + state.prefs.tile_size = TileSize::Small; + assert_eq!(state.tiles().len(), 3); + + state.instance_query = "demo".to_string(); + let tiles = state.tiles(); + assert_eq!(tiles.len(), 1); + assert_eq!(tiles[0].instance.id, "c"); + assert_eq!((tiles[0].row, tiles[0].col), (0, 0)); +} + +#[test] +fn tiles_reflow_when_tile_size_shrinks() { + let mut state = AppState::new(); + state.instances = (0..8) + .map(|i| instance(&format!("i{i}"), &format!("实例{i}"))) + .collect(); + + state.prefs.tile_size = TileSize::Medium; + let medium_rows = state.tiles().iter().map(|cell| cell.row).max(); + + state.prefs.tile_size = TileSize::Small; + let small_rows = state.tiles().iter().map(|cell| cell.row).max(); + + assert_eq!(medium_rows, Some(1)); // 8 / 4 列 = 2 行 + assert_eq!(small_rows, Some(1)); // 8 / 6 列 = 2 行 +} + +#[test] +fn runtime_events_update_the_matching_instance() { + let mut state = AppState::new(); + state.instances = vec![instance("a", "生存"), instance("b", "创造")]; + + state.apply(AppEvent::Core(CoreEvent::InstanceRuntimeChanged { + id: "b".to_string(), + runtime: InstanceRuntime::Running { pid: 42 }, + })); + + assert_eq!(state.instances[0].runtime, InstanceRuntime::Idle); + assert_eq!( + state.instances[1].runtime, + InstanceRuntime::Running { pid: 42 } + ); + assert_eq!(state.running().len(), 1); + assert_eq!(state.running()[0].id, "b"); +} + +#[test] +fn progress_events_only_touch_busy_instances() { + let mut state = AppState::new(); + state.instances = vec![instance("a", "生存")]; + state.instances[0].runtime = InstanceRuntime::Busy { + stage: crate::dto::LaunchStage::Download, + progress: crate::dto::Progress { + done: 0, + total: 100, + }, + }; + + state.set_progress( + "a", + crate::dto::Progress { + done: 50, + total: 100, + }, + ); + match &state.instances[0].runtime { + InstanceRuntime::Busy { progress, .. } => { + assert!((progress.ratio() - 0.5).abs() < f32::EPSILON); + } + other => panic!("unexpected runtime: {other:?}"), + } + + // 不存在的 id / 非 Busy 状态不改变任何东西 + state.set_progress("missing", crate::dto::Progress { done: 1, total: 1 }); + assert_eq!(state.instances.len(), 1); +} + +#[test] +fn ai_stream_deltas_accumulate_and_finish() { + let mut state = AppState::new(); + state.apply(AppEvent::Agent(AgentEvent::MessageAppended( + ChatMessage::user("怎么装 1.20.4"), + ))); + state.apply(AppEvent::Agent(AgentEvent::MessageAppended( + ChatMessage::assistant_stream(), + ))); + assert_eq!(state.ai.messages.len(), 2); + assert!(state.ai.streaming); + assert_eq!(state.ai.messages[1].role, ChatRole::Assistant); + + state.apply(AppEvent::Agent(AgentEvent::MessageDelta { + index: 1, + delta: "先".to_string(), + })); + state.apply(AppEvent::Agent(AgentEvent::MessageDelta { + index: 1, + delta: "下载".to_string(), + })); + state.apply(AppEvent::Agent(AgentEvent::MessageFinished { index: 1 })); + + assert_eq!(state.ai.messages[1].text, "先下载"); + assert!(!state.ai.messages[1].streaming); + assert!(!state.ai.streaming); +} + +#[test] +fn ai_session_finish_stream_clears_every_flag() { + let mut session = AiSession::new(); + session.messages.push(ChatMessage::user("hi")); + session.messages.push(ChatMessage::assistant_stream()); + session.finish_stream(); + assert!(session.messages.iter().all(|message| !message.streaming)); + assert!(!session.streaming); +} + +#[test] +fn tool_call_finish_updates_the_last_matching_message() { + let mut state = AppState::new(); + state.apply(AppEvent::Agent(AgentEvent::ToolCallStarted { + name: "get_config".to_string(), + summary: "读取设置".to_string(), + })); + state.apply(AppEvent::Agent(AgentEvent::ToolCallFinished { + name: "get_config".to_string(), + ok: true, + summary: "读取到 3 项".to_string(), + })); + + let tool = &state.ai.messages[0]; + assert_eq!(tool.role, ChatRole::Tool); + assert_eq!(tool.text, "读取到 3 项"); + assert!(!tool.streaming); + assert!(tool.tool.as_ref().is_some_and(|call| call.ok)); +} + +#[test] +fn dialogs_and_toasts_flow_through_apply() { + let mut state = AppState::new(); + state.apply(AppEvent::DialogRequested(DialogRequest { + id: 42, + source: DialogSource::UserAction, + title: "AI 想修改内存".to_string(), + body: "改为 8G".to_string(), + kind: DialogKind::Confirm { + primary: "允许".to_string(), + secondary: "拒绝".to_string(), + }, + })); + state.apply(AppEvent::Toast(Toast { + id: 0, + kind: ToastKind::Success, + text: "下载完成".to_string(), + remaining_ms: 0, + })); + + assert_eq!(state.dialogs.pending_len(), 0); + // id 归生产者:队列原样保留,DialogClosed(42) 才能命中同一条 + assert_eq!(state.dialogs.current().map(|dialog| dialog.id), Some(42)); + assert_eq!(state.toasts.len(), 1); + + state.apply(AppEvent::DialogClosed(42)); + assert!(state.dialogs.is_empty()); +} + +#[test] +fn navigate_to_agent_page_expands_the_ai_panel() { + let mut state = AppState::new(); + state.prefs.ai_collapsed = true; + state.navigate(Route::Agent); + assert_eq!(state.route(), &Route::Agent); + assert_eq!(state.ai_mode(), crate::router::AiPanelMode::Page); + assert!(!state.prefs.ai_collapsed); +} + +#[test] +fn toggling_ai_from_the_page_form_docks_it_again() { + let mut state = AppState::new(); + state.navigate(Route::Agent); + state.toggle_ai(); + assert_eq!(state.route(), &Route::Instances); + assert_eq!(state.ai_mode(), crate::router::AiPanelMode::Docked); + assert!(!state.prefs.ai_collapsed); + + state.toggle_ai(); + assert!(state.prefs.ai_collapsed); +} + +#[test] +fn toggle_nav_flips_the_pref() { + let mut state = AppState::new(); + state.toggle_nav(); + assert!(state.prefs.nav_collapsed); + state.toggle_nav(); + assert!(!state.prefs.nav_collapsed); +} + +#[test] +fn diagnosis_report_lands_on_state() { + let mut state = AppState::new(); + state.apply(AppEvent::Core(CoreEvent::DiagnosisReady(DiagnosisReport { + session: "s1".to_string(), + diagnosis: Diagnosis { + rule: "gpu.driver_old".to_string(), + title: "显卡驱动过旧".to_string(), + confidence: 92, + hint: "升级驱动".to_string(), + fix: Some("open_driver_page".to_string()), + }, + }))); + assert!(state.diagnosis.is_some()); +} diff --git a/crates/app/src/theme_runtime.rs b/crates/app/src/theme_runtime.rs new file mode 100644 index 0000000..851e612 --- /dev/null +++ b/crates/app/src/theme_runtime.rs @@ -0,0 +1,152 @@ +//! 主题与外观的运行期逻辑(从 `ui_bind.rs` 拆出,守住单文件 ≤400 行)。 +//! +//! 全是 `impl Inner` 的方法:配置热切轮询、系统主题对齐、外观令牌下发、背景图/模糊层重建。 +//! 拆出来的边界是「时间驱动的状态维护」——`ui_bind.rs` 只留「建窗口 + 每拍驱动 + 灌属性」。 + +use slint::ComponentHandle; + +use crate::bg_image::{self, BackgroundLayers}; +use crate::conv; +use crate::prefs::{AppearancePrefs, BgFill, ThemeMode}; +use crate::toast::ToastKind; +use crate::ui_bind::{ + BackgroundKey, Inner, CONFIG_POLL_TICKS, SYSTEM_THEME_POLL_TICKS, TILE_REGENERATE_STEP, +}; + +impl Inner { + pub(crate) fn poll_settings(&self) { + if !self.ticks.get().is_multiple_of(CONFIG_POLL_TICKS) { + return; + } + let Some((appearance, theme)) = self.bridge.borrow_mut().reload_if_changed() else { + return; + }; + { + let mut state = self.state.borrow_mut(); + state.prefs.appearance = appearance; + state.prefs.theme = theme; + } + self.apply_theme(); + self.apply_appearance(); + } + + /// 系统主题对齐:启动时先读一次真实系统主题(避免 auto 首帧按浅色画再翻黑); + /// 之后仅在「auto + 后端不认识系统主题」(`Theme.system-theme-known == false`)时兜底轮询。 + pub(crate) fn poll_system_theme(&self) { + let auto = matches!(self.state.borrow().prefs.theme, ThemeMode::Auto); + let backend_known = self.ui.get_system_theme_known(); + let due = !self.system_theme_probed.get() + || (auto && !backend_known && self.ticks.get().is_multiple_of(SYSTEM_THEME_POLL_TICKS)); + if !due { + return; + } + self.system_theme_probed.set(true); + let value = match unlucky_core::platform::theme_probe::probe() { + unlucky_core::platform::theme_probe::SystemTheme::Light => 1, + unlucky_core::platform::theme_probe::SystemTheme::Dark => 2, + unlucky_core::platform::theme_probe::SystemTheme::Unknown => 0, + }; + if self.ui.get_system_theme() != value { + self.ui.set_system_theme(value); + } + } + + /// 启动时的系统主题对齐(首帧前调用一次)。 + pub(crate) fn align_system_theme(&self) { + self.poll_system_theme(); + } + + /// 主题三态 → 根组件属性;`ui/main.slint` 里的 changed 处理器再赋给 `Theme` 全局 + /// (auto 时 Theme 内部读系统的 `Palette.color-scheme` 与本进程探测到的系统主题)。 + pub(crate) fn apply_theme(&self) { + let mode = self.state.borrow().prefs.theme; + self.ui.set_theme_mode(conv::theme_mode(mode)); + } + + /// 外观(高级)四件 → 根组件属性(进而写进 `Theme` 全局)。 + /// + /// 背景图只在「开关打开 + 路径非空 + 换了输入」时才重新解码/生成模糊层。 + pub(crate) fn apply_appearance(&self) { + let appearance = self.state.borrow().prefs.appearance.clone(); + self.ui.set_bg_fill(conv::bg_fill(appearance.bg_fill)); + self.ui.set_bg_dim(appearance.bg_dim.clamp(0.0, 0.9)); + self.ui + .set_panel_alpha(appearance.panel_alpha.clamp(0.4, 1.0)); + self.ui.set_glass(appearance.glass); + self.ui + .set_accent_color(conv::accent_color(&appearance.accent_color)); + self.ui + .set_accent_hex(appearance.accent_color.clone().into()); + self.refresh_background(&appearance); + } + + /// 按需重建背景图 + 模糊层。 + fn refresh_background(&self, appearance: &AppearancePrefs) { + if !appearance.bg_active() { + let had = self.background_key.borrow_mut().take().is_some(); + if had { + self.background.replace(BackgroundLayers::default()); + self.ui.set_background_image(slint::Image::default()); + self.ui.set_background_blur(slint::Image::default()); + } + return; + } + + let key = BackgroundKey { + path: appearance.bg_image.clone(), + fill: appearance.bg_fill, + viewport: self.tile_viewport(appearance.bg_fill), + }; + if self.background_key.borrow().as_ref() == Some(&key) { + return; + } + self.background_key.replace(Some(key.clone())); + + // 模糊层按窗口物理尺寸放大(软件渲染器不做图片缩放插值,见 bg_image::prepare); + // tile 之外的模式窗口缩放不重建画布,模糊层被拉伸也无感。 + let real_viewport = self.window_physical_size(); + match bg_image::prepare(&key.path, key.fill, real_viewport) { + Some(layers) => { + self.ui.set_background_image(layers.image.clone()); + self.ui.set_background_blur(layers.blur.clone()); + self.background.replace(layers); + } + None => { + // 载入失败(路径不存在 / 不是 PNG):清空图层并提示一次,别留上一张图的残影 + self.background.replace(BackgroundLayers::default()); + self.ui.set_background_image(slint::Image::default()); + self.ui.set_background_blur(slint::Image::default()); + self.state.borrow_mut().toasts.push( + ToastKind::Warn, + "背景图载入失败(当前只支持 PNG,且路径需存在)", + ); + } + } + } + + /// tile 画布的窗口尺寸(量化到 [`TILE_REGENERATE_STEP`],避免拖动窗口时反复重算)。 + fn tile_viewport(&self, fill: BgFill) -> (u32, u32) { + if fill != BgFill::Tile { + return (0, 0); + } + let (width, height) = self.window_physical_size(); + ( + width / TILE_REGENERATE_STEP * TILE_REGENERATE_STEP, + height / TILE_REGENERATE_STEP * TILE_REGENERATE_STEP, + ) + } + + /// 窗口物理像素尺寸(取不到时 (0, 0),tile 退化为单块)。 + fn window_physical_size(&self) -> (u32, u32) { + let size = self.ui.window().size(); + (size.width, size.height) + } + + /// 落盘当前偏好(主题 + 外观四件)。 + pub(crate) fn persist_settings(&self) { + let state = self.state.borrow(); + self.bridge + .borrow_mut() + .persist(&state.prefs.appearance, state.prefs.theme); + } +} diff --git a/crates/app/src/toast.rs b/crates/app/src/toast.rs new file mode 100644 index 0000000..5d8ef8e --- /dev/null +++ b/crates/app/src/toast.rs @@ -0,0 +1,152 @@ +//! Toast 中心:右下角非阻塞提示(下载完成 / 复制成功 / 后台任务结论)。 +//! +//! 与模态对话框队列([`crate::dialogs`])分开:Toast 不阻塞、不排序、可叠加, +//! 只做「先进先出 + 上限裁剪 + TTL 到期」。UI 侧由 Rust 定时器每帧调用 [`ToastCenter::expire`]。 + +/// Toast 标识。 +pub type ToastId = u64; + +/// Toast 语义色。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastKind { + /// 一般信息 + Info, + /// 成功 + Success, + /// 警告 + Warn, + /// 错误 + Error, +} + +/// 一条 Toast;`remaining_ms` 由 [`ToastCenter::expire`] 递减。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Toast { + /// 队列内唯一 id(由 [`ToastCenter::push_request`] 重新编号) + pub id: ToastId, + /// 语义色 + pub kind: ToastKind, + /// 提示文案 + pub text: String, + /// 剩余存活毫秒(0 = 用默认 TTL,入队时补) + pub remaining_ms: u64, +} + +/// Toast 中心:先进先出,超出上限丢最旧的。 +#[derive(Debug)] +pub struct ToastCenter { + items: Vec, + next_id: ToastId, + ttl_ms: u64, +} + +impl Default for ToastCenter { + fn default() -> Self { + Self::new() + } +} + +impl ToastCenter { + /// 默认 4s 后自动消失。 + pub fn new() -> Self { + Self { + items: Vec::new(), + next_id: 0, + ttl_ms: 4_000, + } + } + + /// 同时最多显示的条数。 + pub const MAX_VISIBLE: usize = 4; + + /// 入队一条 Toast。 + pub fn push(&mut self, kind: ToastKind, text: impl Into) -> ToastId { + self.push_request(Toast { + id: 0, + kind, + text: text.into(), + remaining_ms: 0, + }) + } + + /// 入队一条已组装好的 Toast(事件通道用);id 重新编号,`remaining_ms == 0` 视为用默认 TTL。 + pub fn push_request(&mut self, mut toast: Toast) -> ToastId { + self.next_id += 1; + toast.id = self.next_id; + if toast.remaining_ms == 0 { + toast.remaining_ms = self.ttl_ms; + } + let id = toast.id; + self.items.push(toast); + while self.items.len() > Self::MAX_VISIBLE { + self.items.remove(0); + } + id + } + + /// 当前显示列表(UI 模型来源)。 + pub fn items(&self) -> &[Toast] { + &self.items + } + + /// 手动关闭(点了关闭按钮)。 + pub fn dismiss(&mut self, id: ToastId) -> bool { + if let Some(at) = self.items.iter().position(|toast| toast.id == id) { + self.items.remove(at); + true + } else { + false + } + } + + /// 时间推进:移除到期 Toast。 + pub fn expire(&mut self, elapsed_ms: u64) { + for toast in &mut self.items { + toast.remaining_ms = toast.remaining_ms.saturating_sub(elapsed_ms); + } + self.items.retain(|toast| toast.remaining_ms > 0); + } + + /// 当前条数。 + pub fn len(&self) -> usize { + self.items.len() + } + + /// 是否没有 Toast。 + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::{ToastCenter, ToastKind}; + + #[test] + fn toasts_expire_and_cap_the_stack() { + let mut center = ToastCenter::new(); + center.push(ToastKind::Info, "a"); + center.push(ToastKind::Info, "b"); + assert_eq!(center.len(), 2); + + for i in 0..10 { + center.push(ToastKind::Success, format!("t{i}")); + } + assert_eq!(center.len(), ToastCenter::MAX_VISIBLE); + + center.expire(3_000); + assert_eq!(center.len(), ToastCenter::MAX_VISIBLE); + center.expire(1_001); + assert!(center.is_empty()); + } + + #[test] + fn dismiss_removes_one_toast() { + let mut center = ToastCenter::new(); + let first = center.push(ToastKind::Info, "a"); + center.push(ToastKind::Warn, "b"); + assert!(center.dismiss(first)); + assert!(!center.dismiss(first)); + assert_eq!(center.len(), 1); + } +} diff --git a/crates/app/src/ui_bind.rs b/crates/app/src/ui_bind.rs new file mode 100644 index 0000000..3352b0c --- /dev/null +++ b/crates/app/src/ui_bind.rs @@ -0,0 +1,333 @@ +//! 绑定层(核心):窗口/状态/后端的持有与同步;回调接线在 [`crate::callbacks`]。 +//! +//! P0 的“后端”是 [`MockBackend`](脚本化假数据);把 `Box` 换成真实的 core/agent +//! host 时,除本文件、`conv.rs`、`callbacks.rs` 外都不需要改 +//! (通道语义见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`)。 +//! +//! 主题与外观(D17 / 09 §101-113):落盘配置由 [`SettingsBridge`] 读写(`config/launcher.json`, +//! 与 `ull config set` 同一份),tick 里 1s 一次 `mtime` 热切;系统主题由 +//! `core::platform::theme_probe` 做启动对齐 + 后端不支持时的兜底轮询。 + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::time::Duration; + +use slint::{ComponentHandle, ModelRc, VecModel}; + +use crate::bg_image::BackgroundLayers; +use crate::conv; +use crate::dto::{AppCommand, Backend, InstanceId}; +use crate::mock::MockBackend; +use crate::prefs::BgFill; +use crate::settings_bridge::SettingsBridge; +use crate::state::AppState; +use crate::{MainWindow, SettingSection}; + +/// 定时器节拍:33ms ≈ 30fps(磁贴进度环 / 流式文本 / Toast TTL 的刷新下限)。 +const TICK_MS: u64 = 33; + +/// 配置热切轮询间隔(拍数):约 1s 一次 `mtime` 比对,AI/CLI 改完下一拍即生效。 +pub(crate) const CONFIG_POLL_TICKS: u64 = 30; + +/// 系统主题兜底轮询间隔(拍数):只在 `auto` 且后端不认识系统主题时启动(约 3s)。 +pub(crate) const SYSTEM_THEME_POLL_TICKS: u64 = 90; + +/// tile 模式重建画布的窗口尺寸粒度(像素):避免拖动窗口时反复重算平铺画布。 +pub(crate) const TILE_REGENERATE_STEP: u32 = 128; + +/// 应用外壳:持有窗口、状态与后端,把三者缝在一起。 +pub struct AppUi { + inner: Rc, + timer: slint::Timer, +} + +pub(crate) struct Inner { + pub(crate) ui: MainWindow, + pub(crate) state: RefCell, + pub(crate) backend: RefCell>, + /// `config/launcher.json` 读写桥(app 不自己写盘:一律走 core::setting 的 schema API) + pub(crate) bridge: RefCell, + /// 当前背景图 / 模糊层(避免每次同步都重新解码) + pub(crate) background: RefCell, + /// 生成 `background` 时的输入(路径 / 填充模式 / 窗口粒度尺寸) + pub(crate) background_key: RefCell>, + /// 拍数(轮询节拍) + pub(crate) ticks: Cell, + /// 是否已经做过启动时的系统主题对齐 + pub(crate) system_theme_probed: Cell, +} + +/// 背景图加工输入(值相同就不重新解码)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BackgroundKey { + pub(crate) path: String, + pub(crate) fill: BgFill, + /// tile 用的窗口尺寸(按 [`TILE_REGENERATE_STEP`] 量化;cover/contain 恒为 (0, 0)) + pub(crate) viewport: (u32, u32), +} + +impl AppUi { + /// 建状态 + 假后端、读配置、接全部回调、灌初始事件并做第一次同步。 + pub fn new(ui: MainWindow) -> Self { + // 先读一次落盘配置:主题三态 + 外观四件必须在首帧前就位(否则会闪一下出厂外观) + let bridge = SettingsBridge::open(); + let (appearance, theme) = bridge.load(); + let mut state = AppState::new(); + state.prefs.appearance = appearance; + state.prefs.theme = theme; + + let inner = Rc::new(Inner { + ui, + state: RefCell::new(state), + backend: RefCell::new(Box::new(MockBackend::new())), + bridge: RefCell::new(bridge), + background: RefCell::new(BackgroundLayers::default()), + background_key: RefCell::new(None), + ticks: Cell::new(0), + system_theme_probed: Cell::new(false), + }); + + crate::callbacks::wire(Rc::downgrade(&inner)); + inner.drain_events(); + inner.align_system_theme(); + inner.apply_theme(); + inner.apply_appearance(); + inner.sync(); + + Self { + inner, + timer: slint::Timer::default(), + } + } + + /// 进入 Slint 事件循环(窗口句柄由本结构持有,直到循环退出)。 + pub fn run(&self) -> Result<(), slint::PlatformError> { + self.inner.ui.run() + } + + /// 启动 33ms 节拍(后端 tick → 事件 → 状态 → UI)。 + pub fn start_timer(&self) { + let inner = Rc::clone(&self.inner); + self.timer.start( + slint::TimerMode::Repeated, + Duration::from_millis(TICK_MS), + move || inner.tick(), + ); + } + + /// 推进一拍(截图通道用:没有事件循环时手动把 tick 走起来,让 mock 事件灌进状态)。 + pub fn tick_once(&self) { + self.inner.tick(); + slint::platform::update_timers_and_animations(); + } + + /// 直接跳到「设置 → 外观」页(截图验收用:证明外观分组的四组控件真的接线了)。 + pub fn open_settings_appearance(&self) { + self.inner + .state + .borrow_mut() + .navigate(crate::router::Route::Settings); + self.inner.sync(); + self.inner + .ui + .set_settings_section(SettingSection::Appearance); + slint::platform::update_timers_and_animations(); + } + + /// 渲染当前场景并存成 PNG(`--screenshot` 通道;见 `crate::png`)。 + /// + /// 为什么要这条通道:验收环境可能没有交互桌面(`CopyFromScreen` 直接失败), + /// 而 Slint 的软件渲染器可以把场景渲染进内存像素缓冲(`Window::take_snapshot`), + /// 结果与屏幕上看到的逐像素一致,且不需要 GPU。 + /// + /// # Errors + /// 窗口显示失败、快照失败或写文件失败时返回错误。 + pub fn render_screenshot(&self, path: &str) -> Result<(), slint::PlatformError> { + let ui = &self.inner.ui; + ui.show()?; + // 固定逻辑尺寸 = main.slint 的默认窗口口径,保证验收图可比 + ui.window() + .set_size(slint::WindowSize::Logical(slint::LogicalSize::new( + 1180.0, 760.0, + ))); + // 先走几拍:把 mock 事件(实例/版本/日志)与 33ms 节拍的效果灌进去 + for _ in 0..4 { + self.tick_once(); + std::thread::sleep(Duration::from_millis(TICK_MS)); + } + let buffer = ui.window().take_snapshot()?; + let bytes = crate::png::encode_rgba8(&buffer); + std::fs::write(path, bytes) + .map_err(|error| slint::PlatformError::Other(format!("写截图失败 {path}: {error}")))?; + Ok(()) + } +} + +/// 发送一条 AI 提问:**只发指令 + 回流事件**,绝不做本地乐观回显。 +/// +/// 契约 §3 规定 host 先发 `MessageAppended(user)`:本地再 push 一次用户消息就会重复; +/// 且流式气泡的收敛依赖事件里的 `MessageFinished{index}`,本地多建的占位会变成 +/// 永不收敛的「幽灵气泡」(review P1-1)。callbacks 与单测都走这一个入口。 +pub(crate) fn send_agent_message(state: &mut AppState, backend: &mut dyn Backend, text: &str) { + backend.dispatch(AppCommand::AskAgent(text.to_string())); + for event in backend.drain_events() { + state.apply(event); + } +} + +impl Inner { + /// 走一次完整的「提问 → 事件回流」。 + pub(crate) fn ask_agent(&self, text: &str) { + let mut state = self.state.borrow_mut(); + let mut backend = self.backend.borrow_mut(); + send_agent_message(&mut state, backend.as_mut(), text); + } + + pub(crate) fn dispatch(&self, command: AppCommand) { + self.backend.borrow_mut().dispatch(command); + self.drain_events(); + } + + pub(crate) fn drain_events(&self) { + let events = self.backend.borrow_mut().drain_events(); + if events.is_empty() { + return; + } + let mut state = self.state.borrow_mut(); + for event in events { + state.apply(event); + } + } + + fn tick(&self) { + self.backend + .borrow_mut() + .tick(Duration::from_millis(TICK_MS)); + self.drain_events(); + self.state.borrow_mut().toasts.expire(TICK_MS); + self.ticks.set(self.ticks.get() + 1); + self.poll_settings(); + self.poll_system_theme(); + self.sync(); + } + + pub(crate) fn sync(&self) { + let state = self.state.borrow(); + let ui = &self.ui; + + ui.set_route(conv::route(state.route())); + ui.set_nav_collapsed(state.prefs.nav_collapsed); + ui.set_ai_collapsed(state.prefs.ai_collapsed); + ui.set_tile_size(conv::tile_size(state.prefs.tile_size)); + ui.set_instance_view(conv::instance_view(state.prefs.instance_view)); + ui.set_download_category(conv::category_from(state.prefs.download_category)); + ui.set_download_installed_only(state.installed_only); + ui.set_theme_mode(conv::theme_mode(state.prefs.theme)); + + ui.set_tiles(model(conv::tiles(&state))); + ui.set_running_tiles(model(conv::running_tiles(&state))); + ui.set_versions(model(conv::versions(&state))); + ui.set_log_sessions(model(conv::log_sessions(&state))); + ui.set_log_lines(model(conv::log_lines(&state))); + ui.set_toasts(model(conv::toasts(&state))); + + let (diagnosis, has_diagnosis) = conv::diagnosis(&state); + ui.set_diagnosis(diagnosis); + ui.set_has_diagnosis(has_diagnosis); + ui.set_selected_session(state.selected_session.clone().unwrap_or_default().into()); + + ui.set_ai_messages(model(conv::chat(&state))); + ui.set_ai_streaming(state.ai.streaming); + ui.set_yolo(state.ai.yolo); + ui.set_ai_endpoint_label(state.ai.endpoint_label.clone().into()); + + let (dialog, open, queued) = conv::dialog(&state); + ui.set_dialog(dialog); + ui.set_dialog_open(open); + ui.set_dialog_queued(queued); + } +} + +pub(crate) fn model(items: Vec) -> ModelRc { + 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, + "一轮对话 = 流式回复 + 收尾回复" + ); + } +} diff --git a/crates/app/ui/components/ai-panel.slint b/crates/app/ui/components/ai-panel.slint new file mode 100644 index 0000000..a9982f1 --- /dev/null +++ b/crates/app/ui/components/ai-panel.slint @@ -0,0 +1,283 @@ +// 右侧 AI 常驻栏(D16 §1):默认 320px 可折叠;可「展开为页面」变成全宽独立页, +// 两形态共享同一份会话模型(由 Rust 侧 AiSession 持有,这里只渲染,切换不丢状态)。 +// 栏顶承载 YOLO 快捷开关(D4 补充)与端点标签。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiChatMessage, ChatRole } from "../models.slint"; +import { UiText, TextRole } from "label.slint"; +import { AppIcon, IconKind } from "icons.slint"; +import { MetroButton, ButtonKind } from "../widgets/button.slint"; +import { PlainInput } from "../widgets/inputs.slint"; +import { GlassBase } from "glass.slint"; +import { ListView } from "std-widgets.slint"; + +// 一条会话消息:user 靠右(accent-soft 块)/ assistant 靠左 / tool 用状态边框 / system 居中弱化 +component ChatBubble inherits Rectangle { + in property message; + + property is-tool: message.role == ChatRole.tool; + property is-system: message.role == ChatRole.system; + property text-width: root.width - 2 * Metrics.gap-2; + property tool-row: is-tool ? 16px : 0px; + + height: text-node.preferred-height + 2 * Metrics.gap-2 + tool-row; + background: message.role == ChatRole.user + ? Theme.accent-soft + : is-tool ? Theme.bg-canvas + : is-system ? transparent + : Theme.bg-elevated; + border-radius: Metrics.radius-md; + border-width: is-tool ? 1px : 0px; + border-color: message.tool_ok ? Theme.state-success : Theme.state-danger; + + // 工具调用可视化(D4 要求:工具执行在会话里可见) + if is-tool : AppIcon { + x: Metrics.gap-2; + y: Metrics.gap-2 + 1px; + icon-size: 12px; + kind: message.tool_ok ? IconKind.check : IconKind.error; + tint: message.tool_ok ? Theme.state-success : Theme.state-danger; + } + if is-tool : UiText { + x: Metrics.gap-2 + 16px; + y: Metrics.gap-2; + width: text-width - 16px; + height: 14px; + role: TextRole.caption; + text: "工具 · " + message.tool_name; + color: message.tool_ok ? Theme.state-success : Theme.state-danger; + } + + text-node := UiText { + x: Metrics.gap-2; + y: Metrics.gap-2 + tool-row; + width: text-width; + height: text-node.preferred-height; + role: TextRole.body; + text: message.text; + wrap: word-wrap; + overflow: elide; + vertical-alignment: top; + horizontal-alignment: message.role == ChatRole.user ? right : left; + color: is-system ? Theme.fg-secondary : Theme.fg-primary; + } + + // 流式未收敛:末尾的 accent 光标块 + if message.streaming : Rectangle { + x: 0; + y: root.height - 10px; + width: 8px; + height: 10px; + background: Theme.accent; + } +} + +export component AiPanel inherits Rectangle { + in property <[UiChatMessage]> messages; + in-out property collapsed: false; + /// true = 停在右侧栏形态;false = 已拆成独立全宽页 + in property docked: true; + in property yolo: false; + in property streaming: false; + in property endpoint-label: "llama.cpp · 本地"; + in-out property draft: ""; + + callback toggle-collapsed(); + callback expand(); + callback dock(); + callback toggle-yolo(); + callback send(string); + callback stop(); + + // 栏形态用 min/max 钉死宽度(布局里等价于固定宽度),页形态交给父容器铺满 + min-width: docked ? (collapsed ? Metrics.ai-rail-width : Metrics.ai-panel-width) : 0px; + max-width: docked ? (collapsed ? Metrics.ai-rail-width : Metrics.ai-panel-width) : 100000px; + horizontal-stretch: docked ? 0 : 1; + height: 100%; + background: Theme.bg-surface; + + // 玻璃拟态底(D17 高级特性;关闭态不画东西)——排在内容之前,压在最底层 + GlassBase { card-radius: 0px; } + + // 左边界线 + Rectangle { + x: 0; + y: 0; + width: 1px; + height: root.height; + background: Theme.stroke; + } + + // ── 折叠态:icon 轨 ───────────────────────────────────────── + if docked && collapsed : VerticalLayout { + x: 1px; + width: root.width - 1px; + height: root.height; + padding-top: Metrics.gap-2; + padding-bottom: Metrics.gap-2; + spacing: Metrics.gap-2; + + AppIcon { + kind: IconKind.ai; + icon-size: 18px; + tint: Theme.accent; + + TouchArea { + mouse-cursor: pointer; + clicked => { root.toggle-collapsed(); } + } + } + + AppIcon { + kind: IconKind.expand; + icon-size: 14px; + tint: Theme.fg-secondary; + + TouchArea { + mouse-cursor: pointer; + clicked => { root.expand(); } + } + } + + Rectangle { vertical-stretch: 1; background: transparent; } + + // YOLO 指示点:开启时实心 accent + Rectangle { + width: 6px; + height: 6px; + border-radius: 3px; + background: root.yolo ? Theme.accent : Theme.stroke-strong; + } + } + + // ── 展开态:栏内 / 独立页共用 ──────────────────────────────── + if !(docked && collapsed) : VerticalLayout { + x: 1px; + width: root.width - 1px; + height: root.height; + + // 头部 + Rectangle { + height: 52px; + background: transparent; + + HorizontalLayout { + padding-left: Metrics.gap-3; + padding-right: Metrics.gap-2; + spacing: Metrics.gap-2; + + AppIcon { kind: IconKind.ai; icon-size: 16px; tint: Theme.accent; } + + VerticalLayout { + spacing: 0px; + vertical-stretch: 1; + + UiText { + role: TextRole.bodyStrong; + text: "AI 助手"; + horizontal-stretch: 1; + } + UiText { + role: TextRole.caption; + text: root.endpoint-label; + horizontal-stretch: 1; + } + } + + MetroButton { + kind: root.yolo ? ButtonKind.accent : ButtonKind.subtle; + text: "YOLO"; + button-height: 24px; + clicked => { root.toggle-yolo(); } + } + + if root.docked : MetroButton { + kind: ButtonKind.subtle; + icon: IconKind.expand; + icon-only: true; + button-height: 24px; + clicked => { root.expand(); } + } + if !root.docked : MetroButton { + kind: ButtonKind.subtle; + icon: IconKind.sidebar; + icon-only: true; + button-height: 24px; + clicked => { root.dock(); } + } + if root.docked : MetroButton { + kind: ButtonKind.subtle; + icon: IconKind.chevronRight; + icon-only: true; + button-height: 24px; + clicked => { root.toggle-collapsed(); } + } + } + } + + Rectangle { height: 1px; background: Theme.stroke; } + + // 对话流(ListView 行虚拟化,research/01 已确认) + ListView { + vertical-stretch: 1; + + for m in root.messages : ChatBubble { + message: m; + width: root.width - 2 * Metrics.gap-3; + } + } + + if root.messages.length == 0 : UiText { + role: TextRole.caption; + text: "问点什么:装哪个版本、崩溃怎么修、帮我改设置…"; + horizontal-alignment: center; + height: 40px; + } + + Rectangle { height: 1px; background: Theme.stroke; } + + // 输入区 + Rectangle { + height: 56px; + background: transparent; + + HorizontalLayout { + padding: Metrics.gap-2; + spacing: Metrics.gap-2; + + input-box := Rectangle { + horizontal-stretch: 1; + vertical-stretch: 1; + background: Theme.bg-input; + border-radius: Metrics.radius-sm; + border-width: 1px; + border-color: input-field.focused ? Theme.accent : Theme.stroke; + + input-field := PlainInput { + text-padding: Metrics.gap-2; + placeholder: "输入消息,Enter 发送"; + text <=> root.draft; + accepted(t) => { + if (t != "") { + root.send(t); + } + } + } + } + + MetroButton { + kind: ButtonKind.accent; + icon: root.streaming ? IconKind.stop : IconKind.send; + icon-only: true; + clicked => { + if (root.streaming) { + root.stop(); + } else if (root.draft != "") { + root.send(root.draft); + } + } + } + } + } + } +} diff --git a/crates/app/ui/components/badge.slint b/crates/app/ui/components/badge.slint new file mode 100644 index 0000000..4d68f57 --- /dev/null +++ b/crates/app/ui/components/badge.slint @@ -0,0 +1,27 @@ +// 徽标:MC 版本 / 加载器 / 已装 / 远古版 等短标记。Metro 做法是细边框小方块,不用药丸圆角。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiText, TextRole } from "../components/label.slint"; + +export component Badge inherits Rectangle { + in property text: ""; + in property tint: Theme.fg-secondary; + in property fill: transparent; + + height: 18px; + width: label.preferred-width + 2 * Metrics.gap-1 + 2px; + background: fill; + border-width: 1px; + border-color: tint; + border-radius: Metrics.radius-sm; + + label := UiText { + role: TextRole.caption; + text: root.text; + color: root.tint; + font-size: 11px; + horizontal-alignment: center; + x: Metrics.gap-1 + 1px; + width: label.preferred-width; + } +} diff --git a/crates/app/ui/components/glass.slint b/crates/app/ui/components/glass.slint new file mode 100644 index 0000000..79fc043 --- /dev/null +++ b/crates/app/ui/components/glass.slint @@ -0,0 +1,59 @@ +// 玻璃拟态卡片的公共底座(09 §103:Slint 没有 backdrop-filter,用「预生成模糊背景层」替代)。 +// +// 手法:Rust 侧把背景图降采样成一张极小的图(`Theme.bg-blur`),渲染时被平滑放大 → 天然模糊。 +// 卡片里把它按 `absolute-position` **反向偏移**画出来,于是卡片显示的是「自己这一块屏幕区域的 +// 模糊影像」,视觉上就是毛玻璃;再叠一层半透明面板色保证文字可读(对比度优先)。 +// +// 关闭态(`Theme.glass == false` 或无背景图)不画任何东西:卡片保持 Metro 纯色观感。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; + +export component GlassBase inherits Rectangle { + /// 卡片圆角(跟随宿主卡片) + in property card-radius: Metrics.radius-md; + /// 叠在模糊层上的半透明面板色(保证前景对比度) + in property tint: Theme.surface-solid; + + out property active: Theme.glass && Theme.bg-blur.width > 0 && Theme.bg-image.width > 0; + + x: 0; + y: 0; + width: 100%; + height: 100%; + border-radius: root.card-radius; + clip: true; + background: transparent; + + if root.active : Image { + // 整窗模糊层 + 反向偏移 = 只露出「卡片所在那一块」。 + // 必须用 `parent`(本组件自身)的绝对位置:用 `self` 会与自身 x 形成绑定环 + // (absolute-position 依赖 x,x 又依赖 absolute-position)。 + source: Theme.bg-blur; + x: -parent.absolute-position.x; + y: -parent.absolute-position.y; + width: Theme.window-width; + height: Theme.window-height; + image-fit: cover; + } + + if root.active : Rectangle { + // 暗化层要**重画一次**:模糊层在卡片内部、盖在窗口的暗化层之上, + // 不补这一层的话玻璃卡片会比周围亮一大截(05 截图验收时的真实问题)。 + background: rgba(0, 0, 0, Theme.bg-dim); + border-radius: root.card-radius; + } + + if root.active : Rectangle { + // 玻璃染色:面板底色 55% 不透明,字压得住背景(对比度优先于「更透」) + background: root.tint.with-alpha(0.55); + border-radius: root.card-radius; + } + + if root.active : Rectangle { + // 上缘一道细高光,玻璃的「厚度感」 + y: 0; + height: 1px; + width: root.width; + background: Theme.stroke.with-alpha(0.35); + } +} diff --git a/crates/app/ui/components/icons.slint b/crates/app/ui/components/icons.slint new file mode 100644 index 0000000..c1a6e61 --- /dev/null +++ b/crates/app/ui/components/icons.slint @@ -0,0 +1,192 @@ +// 图标:18×18 viewbox 的矢量绘制(Metro 要求线性几何图标,不用 emoji/位图)。 +// 只用 M/L/Q/Z 命令 + 直线,避免依赖字体字形;描边图标 stroke,实心图标 fill。 +import { Theme } from "../theme/tokens.slint"; + +export enum IconKind { + instances, + download, + logs, + settings, + ai, + play, + stop, + search, + plus, + close, + minimize, + maximize, + chevronLeft, + chevronRight, + chevronDown, + expand, + send, + check, + warn, + error, + more, + sidebar, + restore, +} + +export component AppIcon inherits Rectangle { + in property kind: IconKind.instances; + in property tint: Theme.fg-secondary; + in property icon-size: 18px; + + width: icon-size; + height: icon-size; + background: transparent; + + if kind == IconKind.instances : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 2 2 L 8 2 L 8 8 L 2 8 Z M 10 2 L 16 2 L 16 8 L 10 8 Z M 2 10 L 8 10 L 8 16 L 2 16 Z M 10 10 L 16 10 L 16 16 L 10 16 Z"; + fill: transparent; stroke: root.tint; stroke-width: 1.4px; + } + if kind == IconKind.download : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 9 2 L 9 11 M 4 7 L 9 12 L 14 7 M 3 15 L 15 15"; + fill: transparent; stroke: root.tint; stroke-width: 1.6px; + } + if kind == IconKind.logs : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 3 4 L 15 4 M 3 9 L 15 9 M 3 14 L 11 14"; + fill: transparent; stroke: root.tint; stroke-width: 1.6px; + } + if kind == IconKind.settings : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 3 5 L 15 5 M 3 9 L 15 9 M 3 13 L 15 13"; + fill: transparent; stroke: root.tint; stroke-width: 1.4px; + } + if kind == IconKind.settings : Rectangle { + x: 5px; + width: 5px; height: 5px; y: 3px; + border-radius: 2.5px; background: root.tint; + } + if kind == IconKind.settings : Rectangle { + x: 10px; y: 7px; width: 5px; height: 5px; + border-radius: 2.5px; background: root.tint; + } + if kind == IconKind.settings : Rectangle { + x: 3px; y: 11px; width: 5px; height: 5px; + border-radius: 2.5px; background: root.tint; + } + if kind == IconKind.ai : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 9 1.5 L 10.7 7.3 L 16.5 9 L 10.7 10.7 L 9 16.5 L 7.3 10.7 L 1.5 9 L 7.3 7.3 Z"; + fill: root.tint; stroke: transparent; stroke-width: 1px; + } + if kind == IconKind.play : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 5 3 L 15 9 L 5 15 Z"; + fill: root.tint; stroke: transparent; stroke-width: 1px; + } + if kind == IconKind.stop : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 5 5 L 13 5 L 13 13 L 5 13 Z"; + fill: root.tint; stroke: transparent; stroke-width: 1px; + } + if kind == IconKind.search : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 8 3 Q 13 3 13 8 Q 13 13 8 13 Q 3 13 3 8 Q 3 3 8 3 Z M 11.5 11.5 L 16 16"; + fill: transparent; stroke: root.tint; stroke-width: 1.5px; + } + if kind == IconKind.plus : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 9 3 L 9 15 M 3 9 L 15 9"; + fill: transparent; stroke: root.tint; stroke-width: 1.8px; + } + if kind == IconKind.close : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 4 4 L 14 14 M 14 4 L 4 14"; + fill: transparent; stroke: root.tint; stroke-width: 1.4px; + } + if kind == IconKind.minimize : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 4 9 L 14 9"; + fill: transparent; stroke: root.tint; stroke-width: 1.4px; + } + if kind == IconKind.maximize : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 4.5 4.5 L 13.5 4.5 L 13.5 13.5 L 4.5 13.5 Z"; + fill: transparent; stroke: root.tint; stroke-width: 1.2px; + } + if kind == IconKind.chevronLeft : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 11 3 L 6 9 L 11 15"; + fill: transparent; stroke: root.tint; stroke-width: 1.6px; + } + if kind == IconKind.chevronRight : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 7 3 L 12 9 L 7 15"; + fill: transparent; stroke: root.tint; stroke-width: 1.6px; + } + if kind == IconKind.chevronDown : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 3 7 L 9 13 L 15 7"; + fill: transparent; stroke: root.tint; stroke-width: 1.6px; + } + if kind == IconKind.expand : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 3 7 L 3 3 L 7 3 M 11 3 L 15 3 L 15 7 M 15 11 L 15 15 L 11 15 M 7 15 L 3 15 L 3 11"; + fill: transparent; stroke: root.tint; stroke-width: 1.4px; + } + if kind == IconKind.send : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 2 9 L 16 9 M 11 4 L 16 9 L 11 14"; + fill: transparent; stroke: root.tint; stroke-width: 1.6px; + } + if kind == IconKind.check : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 3 9 L 7 13 L 15 4"; + fill: transparent; stroke: root.tint; stroke-width: 1.8px; + } + if kind == IconKind.warn : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 9 2 L 16 15 L 2 15 Z M 9 6.5 L 9 10.5"; + fill: transparent; stroke: root.tint; stroke-width: 1.4px; + } + if kind == IconKind.error : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 9 2 Q 16 2 16 9 Q 16 16 9 16 Q 2 16 2 9 Q 2 2 9 2 Z M 9 5 L 9 10"; + fill: transparent; stroke: root.tint; stroke-width: 1.5px; + } + if kind == IconKind.more : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 4 7 L 6 7 L 6 9 L 4 9 Z M 8 7 L 10 7 L 10 9 L 8 9 Z M 12 7 L 14 7 L 14 9 L 12 9 Z"; + fill: root.tint; stroke: transparent; stroke-width: 1px; + } + if kind == IconKind.sidebar : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 3 4 L 15 4 L 15 14 L 3 14 Z M 7 4 L 7 14"; + fill: transparent; stroke: root.tint; stroke-width: 1.4px; + } + // 还原(已最大化时的标题栏按钮):两层叠放方框 + if kind == IconKind.restore : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 6 3 L 15 3 L 15 12 M 3 6 L 12 6 L 12 15 L 3 15 Z"; + fill: transparent; stroke: root.tint; stroke-width: 1.2px; + } +} diff --git a/crates/app/ui/components/label.slint b/crates/app/ui/components/label.slint new file mode 100644 index 0000000..0dd42ae --- /dev/null +++ b/crates/app/ui/components/label.slint @@ -0,0 +1,34 @@ +// 排版角色封装:所有文字都走 UiText,不在页面里散落 font-size/font-family 字面量。 +// 角色 → 字号/字重/默认前景色由 tokens.slint 的 ramp 决定;外部赋值可覆盖(例如强调色标题)。 +import { Theme } from "../theme/tokens.slint"; + +export enum TextRole { + display, + title, + subtitle, + body, + bodyStrong, + caption, + mono, + monoSmall, +} + +export component UiText inherits Text { + in property role: TextRole.body; + + color: role == TextRole.caption ? Theme.fg-secondary : Theme.fg-primary; + font-family: (role == TextRole.mono || role == TextRole.monoSmall) + ? Theme.font-family-mono + : Theme.font-family; + font-size: (role == TextRole.display) ? Theme.font-size-display + : (role == TextRole.title) ? Theme.font-size-title + : (role == TextRole.subtitle) ? Theme.font-size-subtitle + : (role == TextRole.caption || role == TextRole.monoSmall) ? Theme.font-size-caption + : Theme.font-size-body; + font-weight: (role == TextRole.bodyStrong || role == TextRole.title || role == TextRole.display) + ? 600 + : 400; + vertical-alignment: center; + wrap: no-wrap; + overflow: elide; +} diff --git a/crates/app/ui/components/nav.slint b/crates/app/ui/components/nav.slint new file mode 100644 index 0000000..739f308 --- /dev/null +++ b/crates/app/ui/components/nav.slint @@ -0,0 +1,182 @@ +// 左侧主导航(D16 §1):图标+文字,选中项 accent 色块;可折叠成纯图标窄条(偏好记忆)。 +// 折叠宽度/展开宽度取 Metrics,动画时长取 Theme.motion-normal。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { RouteKind } from "../models.slint"; +import { UiText, TextRole } from "label.slint"; +import { AppIcon, IconKind } from "icons.slint"; + +component NavItem inherits Rectangle { + in property icon: IconKind.instances; + in property label: ""; + in property active: false; + in property collapsed: false; + callback clicked(); + + height: root.collapsed ? 48px : 64px; + background: root.active + ? Theme.accent-soft + : touch.has-hover ? Theme.bg-hover : transparent; + animate background { duration: Theme.motion-fast; } + + // 选中态:左侧 accent 竖条(Metro 侧栏语义) + Rectangle { + visible: root.active; + x: 0; + y: 0; + width: 3px; + height: root.height; + background: Theme.accent; + } + + AppIcon { + kind: root.icon; + icon-size: 20px; + x: (root.width - self.width) / 2; + y: root.collapsed ? (root.height - self.height) / 2 : 12px; + tint: root.active ? Theme.accent : Theme.fg-secondary; + } + + UiText { + visible: !root.collapsed; + role: TextRole.caption; + text: root.label; + color: root.active ? Theme.accent : Theme.fg-secondary; + horizontal-alignment: center; + x: 0; + y: 38px; + width: root.width; + } + + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } + + accessible-role: button; + accessible-label: root.label; +} + +export component Nav inherits Rectangle { + in property active: RouteKind.instances; + in-out property collapsed: false; + in property account-name: "未登录"; + in property account-hue: #5C2D91; + in property account-initial: "?"; + callback navigate(RouteKind); + callback toggle-collapsed(); + callback account-clicked(); + + width: collapsed ? Metrics.nav-collapsed-width : Metrics.nav-expanded-width; + height: 100%; + background: Theme.bg-surface; + animate width { duration: Theme.motion-normal; easing: ease-in-out; } + + // 右边界线 + Rectangle { + x: root.width - 1px; + y: 0; + width: 1px; + height: root.height; + background: Theme.stroke; + } + + VerticalLayout { + x: 0; + y: Metrics.gap-2; + width: root.width; + height: root.height - Metrics.gap-2 - Metrics.gap-2; + + NavItem { + icon: IconKind.instances; + label: "实例"; + active: root.active == RouteKind.instances || root.active == RouteKind.detail; + collapsed: root.collapsed; + clicked => { root.navigate(RouteKind.instances); } + } + NavItem { + icon: IconKind.download; + label: "下载"; + active: root.active == RouteKind.download; + collapsed: root.collapsed; + clicked => { root.navigate(RouteKind.download); } + } + NavItem { + icon: IconKind.logs; + label: "日志"; + active: root.active == RouteKind.logs; + collapsed: root.collapsed; + clicked => { root.navigate(RouteKind.logs); } + } + NavItem { + icon: IconKind.settings; + label: "设置"; + active: root.active == RouteKind.settings; + collapsed: root.collapsed; + clicked => { root.navigate(RouteKind.settings); } + } + + Rectangle { vertical-stretch: 1; background: transparent; } + + // 底部:账户头像(点击进设置·账户) + Rectangle { + height: 56px; + background: account-touch.has-hover ? Theme.bg-hover : transparent; + + Rectangle { + width: 28px; + height: 28px; + border-radius: 14px; + background: root.account-hue; + x: (root.width - self.width) / 2; + y: 8px; + + UiText { + text: root.account-initial; + font-size: 12px; + font-weight: 600; + color: Theme.fg-on-accent; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + UiText { + visible: !root.collapsed; + role: TextRole.caption; + text: root.account-name; + horizontal-alignment: center; + x: 0; + y: 38px; + width: root.width; + } + + account-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.account-clicked(); } + } + } + + // 折叠开关(偏好由 Rust 侧持久化到 ui.nav.collapsed) + Rectangle { + height: 40px; + background: toggle-touch.has-hover ? Theme.bg-hover : transparent; + + AppIcon { + kind: root.collapsed ? IconKind.chevronRight : IconKind.chevronLeft; + icon-size: 16px; + x: (root.width - self.width) / 2; + y: (root.height - self.height) / 2; + tint: Theme.fg-secondary; + } + + toggle-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.toggle-collapsed(); } + } + + accessible-role: button; + accessible-label: root.collapsed ? "展开导航栏" : "折叠导航栏"; + } + } +} diff --git a/crates/app/ui/components/progress-ring.slint b/crates/app/ui/components/progress-ring.slint new file mode 100644 index 0000000..4bb82d4 --- /dev/null +++ b/crates/app/ui/components/progress-ring.slint @@ -0,0 +1,22 @@ +// 启动进度环:磁贴「启动中」状态用(下载/校验/启动三个阶段共用一个环)。 +// 用 std-widgets 的 ProgressIndicator:progress <= 0 时转为不确定态(转圈)。 +// P1 若要完全贴合 Metro 强调色,需自绘弧线(Slint 无 arc 命令,得用旋转遮罩法)。 +import { Theme } from "../theme/tokens.slint"; +import { ProgressIndicator } from "std-widgets.slint"; + +export component ProgressRing inherits Rectangle { + /// 0.0~1.0;<= 0 表示未知进度 + in property progress: 0; + in property ring-size: 28px; + + width: ring-size; + height: ring-size; + background: transparent; + + ProgressIndicator { + width: root.ring-size; + height: root.ring-size; + progress: root.progress; + indeterminate: root.progress <= 0; + } +} diff --git a/crates/app/ui/components/tile.slint b/crates/app/ui/components/tile.slint new file mode 100644 index 0000000..16e455c --- /dev/null +++ b/crates/app/ui/components/tile.slint @@ -0,0 +1,198 @@ +// 实例磁贴(G1):图标 / 名字 / MC 版本徽标 / 加载器徽标 / 最后游玩 / 大启动键 + 设置键。 +// 三档尺寸由 Metrics.tile-* 决定(小磁贴藏徽标与时间,只留名字+启动); +// 启动中把底部按钮行替换成「进度环 + 阶段文案 + 停止」。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiTile, RuntimePhase, LoaderKind } from "../models.slint"; +import { UiText, TextRole } from "label.slint"; +import { AppIcon, IconKind } from "icons.slint"; +import { Badge } from "badge.slint"; +import { ProgressRing } from "progress-ring.slint"; +import { GlassBase } from "glass.slint"; +import { MetroButton, ButtonKind } from "../widgets/button.slint"; + +component TileIcon inherits Rectangle { + in property data; + in property box-size: 44px; + + width: box-size; + height: box-size; + border-radius: Metrics.radius-md; + background: root.data.hue; + + if root.data.icon.width > 0 : Image { + x: 0; + y: 0; + width: root.width; + height: root.height; + source: root.data.icon; + image-fit: cover; + } + if root.data.icon.width <= 0 : UiText { + text: root.data.initial; + font-size: 18px; + font-weight: 600; + color: Theme.fg-on-accent; + horizontal-alignment: center; + vertical-alignment: center; + } +} + +export component InstanceTile inherits Rectangle { + in property data; + in property compact: false; + callback open(); + callback launch(); + callback configure(); + /// 启动中/运行中的停止键(dispatch StopInstance) + callback stop(); + + height: Metrics.tile-height; + background: touch.has-hover ? Theme.bg-elevated : Theme.bg-surface; + border-radius: Metrics.radius-md; + border-width: 1px; + border-color: root.data.phase == RuntimePhase.running ? Theme.accent : Theme.stroke; + drop-shadow-blur: touch.has-hover ? 12px : 0px; + drop-shadow-color: Theme.shadow; + drop-shadow-offset-y: 2px; + animate background { duration: Theme.motion-fast; } + animate drop-shadow-blur { duration: Theme.motion-normal; } + + // 玻璃拟态底(关闭态什么都不画,卡片就是 Metro 纯色);必须排在最前,否则盖住 accent 细线 + GlassBase { + card-radius: root.border-radius; + } + + // 运行中:顶部 accent 细线(Metro 色块语义) + Rectangle { + visible: root.data.phase == RuntimePhase.running; + x: 0; + y: 0; + width: root.width; + height: 2px; + background: Theme.accent; + } + + // 磁贴主体点击 → 实例详情(G4);按钮的 TouchArea 后声明,命中优先 + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.open(); } + } + + VerticalLayout { + padding: Metrics.gap-3; + spacing: Metrics.gap-2; + + HorizontalLayout { + spacing: Metrics.gap-2; + + TileIcon { + data: root.data; + box-size: Metrics.tile-icon-size; + } + + VerticalLayout { + spacing: 2px; + vertical-stretch: 1; + + UiText { + role: TextRole.bodyStrong; + text: root.data.name; + horizontal-stretch: 1; + } + + UiText { + role: TextRole.caption; + text: root.data.last_played; + visible: !root.compact; + } + } + } + + HorizontalLayout { + visible: !root.compact; + spacing: Metrics.gap-1; + + Badge { + text: root.data.mc_version; + tint: Theme.fg-secondary; + } + Badge { + text: root.data.loader_label; + tint: root.data.loader == LoaderKind.vanilla ? Theme.fg-secondary : Theme.accent; + } + } + + Rectangle { vertical-stretch: 1; background: transparent; } + + // 空闲/失败:大启动键 + 设置键 + HorizontalLayout { + visible: root.data.phase == RuntimePhase.idle || root.data.phase == RuntimePhase.failed; + spacing: Metrics.gap-2; + + MetroButton { + kind: ButtonKind.accent; + text: root.data.phase == RuntimePhase.failed ? "重试" : "启动"; + show-icon: true; + icon: IconKind.play; + grow: true; + clicked => { root.launch(); } + } + MetroButton { + kind: ButtonKind.subtle; + icon: IconKind.settings; + icon-only: true; + clicked => { root.configure(); } + } + } + + // 启动中:进度环 + 阶段文案 + 停止 + HorizontalLayout { + visible: root.data.phase == RuntimePhase.downloading + || root.data.phase == RuntimePhase.verifying + || root.data.phase == RuntimePhase.launching; + spacing: Metrics.gap-2; + + ProgressRing { + progress: root.data.progress; + ring-size: 22px; + } + UiText { + role: TextRole.caption; + text: root.data.phase_label; + horizontal-stretch: 1; + } + MetroButton { + kind: ButtonKind.subtle; + icon: IconKind.stop; + icon-only: true; + clicked => { root.stop(); } + } + } + + // 运行中:状态点 + 停止 + HorizontalLayout { + visible: root.data.phase == RuntimePhase.running; + spacing: Metrics.gap-2; + + Rectangle { + width: 8px; + height: 8px; + border-radius: 4px; + background: Theme.state-success; + } + UiText { + role: TextRole.caption; + text: "运行中"; + color: Theme.state-success; + horizontal-stretch: 1; + } + MetroButton { + kind: ButtonKind.subtle; + icon: IconKind.stop; + icon-only: true; + clicked => { root.stop(); } + } + } + } +} diff --git a/crates/app/ui/components/titlebar.slint b/crates/app/ui/components/titlebar.slint new file mode 100644 index 0000000..6fc46e2 --- /dev/null +++ b/crates/app/ui/components/titlebar.slint @@ -0,0 +1,126 @@ +// 自绘无边框标题栏(D16 §1):Metro 一体感 + accent 顶线。 +// 拖拽/最大化/最小化/关闭只发回调,原生窗口操作由 main.rs 接到 Slint Window 平台上 +// (Slint DSL 内无法移动原生窗口)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiText, TextRole } from "label.slint"; +import { AppIcon, IconKind } from "icons.slint"; + +component WindowButton inherits Rectangle { + in property icon: IconKind.minimize; + /// 关闭键:hover 用 Metro 红底白字 + in property danger: false; + callback clicked(); + + width: Metrics.titlebar-button-width; + height: Metrics.titlebar-height; + background: touch.pressed + ? (danger ? Theme.state-danger-hover : Theme.bg-pressed) + : touch.has-hover ? (danger ? Theme.state-danger : Theme.bg-hover) : transparent; + animate background { duration: Theme.motion-fast; } + + AppIcon { + kind: root.icon; + icon-size: 12px; + x: (root.width - self.width) / 2; + y: (root.height - self.height) / 2; + tint: root.danger && touch.has-hover ? Theme.fg-on-accent : Theme.fg-primary; + } + + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +export component TitleBar inherits Rectangle { + in property title: "Unlucky Launcher"; + in property subtitle: ""; + in property maximized: false; + + callback toggle-maximize(); + callback minimize(); + callback request-close(); + + height: Metrics.titlebar-height; + background: Theme.bg-titlebar; + + // accent 顶线(Metro 强调色贯穿) + Rectangle { + x: 0; + y: 0; + width: root.width; + height: Metrics.accent-line; + background: Theme.accent; + } + + // 拖拽区:交给原生 `WindowMoveArea`(Slint 1.18 内置,拖过阈值才移动窗口, + // 并保持子元素可交互)。只覆盖左侧,右侧三个窗口按钮留出来。 + // 平台不支持「系统级窗口移动」时该元素退化为无操作(Qt/部分 Wayland 组合), + // winit on Windows/macOS/X11 可用——Win7 落在这条路上。 + WindowMoveArea { + x: 0; + y: Metrics.accent-line; + width: root.width - 3 * Metrics.titlebar-button-width; + height: root.height - Metrics.accent-line; + } + + // 应用标记 + 标题 + HorizontalLayout { + x: Metrics.gap-3; + y: Metrics.accent-line; + width: root.width - 3 * Metrics.titlebar-button-width - 2 * Metrics.gap-3; + height: root.height - Metrics.accent-line; + spacing: Metrics.gap-2; + + Rectangle { + width: 14px; + height: 14px; + border-radius: Metrics.radius-sm; + background: Theme.accent; + UiText { + text: "U"; + font-size: 10px; + font-weight: 700; + color: Theme.fg-on-accent; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + UiText { + role: TextRole.caption; + text: root.title; + font-weight: 600; + horizontal-stretch: 1; + } + + UiText { + role: TextRole.caption; + text: root.subtitle; + color: Theme.fg-secondary; + } + } + + HorizontalLayout { + x: root.width - 3 * Metrics.titlebar-button-width; + y: Metrics.accent-line; + width: 3 * Metrics.titlebar-button-width; + height: root.height - Metrics.accent-line; + spacing: 0px; + + WindowButton { + icon: IconKind.minimize; + clicked => { root.minimize(); } + } + WindowButton { + icon: root.maximized ? IconKind.restore : IconKind.maximize; + clicked => { root.toggle-maximize(); } + } + WindowButton { + icon: IconKind.close; + danger: true; + clicked => { root.request-close(); } + } + } +} diff --git a/crates/app/ui/main.slint b/crates/app/ui/main.slint new file mode 100644 index 0000000..7e2e84a --- /dev/null +++ b/crates/app/ui/main.slint @@ -0,0 +1,303 @@ +// Unlucky-Launcher · 主入口窗口(P0 阶段 B 接线) +// +// 布局真相源:DevDocs/PlanDoc/09-ui-layout.md(D16:左侧固定导航 + 右侧 AI 常驻栏 + 磁贴实例页)。 +// 本文件只做三件事:①无边框窗口与字体/缩放口径 ②实例化 `AppShell` ③把 AppShell 的全部 +// 属性/回调原样上抛给 src/main.rs(Slint 只能从根组件访问属性与回调,所以这层转发是必需的)。 +// +// 与 M11 骨架的关系:M11 的 `Metro` palette global(accent/surface/sidebar/tile/text/muted/pad/gap) +// 已由 `theme/tokens.slint` 的 `Theme` 继承并扩展——同一「palette global 令牌化」思路,但多了 +// 亮/暗双套色板(LightPalette/DarkPalette)、主题三态解析、排版 ramp 与字体栈;M11 的占位 +// NavItem/Tile 组件由 `components/nav.slint` 与 `components/tile.slint` 取代,占位内核不再保留。 + +import { AppShell } from "shell.slint"; +import { Theme } from "theme/tokens.slint"; +import { Metrics } from "theme/metrics.slint"; +import { + RouteKind, TileSize, InstanceView, SettingSection, DownloadCategory, ThemeMode, BgFillKind, + UiTile, UiVersion, UiLogSession, UiLogLine, UiDiagnosis, UiChatMessage, UiDialog, UiToast, +} from "models.slint"; + +export component MainWindow inherits Window { + title: root.app-title; + // 默认尺寸取 D16 线框口径;最小尺寸保证 5 列磁贴 + 导航 + AI 栏不挤压 + width: 1180px; + height: 760px; + // Metro 一体感:自绘标题栏(components/titlebar.slint) + no-frame: true; + // 无边框窗口必须显式给边缘缩放热区,否则用户无法拖拽改窗口大小 + resize-border-width: 4px; + default-font-family: Theme.font-family; + default-font-size: Theme.font-size-body; + // 窗口自身底色必须**不透明**:面板半透明度(panel-alpha)只该透出背景图,不该透到桌面 + background: Theme.canvas-solid; + + // ── 从 main.rs 灌入的状态 ─────────────────────────────────── + in-out property app-title: "Unlucky Launcher"; + in property route: RouteKind.instances; + + in property nav-collapsed: false; + in property account-name: "未登录"; + in property account-initial: "?"; + in property account-hue: #5C2D91; + + in-out property ai-collapsed: false; + in property <[UiChatMessage]> ai-messages; + in property ai-streaming: false; + in-out property yolo: false; + in property ai-endpoint-label: "llama.cpp · 本地"; + in-out property ai-draft: ""; + + in property <[UiTile]> tiles; + in property <[UiTile]> running-tiles; + in property <[UiVersion]> versions; + in property <[UiLogSession]> log-sessions; + in property <[UiLogLine]> log-lines; + in property diagnosis; + in property has-diagnosis: false; + in property selected-session: ""; + + in-out property tile-size: TileSize.medium; + in-out property instance-view: InstanceView.tiles; + in-out property instance-query: ""; + in-out property settings-section: SettingSection.general; + in-out property theme-mode: ThemeMode.auto; + // 系统主题(M41):0 = 未知(回落 Slint 后端),1 = 浅色,2 = 深色 + in property system-theme: 0; + /// 后端是否认得系统主题(`false` 时 Rust 侧才需要轮询探测兜底) + out property system-theme-known: Theme.system-theme-known; + + // ── 外观(高级)P1 四件(09 §101-104,出厂全部关闭)────────────── + // 图片由 Rust 灌入(`in`);其余是 in-out:设置页控件会写回,回调里再原样上抛给 Rust。 + in property background-image; + in property background-blur; + in-out property bg-image-enabled: false; + in-out property bg-image: ""; + in-out property bg-fill: BgFillKind.cover; + in-out property bg-dim: 0.35; + in-out property panel-alpha: 1.0; + in-out property glass: false; + in-out property accent-color: transparent; + /// 设置页「强调色(十六进制)」输入框的内容(Rust 写入规范化后的值) + in-out property accent-hex: ""; + /// 强调色输入是否非法(设置页亮 warn 行) + in-out property accent-invalid: false; + in-out property download-category: DownloadCategory.release; + in-out property download-installed-only: false; + in-out property download-query: ""; + + in property dialog; + in property dialog-open: false; + in property dialog-queued: 0; + in property <[UiToast]> toasts; + + // 主题/外观:handler 里整体赋值给 `Theme` 全局(Slint 不允许在组件体内给全局属性做绑定, + // 但允许在回调/init 里赋值)。auto 分支由 Theme 自己读系统的 Palette.color-scheme。 + init => { + Theme.theme-mode = root.theme-mode; + Theme.system-theme = root.system-theme; + Theme.bg-image = root.background-image; + Theme.bg-blur = root.background-blur; + Theme.bg-dim = root.bg-dim; + Theme.panel-alpha = root.panel-alpha; + Theme.glass = root.glass; + Theme.accent-override = root.accent-color; + } + changed theme-mode => { Theme.theme-mode = root.theme-mode; } + changed system-theme => { Theme.system-theme = root.system-theme; } + changed background-image => { Theme.bg-image = root.background-image; } + changed background-blur => { Theme.bg-blur = root.background-blur; } + changed bg-dim => { Theme.bg-dim = root.bg-dim; } + changed panel-alpha => { Theme.panel-alpha = root.panel-alpha; } + changed glass => { Theme.glass = root.glass; } + changed accent-color => { Theme.accent-override = root.accent-color; } + + // ── 用户操作(main.rs 转成 AppCommand 或纯 UI 偏好)────────────── + callback toggle-maximize(); + callback minimize(); + callback request-close(); + + callback navigate(RouteKind); + callback toggle-nav(); + callback toggle-ai(); + callback expand-ai(); + callback dock-ai(); + callback toggle-yolo(); + callback ai-send(string); + callback ai-stop(); + + callback instance-launch(string); + callback instance-configure(string); + callback instance-stop(string); + callback instance-open(string); + callback new-instance(); + callback instance-view-changed(InstanceView); + callback tile-size-changed(TileSize); + callback instance-query-changed(string); + + callback download-category-changed(DownloadCategory); + callback download-install(string); + callback installed-only-changed(bool); + callback download-query-changed(string); + callback ancient-help(); + + callback log-select(string); + callback log-ask-ai(string); + callback log-apply-fix(); + + callback settings-section-changed(SettingSection); + callback theme-changed(ThemeMode); + callback bg-image-enabled-changed(bool); + callback bg-image-changed(string); + callback bg-fill-changed(BgFillKind); + callback bg-dim-changed(float); + callback panel-alpha-changed(float); + callback glass-changed(bool); + callback accent-color-changed(string); + callback accent-swatch-picked(int); + callback yolo-changed(bool); + callback mcp-changed(bool); + callback ai-endpoint-changed(int); + + callback dialog-primary(); + callback dialog-secondary(); + callback dialog-dismissed(); + callback toast-dismissed(string); + + AppShell { + app-title: root.app-title; + // 直接复用 Window 的内建 `maximized`(后端随窗口状态更新,无需自建属性) + maximized: root.maximized; + route: root.route; + nav-collapsed: root.nav-collapsed; + account-name: root.account-name; + account-initial: root.account-initial; + account-hue: root.account-hue; + ai-collapsed: root.ai-collapsed; + ai-messages: root.ai-messages; + ai-streaming: root.ai-streaming; + yolo: root.yolo; + ai-endpoint-label: root.ai-endpoint-label; + ai-draft: root.ai-draft; + tiles: root.tiles; + running-tiles: root.running-tiles; + versions: root.versions; + log-sessions: root.log-sessions; + log-lines: root.log-lines; + diagnosis: root.diagnosis; + has-diagnosis: root.has-diagnosis; + selected-session: root.selected-session; + tile-size: root.tile-size; + instance-view: root.instance-view; + instance-query: root.instance-query; + settings-section: root.settings-section; + theme-mode: root.theme-mode; + bg-image-enabled: root.bg-image-enabled; + bg-image: root.bg-image; + bg-fill: root.bg-fill; + bg-dim: root.bg-dim; + panel-alpha: root.panel-alpha; + glass: root.glass; + accent-hex: root.accent-hex; + accent-invalid: root.accent-invalid; + download-category: root.download-category; + download-installed-only: root.download-installed-only; + download-query: root.download-query; + dialog: root.dialog; + dialog-open: root.dialog-open; + dialog-queued: root.dialog-queued; + toasts: root.toasts; + + toggle-maximize => { root.toggle-maximize(); } + minimize => { root.minimize(); } + request-close => { root.request-close(); } + + navigate(r) => { root.navigate(r); } + toggle-nav => { root.toggle-nav(); } + toggle-ai => { root.toggle-ai(); } + expand-ai => { root.expand-ai(); } + dock-ai => { root.dock-ai(); } + toggle-yolo => { root.toggle-yolo(); } + ai-send(text) => { root.ai-send(text); } + ai-stop => { root.ai-stop(); } + + instance-launch(id) => { root.instance-launch(id); } + instance-configure(id) => { root.instance-configure(id); } + instance-stop(id) => { root.instance-stop(id); } + instance-open(id) => { root.instance-open(id); } + new-instance => { root.new-instance(); } + instance-view-changed(view) => { + root.instance-view = view; + root.instance-view-changed(view); + } + tile-size-changed(size) => { + root.tile-size = size; + root.tile-size-changed(size); + } + instance-query-changed(query) => { root.instance-query-changed(query); } + + download-category-changed(category) => { + root.download-category = category; + root.download-category-changed(category); + } + download-install(id) => { root.download-install(id); } + download-query-changed(query) => { + root.download-query = query; + root.download-query-changed(query); + } + ancient-help => { root.ancient-help(); } + installed-only-changed(only) => { + root.download-installed-only = only; + root.installed-only-changed(only); + } + + log-select(id) => { root.log-select(id); } + log-ask-ai(id) => { root.log-ask-ai(id); } + log-apply-fix => { root.log-apply-fix(); } + + settings-section-changed(section) => { + root.settings-section = section; + root.settings-section-changed(section); + } + theme-changed(mode) => { + root.theme-mode = mode; + root.theme-changed(mode); + } + bg-image-enabled-changed(v) => { + root.bg-image-enabled = v; + root.bg-image-enabled-changed(v); + } + bg-image-changed(path) => { + root.bg-image = path; + root.bg-image-changed(path); + } + bg-fill-changed(fill) => { + root.bg-fill = fill; + root.bg-fill-changed(fill); + } + bg-dim-changed(v) => { + root.bg-dim = v; + root.bg-dim-changed(v); + } + panel-alpha-changed(v) => { + root.panel-alpha = v; + root.panel-alpha-changed(v); + } + glass-changed(v) => { + root.glass = v; + root.glass-changed(v); + } + accent-color-changed(text) => { + root.accent-hex = text; + root.accent-color-changed(text); + } + accent-swatch-picked(index) => { root.accent-swatch-picked(index); } + yolo-changed(enabled) => { root.yolo-changed(enabled); } + mcp-changed(enabled) => { root.mcp-changed(enabled); } + ai-endpoint-changed(index) => { root.ai-endpoint-changed(index); } + + dialog-primary => { root.dialog-primary(); } + dialog-secondary => { root.dialog-secondary(); } + dialog-dismissed => { root.dialog-dismissed(); } + toast-dismissed(id) => { root.toast-dismissed(id); } + } +} diff --git a/crates/app/ui/models.slint b/crates/app/ui/models.slint new file mode 100644 index 0000000..f1704a3 --- /dev/null +++ b/crates/app/ui/models.slint @@ -0,0 +1,218 @@ +// UI 视图模型:Rust 的 dto.rs 做单向转换后喂给 Slint(app 不 import core 内部类型,01c §5)。 +// 命名一律 kebab-case,Rust 侧 slint-build 会生成 snake_case 字段。 +// 约定:这里只放「界面要显示什么」,不放领域语义(版本继承链、下载源健康度等留在 core)。 + +// ── 共享枚举 ───────────────────────────────────────────────────── +export enum RouteKind { + instances, + download, + logs, + settings, + detail, + agent, +} + +export enum TileSize { + small, + medium, + large, +} + +export enum InstanceView { + tiles, + list, +} + +/// D17 主题三态:auto 跟随系统(Slint Palette.color-scheme 热更),light/dark 钉死。 +/// 键值 `appearance.theme_mode ∈ {light, dark, auto}`,出厂 auto。 +export enum ThemeMode { + auto, + light, + dark, +} + +/// 背景图填充模式(09 §101:cover / contain / tile)。与 `core::setting::appearance::BgFill` 同序。 +export enum BgFillKind { + cover, + contain, + tile, +} + +export enum LoaderKind { + vanilla, + fabric, + quilt, + forge, + neoforge, + optifine, + liteloader, +} + +export enum RuntimePhase { + idle, + downloading, + verifying, + launching, + running, + failed, +} + +export enum ChatRole { + user, + assistant, + tool, + system, +} + +export enum DialogKind { + confirm, + input, + progress, + error, +} + +export enum ToastKind { + info, + success, + warn, + error, +} + +export enum VersionKind { + release, + snapshot, + ancientClassic, + ancientAlpha, + ancientBeta, + ancientUnlisted, + modpack, + mod, +} + +// 下载页分类侧栏的选中项(远古版把四个子类提升为平级枚值,便于单选高亮) +export enum DownloadCategory { + release, + snapshot, + ancientClassic, + ancientAlpha, + ancientBeta, + ancientUnlisted, + modpack, + mod, +} + +export enum LogOutcome { + ok, + crashed, + silentFail, +} + +export enum LogLevel { + debug, + info, + warn, + error, +} + +export enum SettingSection { + general, + /// D17「外观(高级)」:主题三态 / 背景图 / 半透明度 / 玻璃拟态 / 强调色 / 动画强度 + appearance, + data, + download, + java, + account, + ai, + proxy, + about, +} + +// ── 磁贴 ──────────────────────────────────────────────────────── +// row/col 由 Rust state.rs 的 tile_grid() 按当前磁贴尺寸算好(小 6 / 中 4 / 大 3 列), +// 这样 Slint 侧 GridLayout 只消费整数位置,不必在 DSL 里做除法。 +export struct UiTile { + id: string, + name: string, + mc_version: string, + loader: LoaderKind, + loader_version: string, + /// 加载器徽标文字(「原版」/「Fabric 0.16.5」):由 Rust 侧用 `LoaderInfo::badge()` 拼好, + /// DSL 不做枚举→字符串映射(少一处双份真相)。 + loader_label: string, + last_played: string, + phase: RuntimePhase, + /// 启动中阶段文案(「下载资源」/「校验文件」/「启动中」),idle 时为空。 + phase_label: string, + /// 0.0~1.0;<= 0 表示进度未知(进度环转为不确定态)。 + progress: float, + /// 实例图标;空 image 时退回「名字首字 + hue 底色」占位。 + icon: image, + hue: color, + /// 名字首字,无图标时的占位字母(Rust 侧取首个字素)。 + initial: string, + row: int, + col: int, +} + +// ── 下载页 ────────────────────────────────────────────────────── +export struct UiVersion { + id: string, + kind: VersionKind, + released: string, + installed: bool, + /// 远古版(classic/alpha/beta/unlisted)——列表行带徽标 + 页顶兼容性提示。 + ancient: bool, +} + +// ── 日志页 ────────────────────────────────────────────────────── +export struct UiLogSession { + id: string, + when: string, + instance: string, + outcome: LogOutcome, + duration: string, +} + +export struct UiLogLine { + text: string, + level: LogLevel, +} + +/// D8 规则引擎结论(core::diagnose 的 DTO,app 只负责展示)。 +export struct UiDiagnosis { + rule: string, + title: string, + confidence: int, + hint: string, + has_fix: bool, +} + +// ── AI 栏 ─────────────────────────────────────────────────────── +export struct UiChatMessage { + role: ChatRole, + text: string, + /// role == tool 时的工具名。 + tool_name: string, + tool_ok: bool, + /// 流式未完成时 true(末尾显示光标块)。 + streaming: bool, +} + +// ── 对话框 / Toast ────────────────────────────────────────────── +export struct UiDialog { + id: string, + kind: DialogKind, + title: string, + body: string, + primary: string, + secondary: string, + progress: float, + input_label: string, + input_value: string, +} + +export struct UiToast { + id: string, + kind: ToastKind, + text: string, +} diff --git a/crates/app/ui/pages/download.slint b/crates/app/ui/pages/download.slint new file mode 100644 index 0000000..d3425b2 --- /dev/null +++ b/crates/app/ui/pages/download.slint @@ -0,0 +1,270 @@ +// G2 下载页(09 §2 P0·G2 / Q24-4 定案):页内独立左侧分类栏(常驻,非 tab)+ 右侧版本列表。 +// 远古版入口显式可达(ULL 对 HMCL「隐藏」行为的纠偏),下挂 classic/alpha/beta/unlisted 子类树, +// 进入远古版分类时顶部提示兼容性 + 每行带「远古版」徽标(防误装)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiVersion, VersionKind, DownloadCategory } from "../models.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 { ListView } from "std-widgets.slint"; + +component CategoryRow inherits Rectangle { + in property label: ""; + in property active: false; + in property sub: false; + in property disabled: false; + callback clicked(); + + height: 34px; + background: root.active ? Theme.accent-soft : (row-touch.has-hover ? Theme.bg-hover : transparent); + + if root.active : Rectangle { + x: 0; + y: 0; + width: 3px; + height: root.height; + background: Theme.accent; + } + + UiText { + x: root.sub ? Metrics.gap-5 : Metrics.gap-3; + width: root.width - (root.sub ? Metrics.gap-5 : Metrics.gap-3) - Metrics.gap-2; + height: root.height; + role: TextRole.body; + text: root.label; + color: root.disabled ? Theme.fg-disabled + : root.active ? Theme.accent + : Theme.fg-primary; + } + + row-touch := TouchArea { + mouse-cursor: root.disabled ? default : pointer; + clicked => { + if (!root.disabled) { + root.clicked(); + } + } + } +} + +component VersionRow inherits Rectangle { + in property data; + callback install(); + + height: Metrics.row-height + 8px; + background: row-touch.has-hover ? Theme.bg-hover : transparent; + + HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-4; + spacing: Metrics.gap-2; + + UiText { + role: TextRole.bodyStrong; + text: root.data.id; + width: 160px; + } + + Badge { + text: root.data.kind == VersionKind.release ? "正式版" + : root.data.kind == VersionKind.snapshot ? "预览版" + : "远古版"; + tint: root.data.ancient ? Theme.state-warn : Theme.fg-secondary; + } + + UiText { + role: TextRole.caption; + text: root.data.released; + horizontal-stretch: 1; + } + + if root.data.installed : HorizontalLayout { + spacing: Metrics.gap-1; + AppIcon { kind: IconKind.check; icon-size: 12px; tint: Theme.state-success; } + UiText { + role: TextRole.caption; + text: "已装"; + color: Theme.state-success; + } + } + + MetroButton { + kind: ButtonKind.standard; + text: root.data.installed ? "重装" : "安装"; + button-height: 26px; + clicked => { root.install(); } + } + } + + row-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.install(); } + } +} + +export component DownloadPage inherits Rectangle { + in property <[UiVersion]> versions; + in-out property category: DownloadCategory.release; + in-out property installed-only: false; + in-out property query: ""; + /// 远古版子类树是否展开(默认展开,显式可达) + in-out property ancient-expanded: true; + + callback category-changed(DownloadCategory); + callback install(string); + callback installed-only-changed(bool); + callback query-changed(string); + callback toggle-ancient(); + /// 远古版兼容性说明(P1 出说明页;当前由 Rust 侧弹 Toast 占位) + callback ancient-help(); + + background: Theme.bg-canvas; + + HorizontalLayout { + // ── 页内分类侧栏(常驻)─────────────────────────────────── + 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 { + 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); } + } + } + } + } +} diff --git a/crates/app/ui/pages/instances.slint b/crates/app/ui/pages/instances.slint new file mode 100644 index 0000000..3495bd5 --- /dev/null +++ b/crates/app/ui/pages/instances.slint @@ -0,0 +1,260 @@ +// G1 实例页(09 §2 P0·G1):顶栏(搜索/新建/视图切换/磁贴尺寸)+ 磁贴网格 + 运行中迷你状态条。 +// 网格用 GridLayout:列数由 Rust 按磁贴尺寸算好(小 6 / 中 4 / 大 3),row/col 随 UiTile 一起给。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiTile, InstanceView, TileSize, RuntimePhase } from "../models.slint"; +import { UiText, TextRole } from "../components/label.slint"; +import { AppIcon, IconKind } from "../components/icons.slint"; +import { MetroButton, ButtonKind } from "../widgets/button.slint"; +import { SearchField, SegmentedControl } from "../widgets/inputs.slint"; +import { InstanceTile } from "../components/tile.slint"; +import { ListView } from "std-widgets.slint"; + +// 列表视图的一行(与磁贴共用同一份 UiTile 数据) +component InstanceRow inherits Rectangle { + in property data; + callback open(); + callback launch(); + + height: Metrics.row-height + 8px; + background: row-touch.has-hover ? Theme.bg-hover : transparent; + + // 行点击区先声明,按钮后声明 → 按钮命中优先 + row-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.open(); } + } + + HorizontalLayout { + padding-left: Metrics.gap-3; + padding-right: Metrics.gap-3; + spacing: Metrics.gap-3; + + Rectangle { + width: 28px; + height: 28px; + border-radius: Metrics.radius-sm; + background: root.data.hue; + + UiText { + text: root.data.initial; + font-size: 13px; + font-weight: 600; + color: Theme.fg-on-accent; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + UiText { + role: TextRole.body; + text: root.data.name; + horizontal-stretch: 1; + } + UiText { + role: TextRole.caption; + text: root.data.mc_version; + width: 88px; + } + UiText { + role: TextRole.caption; + text: root.data.loader_label; + width: 88px; + } + UiText { + role: TextRole.caption; + text: root.data.last_played; + width: 120px; + } + + MetroButton { + kind: ButtonKind.subtle; + icon: IconKind.play; + icon-only: true; + enabled: root.data.phase == RuntimePhase.idle + || root.data.phase == RuntimePhase.failed; + clicked => { root.launch(); } + } + } +} + +export component InstancesPage inherits Rectangle { + in property <[UiTile]> tiles; + in property <[UiTile]> running; + in property view: InstanceView.tiles; + in-out property tile-size: TileSize.medium; + in-out property query: ""; + + callback launch(string); + callback configure(string); + callback stop(string); + callback open(string); + callback new-instance(); + callback view-changed(InstanceView); + callback tile-size-changed(TileSize); + callback query-changed(string); + + background: Theme.bg-canvas; + + VerticalLayout { + // ── 顶栏 ──────────────────────────────────────────────── + Rectangle { + height: Metrics.page-header-height; + background: Theme.bg-surface; + + HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-4; + spacing: Metrics.gap-2; + + UiText { + role: TextRole.subtitle; + text: "实例"; + width: 72px; + } + + SearchField { + width: 240px; + placeholder: "搜索实例"; + text <=> root.query; + edited(t) => { root.query-changed(t); } + } + + Rectangle { horizontal-stretch: 1; background: transparent; } + + SegmentedControl { + width: 120px; + options: ["磁贴", "列表"]; + selected: root.view == InstanceView.tiles ? 0 : 1; + changed(i) => { + root.view-changed(i == 0 ? InstanceView.tiles : InstanceView.list); + } + } + + SegmentedControl { + width: 132px; + visible: root.view == InstanceView.tiles; + options: ["小", "中", "大"]; + selected: root.tile-size == TileSize.small ? 0 + : root.tile-size == TileSize.large ? 2 : 1; + changed(i) => { + root.tile-size-changed( + i == 0 ? TileSize.small : i == 2 ? TileSize.large : TileSize.medium); + } + } + + MetroButton { + kind: ButtonKind.accent; + text: "新建实例"; + show-icon: true; + icon: IconKind.plus; + clicked => { root.new-instance(); } + } + } + } + + Rectangle { height: 1px; background: Theme.stroke; } + + // ── 运行中迷你状态条 ────────────────────────────────────── + Rectangle { + height: Metrics.running-strip-height; + visible: root.running.length > 0; + background: Theme.state-info-soft; + + HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-4; + spacing: Metrics.gap-2; + + Rectangle { + width: 8px; + height: 8px; + border-radius: 4px; + background: Theme.state-success; + } + UiText { + role: TextRole.caption; + text: root.running.length + " 个实例运行中"; + color: Theme.state-success; + horizontal-stretch: 1; + } + for r in root.running : UiText { + role: TextRole.caption; + text: r.name; + } + } + } + + // ── 磁贴网格 ───────────────────────────────────────────── + body := Flickable { + vertical-stretch: 1; + visible: root.view == InstanceView.tiles && root.tiles.length > 0; + content-width: self.width; + content-height: grid-content.height; + + grid-content := Rectangle { + width: body.width; + height: grid.preferred-height; + background: transparent; + + grid := GridLayout { + padding: Metrics.gap-4; + spacing: Metrics.gap-3; + + for t in root.tiles : InstanceTile { + row: t.row; + col: t.col; + data: t; + compact: root.tile-size == TileSize.small; + open => { root.open(t.id); } + launch => { root.launch(t.id); } + configure => { root.configure(t.id); } + stop => { root.stop(t.id); } + } + } + } + } + + // ── 列表视图 ───────────────────────────────────────────── + ListView { + vertical-stretch: 1; + visible: root.view == InstanceView.list && root.tiles.length > 0; + + for t in root.tiles : InstanceRow { + data: t; + open => { root.open(t.id); } + launch => { root.launch(t.id); } + } + } + + // ── 空态 ──────────────────────────────────────────────── + VerticalLayout { + vertical-stretch: 1; + visible: root.tiles.length == 0; + alignment: center; + spacing: Metrics.gap-3; + + UiText { + role: TextRole.title; + text: "还没有实例"; + horizontal-alignment: center; + color: Theme.fg-secondary; + } + UiText { + role: TextRole.caption; + text: "新建一个实例,或者先去下载页装个版本"; + horizontal-alignment: center; + } + HorizontalLayout { + alignment: center; + MetroButton { + kind: ButtonKind.accent; + text: "新建实例"; + show-icon: true; + icon: IconKind.plus; + clicked => { root.new-instance(); } + } + } + } + } +} diff --git a/crates/app/ui/pages/logs.slint b/crates/app/ui/pages/logs.slint new file mode 100644 index 0000000..26f2c42 --- /dev/null +++ b/crates/app/ui/pages/logs.slint @@ -0,0 +1,259 @@ +// G3 日志页(09 §2 P0·G3):左侧会话列表(✓正常 / ✗崩溃 / ⚠静默失败)+ 右侧日志流 + 崩溃诊断卡片。 +// 诊断卡片 = D8 规则引擎结论(命中规则 + 置信度 + 修复动作 + 跳 AI 栏带上下文)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiLogSession, UiLogLine, UiDiagnosis, LogOutcome, LogLevel } from "../models.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 } from "../widgets/inputs.slint"; +import { ListView } from "std-widgets.slint"; + +component SessionRow inherits Rectangle { + in property data; + in property active: false; + callback clicked(); + + height: 52px; + background: root.active ? Theme.accent-soft : (row-touch.has-hover ? Theme.bg-hover : transparent); + + if root.active : Rectangle { + x: 0; + y: 0; + width: 3px; + height: root.height; + background: Theme.accent; + } + + HorizontalLayout { + padding-left: Metrics.gap-3; + padding-right: Metrics.gap-2; + spacing: Metrics.gap-2; + + AppIcon { + icon-size: 14px; + kind: root.data.outcome == LogOutcome.ok ? IconKind.check + : root.data.outcome == LogOutcome.crashed ? IconKind.error + : IconKind.warn; + tint: root.data.outcome == LogOutcome.ok ? Theme.state-success + : root.data.outcome == LogOutcome.crashed ? Theme.state-danger + : Theme.state-warn; + } + + VerticalLayout { + spacing: 0px; + vertical-stretch: 1; + + UiText { + role: TextRole.body; + text: root.data.instance; + horizontal-stretch: 1; + } + UiText { + role: TextRole.caption; + text: root.data.when; + horizontal-stretch: 1; + } + } + + UiText { + role: TextRole.caption; + text: root.data.duration; + width: 56px; + horizontal-alignment: right; + } + } + + row-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +component LogRow inherits Rectangle { + in property line; + + height: 18px; + background: root.line.level == LogLevel.error + ? Theme.state-danger-soft + : transparent; + + UiText { + x: Metrics.gap-2; + y: 0; + width: root.width - 2 * Metrics.gap-2; + height: root.height; + role: TextRole.monoSmall; + text: root.line.text; + color: root.line.level == LogLevel.error ? Theme.state-danger + : root.line.level == LogLevel.warn ? Theme.state-warn + : root.line.level == LogLevel.debug ? Theme.fg-disabled + : Theme.fg-primary; + } +} + +export component LogsPage inherits Rectangle { + in property <[UiLogSession]> sessions; + in property <[UiLogLine]> lines; + in property diagnosis; + in property has-diagnosis: false; + in property live: false; + in-out property selected: ""; + in-out property query: ""; + + callback select(string); + callback ask-ai(string); + callback apply-fix(); + callback query-changed(string); + + background: Theme.bg-canvas; + + HorizontalLayout { + // ── 左侧会话列表 ───────────────────────────────────────── + Rectangle { + width: 248px; + background: Theme.bg-surface; + + VerticalLayout { + Rectangle { + height: 44px; + background: transparent; + + HorizontalLayout { + padding-left: Metrics.gap-3; + padding-right: Metrics.gap-2; + spacing: Metrics.gap-2; + + UiText { + role: TextRole.bodyStrong; + text: "会话"; + horizontal-stretch: 1; + } + if root.live : Rectangle { + width: 6px; + height: 6px; + border-radius: 3px; + background: Theme.state-success; + } + } + } + + ListView { + vertical-stretch: 1; + + for s in root.sessions : SessionRow { + data: s; + active: root.selected == s.id; + clicked => { root.select(s.id); } + } + } + } + } + + Rectangle { width: 1px; background: Theme.stroke; } + + // ── 右侧日志流 + 诊断卡片 ──────────────────────────────── + VerticalLayout { + vertical-stretch: 1; + + Rectangle { + height: 44px; + background: Theme.bg-surface; + + HorizontalLayout { + 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; + } + SearchField { + width: 200px; + placeholder: "筛选日志行"; + text <=> root.query; + edited(t) => { root.query-changed(t); } + } + MetroButton { + kind: ButtonKind.subtle; + text: "打开目录"; + button-height: 26px; + } + } + } + + Rectangle { height: 1px; background: Theme.stroke; } + + // 崩溃诊断卡片(D8) + Rectangle { + height: root.has-diagnosis ? 76px : 0px; + visible: root.has-diagnosis; + background: Theme.state-danger-soft; + + HorizontalLayout { + padding: Metrics.gap-3; + spacing: Metrics.gap-3; + + AppIcon { kind: IconKind.error; icon-size: 20px; tint: Theme.state-danger; } + + VerticalLayout { + spacing: 2px; + vertical-stretch: 1; + + HorizontalLayout { + spacing: Metrics.gap-2; + UiText { + role: TextRole.bodyStrong; + text: root.diagnosis.title; + horizontal-stretch: 1; + } + Badge { + text: "置信度 " + root.diagnosis.confidence + "%"; + tint: Theme.state-danger; + } + } + UiText { + role: TextRole.caption; + text: root.diagnosis.hint; + horizontal-stretch: 1; + } + } + + VerticalLayout { + spacing: Metrics.gap-1; + alignment: center; + + if root.diagnosis.has_fix : MetroButton { + kind: ButtonKind.standard; + text: "修复建议"; + button-height: 26px; + clicked => { root.apply-fix(); } + } + MetroButton { + kind: ButtonKind.subtle; + text: "让 AI 详解"; + button-height: 26px; + clicked => { root.ask-ai(root.selected); } + } + } + } + } + + // 日志流(行虚拟化) + Rectangle { + vertical-stretch: 1; + background: Theme.bg-canvas; + + ListView { + for l in root.lines : LogRow { + line: l; + width: root.width; + } + } + } + } + } +} diff --git a/crates/app/ui/pages/settings-appearance.slint b/crates/app/ui/pages/settings-appearance.slint new file mode 100644 index 0000000..a7ef123 --- /dev/null +++ b/crates/app/ui/pages/settings-appearance.slint @@ -0,0 +1,146 @@ +// 设置页「外观」分组(09 §2 G6 + §113 D17 定案):主题三态 + 「外观(高级)」P1 四件。 +// +// 从 pages/settings.slint 拆出来只为守住 400 行门槛;控件语义与设置键一一对应: +// theme-mode → appearance.theme_mode / bg-* → appearance.bg_image* / panel-alpha → +// appearance.panel_alpha / glass → appearance.glass / accent-color → appearance.accent_color。 +// 09 §111 硬约束:高级特性全部默认关闭,开启背景图时这里会给出对比度提示(warn 行)。 +import { Theme } from "../theme/tokens.slint"; +import { ThemeMode, BgFillKind } from "../models.slint"; +import { SettingItem, SettingGroup, ControlKind } from "../widgets/setting-item.slint"; + +export component AppearanceSection inherits VerticalLayout { + in-out property theme-mode: ThemeMode.auto; + in-out property bg-image-enabled: false; + in-out property bg-image: ""; + in-out property bg-fill: BgFillKind.cover; + in-out property bg-dim: 0.35; + in-out property panel-alpha: 1.0; + in-out property glass: false; + in-out property accent-color: ""; + in property accent-invalid: false; + + callback theme-changed(ThemeMode); + callback bg-image-enabled-changed(bool); + callback bg-image-changed(string); + callback bg-fill-changed(BgFillKind); + callback bg-dim-changed(float); + callback panel-alpha-changed(float); + callback glass-changed(bool); + callback accent-color-changed(string); + callback accent-swatch-picked(int); + +// ── 外观(D17:主题三态 + 09 §101-104 高级四件)────── +SettingGroup { title: "主题"; } +SettingItem { + label: "主题模式"; + description: "跟随系统(Auto,随 OS 热切)/ 浅色 / 深色"; + kind: ControlKind.segmented; + options: ["跟随系统", "浅色", "深色"]; + selected: root.theme-mode == ThemeMode.auto ? 0 + : root.theme-mode == ThemeMode.light ? 1 : 2; + changed(i) => { + root.theme-mode = i == 0 ? ThemeMode.auto + : i == 1 ? ThemeMode.light : ThemeMode.dark; + root.theme-changed(root.theme-mode); + } +} + +SettingGroup { + title: "高级特性(出厂全部关闭,开启后请确认前景可读)"; +} +// ① 自定义窗口背景图(09 §101) +SettingItem { + label: "自定义窗口背景图"; + description: "PNG 图片铺满窗口;开启后所有面板半透明处都会透出它"; + kind: ControlKind.toggle; + checked: root.bg-image-enabled; + toggled(v) => { + root.bg-image-enabled = v; + root.bg-image-enabled-changed(v); + } +} +SettingItem { + label: "背景图路径"; + description: root.bg-image == "" ? "尚未选择(绝对路径,例:D:/wall/p.png)" : root.bg-image; + kind: ControlKind.text; + button-text: "D:/wall/p.png"; + value <=> root.bg-image; + edited(t) => { root.bg-image-changed(t); } +} +SettingItem { + label: "填充模式"; + description: "铺满(裁切)/ 完整(留底色)/ 平铺"; + kind: ControlKind.segmented; + options: ["铺满", "完整", "平铺"]; + selected: root.bg-fill == BgFillKind.cover ? 0 + : root.bg-fill == BgFillKind.contain ? 1 : 2; + changed(i) => { + root.bg-fill = i == 0 ? BgFillKind.cover + : i == 1 ? BgFillKind.contain : BgFillKind.tile; + root.bg-fill-changed(root.bg-fill); + } +} +SettingItem { + label: "暗化层强度"; + // 09 §111:开启背景图时自动给对比度提示——暗化太低 + 面板太透会伤可读性 + description: (root.bg-image-enabled && (root.bg-dim < 0.25 || root.panel-alpha < 0.8)) + ? "⚠ 对比度偏低:建议暗化 ≥ 25% 或面板不透明度 ≥ 80%" + : "越大背景越暗、前景越清晰(0% = 不压暗)"; + warn: root.bg-image-enabled && (root.bg-dim < 0.25 || root.panel-alpha < 0.8); + kind: ControlKind.slider; + min-value: 0.0; + max-value: 0.9; + slider-value: root.bg-dim; + slid(v) => { + root.bg-dim = v; + root.bg-dim-changed(v); + } +} +// ② 全局面板半透明度(09 §102) +SettingItem { + label: "全局面板半透明度"; + description: "卡片 / 导航 / AI 栏统一透明度(100% = 出厂 Metro 纯色)"; + kind: ControlKind.slider; + min-value: 0.4; + max-value: 1.0; + slider-value: root.panel-alpha; + slid(v) => { + root.panel-alpha = v; + root.panel-alpha-changed(v); + } +} +// ③ 玻璃拟态卡片(09 §103) +SettingItem { + label: "玻璃拟态卡片"; + description: "卡片底铺背景图的预生成模糊层(需先开启背景图)"; + kind: ControlKind.toggle; + checked: root.glass; + toggled(v) => { + root.glass = v; + root.glass-changed(v); + } +} +// ④ 自定义强调色(09 §104) +SettingItem { + label: "预设强调色"; + description: root.accent-color == "" ? "当前:出厂 Metro 蓝" : "当前:" + root.accent-color; + kind: ControlKind.swatches; + swatch-colors: [ + #0078D4, #0F6CBD, #107C10, #9D5D00, + #C42B1C, #8764B8, #C239B3, #00B7C3, + ]; + swatch-current: root.accent-color == "" ? #0078D4 : Theme.accent; + swatch-picked(i) => { root.accent-swatch-picked(i); } +} +SettingItem { + label: "强调色(十六进制)"; + description: root.accent-invalid + ? "⚠ 需要 #RRGGBB 形式,留空恢复出厂蓝" + : "留空 = 出厂 #0078D4;影响顶线 / 选中态 / 按钮 / 徽标"; + warn: root.accent-invalid; + kind: ControlKind.text; + button-text: "#0078D4"; + value <=> root.accent-color; + edited(t) => { root.accent-color-changed(t); } +} +} diff --git a/crates/app/ui/pages/settings.slint b/crates/app/ui/pages/settings.slint new file mode 100644 index 0000000..ed176a7 --- /dev/null +++ b/crates/app/ui/pages/settings.slint @@ -0,0 +1,363 @@ +// G6 设置页(09 §2 P0·G6):左子导航 + 右表单 + 搜索框(设置项多,防迷路)。 +// 「通用 / 外观(D17 三态 + 09 §101-104 高级四件,实现在 settings-appearance.slint)/ 数据 / AI / +// 关于」是真实控件,Java / 账户 / 代理分组仍是入口按钮(等各自 mission 接实现)。 +// 关于页内嵌 AboutSlint —— Slint RF 2.0 许可义务(01c §8:关于页放官方部件)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +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 { SearchField } from "../widgets/inputs.slint"; +import { AboutSlint, ScrollView } from "std-widgets.slint"; + +component SectionRow inherits Rectangle { + in property label: ""; + in property section: SettingSection.general; + in property active: false; + callback clicked(); + + height: 36px; + background: root.active ? Theme.accent-soft : (row-touch.has-hover ? Theme.bg-hover : transparent); + + if root.active : Rectangle { + x: 0; + y: 0; + width: 3px; + height: root.height; + background: Theme.accent; + } + + UiText { + x: Metrics.gap-3; + y: 0; + width: root.width - Metrics.gap-3 - Metrics.gap-2; + height: root.height; + role: TextRole.body; + text: root.label; + color: root.active ? Theme.accent : Theme.fg-primary; + } + + row-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +export component SettingsPage inherits Rectangle { + in-out property section: SettingSection.general; + in-out property query: ""; + + // 外观(D17:主题三态 + 09 §101-104 高级特性四件) + in-out property theme-mode: ThemeMode.auto; + in-out property bg-image-enabled: false; + in-out property bg-image: ""; + in-out property bg-fill: BgFillKind.cover; + in-out property bg-dim: 0.35; + in-out property panel-alpha: 1.0; + in-out property glass: false; + in-out property accent-color: ""; + in property accent-invalid: false; + // 通用 + in-out property tile-size: TileSize.medium; + in-out property close-to-tray: true; + + // 数据 + in-out property data-mode: 0; + in property data-path: ""; + // 下载 + in-out property download-concurrency: 1; + in property region-label: "未知"; + // 账户 + in property account-label: "未登录"; + // AI + in-out property ai-endpoint: 0; + in-out property yolo: false; + in-out property mcp-enabled: false; + // 代理 + in-out property proxy: ""; + // 关于 + in property version: ""; + + callback section-changed(SettingSection); + callback query-changed(string); + callback theme-changed(ThemeMode); + callback bg-image-enabled-changed(bool); + callback bg-image-changed(string); + callback bg-fill-changed(BgFillKind); + callback bg-dim-changed(float); + callback panel-alpha-changed(float); + callback glass-changed(bool); + callback accent-color-changed(string); + callback accent-swatch-picked(int); + callback tile-size-changed(TileSize); + callback close-to-tray-changed(bool); + callback data-mode-changed(int); + callback download-concurrency-changed(int); + callback ai-endpoint-changed(int); + callback yolo-changed(bool); + callback mcp-changed(bool); + callback proxy-changed(string); + callback migrate-data(); + callback login(); + callback manage-java(); + callback open-audit-log(); + callback detect-region(); + + background: Theme.bg-canvas; + + HorizontalLayout { + // ── 左子导航 ────────────────────────────────────────────── + Rectangle { + width: 200px; + background: Theme.bg-surface; + + VerticalLayout { + padding-top: Metrics.gap-2; + + SectionRow { label: "通用"; section: SettingSection.general; active: root.section == SettingSection.general; clicked => { root.section-changed(SettingSection.general); } } + SectionRow { label: "外观"; section: SettingSection.appearance; active: root.section == SettingSection.appearance; clicked => { root.section-changed(SettingSection.appearance); } } + SectionRow { label: "数据"; section: SettingSection.data; active: root.section == SettingSection.data; clicked => { root.section-changed(SettingSection.data); } } + SectionRow { label: "下载"; section: SettingSection.download; active: root.section == SettingSection.download; clicked => { root.section-changed(SettingSection.download); } } + SectionRow { label: "Java"; section: SettingSection.java; active: root.section == SettingSection.java; clicked => { root.section-changed(SettingSection.java); } } + SectionRow { label: "账户"; section: SettingSection.account; active: root.section == SettingSection.account; clicked => { root.section-changed(SettingSection.account); } } + SectionRow { label: "AI"; section: SettingSection.ai; active: root.section == SettingSection.ai; clicked => { root.section-changed(SettingSection.ai); } } + SectionRow { label: "代理"; section: SettingSection.proxy; active: root.section == SettingSection.proxy; clicked => { root.section-changed(SettingSection.proxy); } } + SectionRow { label: "关于"; section: SettingSection.about; active: root.section == SettingSection.about; clicked => { root.section-changed(SettingSection.about); } } + + Rectangle { vertical-stretch: 1; background: transparent; } + } + } + + Rectangle { width: 1px; background: Theme.stroke; } + + // ── 右表单 ──────────────────────────────────────────────── + VerticalLayout { + vertical-stretch: 1; + + Rectangle { + height: Metrics.page-header-height; + background: Theme.bg-surface; + + HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-4; + spacing: Metrics.gap-3; + + UiText { + role: TextRole.subtitle; + text: "设置"; + width: 72px; + } + SearchField { + width: 240px; + placeholder: "搜索设置项"; + text <=> root.query; + edited(t) => { root.query-changed(t); } + } + Rectangle { horizontal-stretch: 1; background: transparent; } + } + } + + Rectangle { height: 1px; background: Theme.stroke; } + + ScrollView { + vertical-stretch: 1; + + VerticalLayout { + padding-bottom: Metrics.gap-5; + + // 外观(D17:主题三态 + 09 §101-104 高级四件;实现在 settings-appearance.slint) + if root.section == SettingSection.appearance : AppearanceSection { + theme-mode: root.theme-mode; + bg-image-enabled: root.bg-image-enabled; + bg-image: root.bg-image; + bg-fill: root.bg-fill; + bg-dim: root.bg-dim; + panel-alpha: root.panel-alpha; + glass: root.glass; + accent-color: root.accent-color; + accent-invalid: root.accent-invalid; + theme-changed(m) => { + root.theme-mode = m; + root.theme-changed(m); + } + bg-image-enabled-changed(v) => { + root.bg-image-enabled = v; + root.bg-image-enabled-changed(v); + } + bg-image-changed(t) => { + root.bg-image = t; + root.bg-image-changed(t); + } + bg-fill-changed(f) => { + root.bg-fill = f; + root.bg-fill-changed(f); + } + bg-dim-changed(v) => { + root.bg-dim = v; + root.bg-dim-changed(v); + } + panel-alpha-changed(v) => { + root.panel-alpha = v; + root.panel-alpha-changed(v); + } + glass-changed(v) => { + root.glass = v; + root.glass-changed(v); + } + accent-color-changed(t) => { + root.accent-color = t; + root.accent-color-changed(t); + } + accent-swatch-picked(i) => { root.accent-swatch-picked(i); } + } + + // ── 通用 ──────────────────────────────────────── + if root.section == SettingSection.general : SettingGroup { title: "外观"; } + if root.section == SettingSection.general : SettingItem { + label: "磁贴尺寸"; + description: "小 6 列 / 中 4 列(默认)/ 大 3 列"; + kind: ControlKind.segmented; + options: ["小", "中", "大"]; + selected: root.tile-size == TileSize.small ? 0 + : root.tile-size == TileSize.large ? 2 : 1; + changed(i) => { + root.tile-size-changed( + i == 0 ? TileSize.small : i == 2 ? TileSize.large : TileSize.medium); + } + } + if root.section == SettingSection.general : SettingItem { + label: "关闭窗口时最小化到托盘"; + description: "避免误关正在运行的游戏"; + kind: ControlKind.toggle; + checked: root.close-to-tray; + 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(); } + } + + // ── 代理 ──────────────────────────────────────── + if root.section == SettingSection.proxy : SettingGroup { title: "代理"; } + if root.section == SettingSection.proxy : SettingItem { + label: "HTTP 代理"; + description: "留空则使用系统代理"; + kind: ControlKind.text; + button-text: "http://127.0.0.1:7890"; + value <=> root.proxy; + edited(t) => { root.proxy-changed(t); } + } + + // ── 关于 ──────────────────────────────────────── + if root.section == SettingSection.about : SettingGroup { title: "关于"; } + if root.section == SettingSection.about : SettingItem { + label: "版本"; + description: root.version; + } + if root.section == SettingSection.about : SettingItem { + label: "许可"; + description: "Apache-2.0(Slint 走 RF 2.0 轨道)"; + } + if root.section == SettingSection.about : Rectangle { + height: 120px; + AboutSlint { } + } + } + } + } + } +} diff --git a/crates/app/ui/shell.slint b/crates/app/ui/shell.slint new file mode 100644 index 0000000..7d5e9ce --- /dev/null +++ b/crates/app/ui/shell.slint @@ -0,0 +1,373 @@ +// 主窗口框架(D16 §1):无边框标题栏 + 左侧可折叠导航 + 内容区路由 + 右侧 AI 常驻栏 + 对话框/Toast 覆盖层。 +// 这里只做「组装 + 路由转发」,状态全在 Rust 侧(state.rs),Slint 不持有业务真相。 +// main.slint(阶段 B 接线)只负责实例化 AppShell 并把 Window 的窗口事件接上。 +import { Theme } from "theme/tokens.slint"; +import { Metrics } from "theme/metrics.slint"; +import { + RouteKind, TileSize, InstanceView, SettingSection, DownloadCategory, ThemeMode, BgFillKind, + UiTile, UiVersion, UiLogSession, UiLogLine, UiDiagnosis, UiChatMessage, UiDialog, UiToast, +} from "models.slint"; +import { TitleBar } from "components/titlebar.slint"; +import { Nav } from "components/nav.slint"; +import { AiPanel } from "components/ai-panel.slint"; +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 { 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 app-title: "Unlucky Launcher"; + in property maximized: false; + + // ── 路由 ──────────────────────────────────────────────────── + in property route: RouteKind.instances; + + // ── 导航 / 账户 ────────────────────────────────────────────── + in property nav-collapsed: false; + in property account-name: "未登录"; + in property account-initial: "?"; + in property account-hue: #5C2D91; + + // ── AI 栏(双形态共享同一份会话)────────────────────────────── + in property ai-collapsed: false; + in property <[UiChatMessage]> ai-messages; + in property ai-streaming: false; + in property yolo: false; + in property ai-endpoint-label: "llama.cpp · 本地"; + in-out property ai-draft: ""; + + // ── 页面数据 ──────────────────────────────────────────────── + in property <[UiTile]> tiles; + in property <[UiTile]> running-tiles; + in property <[UiVersion]> versions; + in property <[UiLogSession]> log-sessions; + in property <[UiLogLine]> log-lines; + in property diagnosis; + in property has-diagnosis: false; + in property selected-session: ""; + + // ── 偏好(持久化键见 SubAgentsTalk/app-DTO通道约定.md)────────── + in-out property tile-size: TileSize.medium; + in-out property instance-view: InstanceView.tiles; + in-out property instance-query: ""; + in-out property settings-section: SettingSection.general; + /// D17 主题三态(auto 跟随系统;light/dark 钉死)。Rust 侧同时写 Theme 全局。 + in-out property theme-mode: ThemeMode.auto; + /// 外观(高级)四件的 in-out 通道:MainWindow ↔ SettingsPage(图片本身走 `Theme.bg-image`) + in-out property bg-image-enabled: false; + in-out property bg-image: ""; + in-out property bg-fill: BgFillKind.cover; + in-out property bg-dim: 0.35; + in-out property panel-alpha: 1.0; + in-out property glass: false; + /// 设置页编辑的十六进制串(`accent-color` 是解析后的颜色,只给 Theme 用) + in-out property accent-hex: ""; + /// 强调色输入是否非法(设置页亮 warn 行) + in-out property accent-invalid: false; + in-out property download-category: DownloadCategory.release; + in-out property download-installed-only: false; + /// 下载页版本号筛选词(输入由页面写回,state.download_query 参与过滤) + in-out property download-query: ""; + + // ── 对话框队列 / Toast ─────────────────────────────────────── + in property dialog; + in property dialog-open: false; + in property dialog-queued: 0; + in property <[UiToast]> toasts; + + // ── 窗口控制(拖拽走 WindowMoveArea,不需要 Rust 参与)──────── + callback toggle-maximize(); + callback minimize(); + callback request-close(); + + // ── 导航 / AI ─────────────────────────────────────────────── + callback navigate(RouteKind); + callback toggle-nav(); + callback toggle-ai(); + callback expand-ai(); + callback dock-ai(); + callback toggle-yolo(); + callback ai-send(string); + callback ai-stop(); + + // ── 实例页 ────────────────────────────────────────────────── + callback instance-launch(string); + callback instance-configure(string); + callback instance-stop(string); + callback instance-open(string); + callback new-instance(); + callback instance-view-changed(InstanceView); + callback tile-size-changed(TileSize); + callback instance-query-changed(string); + + // ── 下载页 ────────────────────────────────────────────────── + callback download-category-changed(DownloadCategory); + callback download-install(string); + callback installed-only-changed(bool); + callback download-query-changed(string); + callback ancient-help(); + + // ── 日志页 ────────────────────────────────────────────────── + callback log-select(string); + callback log-ask-ai(string); + callback log-apply-fix(); + + // ── 设置页 ────────────────────────────────────────────────── + callback settings-section-changed(SettingSection); + callback theme-changed(ThemeMode); + callback bg-image-enabled-changed(bool); + callback bg-image-changed(string); + callback bg-fill-changed(BgFillKind); + callback bg-dim-changed(float); + callback panel-alpha-changed(float); + callback glass-changed(bool); + callback accent-color-changed(string); + callback accent-swatch-picked(int); + callback yolo-changed(bool); + callback mcp-changed(bool); + callback ai-endpoint-changed(int); + + // ── 对话框 / Toast ────────────────────────────────────────── + callback dialog-primary(); + callback dialog-secondary(); + callback dialog-dismissed(); + callback toast-dismissed(string); + + // 窗口底色交给 main.slint 的 Window(不透明 `canvas-solid`),这里只铺背景图层 + background: transparent; + + // 玻璃拟态的对齐基准:整窗逻辑尺寸(components/glass.slint 反向偏移用) + init => { + Theme.window-width = root.width; + Theme.window-height = root.height; + } + changed width => { Theme.window-width = root.width; } + changed height => { Theme.window-height = root.height; } + + // ── ① 自定义窗口背景图(09 §101)──────────────────────────── + // 只在「开关打开且图片已加载」时才画:空 image 的 width 为 0,出厂态零开销。 + if Theme.bg-image.width > 0 : Rectangle { + x: 0; + y: 0; + width: root.width; + height: root.height; + clip: true; + background: Theme.canvas-solid; + + Image { + source: Theme.bg-image; + x: 0; + y: 0; + width: parent.width; + height: parent.height; + // tile 模式由 Rust 预平铺成整窗画布,故这里等同 cover + image-fit: root.bg-fill == BgFillKind.contain ? contain : cover; + } + } + + // ── ② 暗化层(09 §111:保证前景可读;0 = 不压暗)───────────── + if Theme.bg-image.width > 0 && Theme.bg-dim > 0 : Rectangle { + x: 0; + y: 0; + width: root.width; + height: root.height; + background: rgba(0, 0, 0, Theme.bg-dim); + } + + VerticalLayout { + TitleBar { + title: root.app-title; + subtitle: "P0 骨架 · Metro"; + maximized: root.maximized; + toggle-maximize => { root.toggle-maximize(); } + minimize => { root.minimize(); } + request-close => { root.request-close(); } + } + + HorizontalLayout { + // 左侧主导航 + Nav { + active: root.route; + collapsed: root.nav-collapsed; + account-name: root.account-name; + account-initial: root.account-initial; + account-hue: root.account-hue; + navigate(r) => { root.navigate(r); } + toggle-collapsed => { root.toggle-nav(); } + account-clicked => { root.navigate(RouteKind.settings); root.settings-section-changed(SettingSection.account); } + } + + // 内容区(每页独立滚动,路由切换栈语义在 Rust Router) + Rectangle { + horizontal-stretch: 1; + background: Theme.bg-canvas; + + if root.route == RouteKind.instances : InstancesPage { + tiles: root.tiles; + running: root.running-tiles; + view: root.instance-view; + tile-size: root.tile-size; + query: root.instance-query; + launch(id) => { root.instance-launch(id); } + configure(id) => { root.instance-configure(id); } + stop(id) => { root.instance-stop(id); } + open(id) => { root.instance-open(id); } + new-instance => { root.new-instance(); } + view-changed(v) => { + root.instance-view = v; + root.instance-view-changed(v); + } + tile-size-changed(s) => { + root.tile-size = s; + root.tile-size-changed(s); + } + query-changed(q) => { root.instance-query-changed(q); } + } + + if root.route == RouteKind.download : DownloadPage { + versions: root.versions; + category: root.download-category; + installed-only: root.download-installed-only; + query: root.download-query; + install(id) => { root.download-install(id); } + query-changed(q) => { + root.download-query = q; + root.download-query-changed(q); + } + ancient-help => { root.ancient-help(); } + category-changed(c) => { + root.download-category = c; + root.download-category-changed(c); + } + installed-only-changed(v) => { + root.download-installed-only = v; + root.installed-only-changed(v); + } + } + + if root.route == RouteKind.logs : LogsPage { + sessions: root.log-sessions; + lines: root.log-lines; + diagnosis: root.diagnosis; + has-diagnosis: root.has-diagnosis; + selected: root.selected-session; + select(id) => { root.log-select(id); } + ask-ai(id) => { root.log-ask-ai(id); } + apply-fix => { root.log-apply-fix(); } + } + + if root.route == RouteKind.settings : SettingsPage { + section: root.settings-section; + theme-mode: root.theme-mode; + bg-image-enabled: root.bg-image-enabled; + bg-image: root.bg-image; + bg-fill: root.bg-fill; + bg-dim: root.bg-dim; + panel-alpha: root.panel-alpha; + glass: root.glass; + accent-color: root.accent-hex; + accent-invalid: root.accent-invalid; + section-changed(s) => { + root.settings-section = s; + root.settings-section-changed(s); + } + theme-changed(mode) => { + root.theme-mode = mode; + root.theme-changed(mode); + } + bg-image-enabled-changed(v) => { root.bg-image-enabled-changed(v); } + bg-image-changed(path) => { root.bg-image-changed(path); } + bg-fill-changed(fill) => { root.bg-fill-changed(fill); } + bg-dim-changed(v) => { root.bg-dim-changed(v); } + panel-alpha-changed(v) => { root.panel-alpha-changed(v); } + glass-changed(v) => { root.glass-changed(v); } + accent-color-changed(text) => { + root.accent-hex = text; + root.accent-color-changed(text); + } + accent-swatch-picked(index) => { root.accent-swatch-picked(index); } + yolo-changed(v) => { root.yolo-changed(v); } + mcp-changed(v) => { root.mcp-changed(v); } + ai-endpoint-changed(i) => { root.ai-endpoint-changed(i); } + } + + // AI 拆成独立全宽页:与右侧栏共用同一份 ai-messages(切换无损) + if root.route == RouteKind.agent : AiPanel { + docked: false; + messages: root.ai-messages; + collapsed: false; + yolo: root.yolo; + streaming: root.ai-streaming; + endpoint-label: root.ai-endpoint-label; + draft <=> root.ai-draft; + dock => { root.dock-ai(); } + toggle-yolo => { root.toggle-yolo(); } + send(t) => { root.ai-send(t); } + stop => { root.ai-stop(); } + } + + // 实例详情 G4:P0 只留占位(点击磁贴进入) + if root.route == RouteKind.detail : VerticalLayout { + alignment: center; + spacing: Metrics.gap-3; + + UiText { + role: TextRole.title; + text: "实例详情(G4)"; + horizontal-alignment: center; + } + UiText { + role: TextRole.caption; + text: "P0 骨架未实现:概要与设置 tab 在 G4 补齐"; + horizontal-alignment: center; + } + HorizontalLayout { + alignment: center; + MetroButton { + kind: ButtonKind.standard; + text: "返回实例页"; + clicked => { root.navigate(RouteKind.instances); } + } + } + } + } + + // 右侧 AI 常驻栏(拆成独立页时让位给内容区) + AiPanel { + visible: root.route != RouteKind.agent; + docked: true; + collapsed: root.ai-collapsed; + messages: root.ai-messages; + yolo: root.yolo; + streaming: root.ai-streaming; + endpoint-label: root.ai-endpoint-label; + draft <=> root.ai-draft; + toggle-collapsed => { root.toggle-ai(); } + expand => { root.expand-ai(); } + toggle-yolo => { root.toggle-yolo(); } + send(t) => { root.ai-send(t); } + stop => { root.ai-stop(); } + } + } + } + + // 覆盖层:模态队列(一次一条)+ 右下 Toast + DialogHost { + current: root.dialog; + open: root.dialog-open; + queued: root.dialog-queued; + primary => { root.dialog-primary(); } + secondary => { root.dialog-secondary(); } + dismissed => { root.dialog-dismissed(); } + } + + ToastHost { + toasts: root.toasts; + dismissed(id) => { root.toast-dismissed(id); } + } +} diff --git a/crates/app/ui/theme/metrics.slint b/crates/app/ui/theme/metrics.slint new file mode 100644 index 0000000..c0483fa --- /dev/null +++ b/crates/app/ui/theme/metrics.slint @@ -0,0 +1,48 @@ +// 布局度量令牌(D16 定稿的尺寸口径集中在此): +// 导航栏折叠宽度、AI 栏宽度、标题栏高度、磁贴三档尺寸与列数。 +import { TileSize } from "../models.slint"; + +export global Metrics { + // 间距刻度(4 的倍数,Metro 栅格) + out property gap-1: 4px; + out property gap-2: 8px; + out property gap-3: 12px; + out property gap-4: 16px; + out property gap-5: 24px; + out property gap-6: 32px; + + out property radius-sm: 2px; + out property radius-md: 4px; + out property radius-lg: 8px; + + // 主窗口骨架 + out property titlebar-height: 40px; + out property titlebar-button-width: 46px; + out property accent-line: 2px; + out property nav-expanded-width: 88px; + out property nav-collapsed-width: 48px; + out property ai-panel-width: 320px; + out property ai-rail-width: 44px; + out property page-header-height: 56px; + out property running-strip-height: 32px; + out property row-height: 44px; + out property control-height: 32px; + out property touch-target: 32px; + + // 磁贴三档(Q24-3:默认中磁贴 4 列,设置可调小 6 / 大 3) + in-out property tile-size: TileSize.medium; + out property tile-columns: + tile-size == TileSize.small ? 6 + : tile-size == TileSize.large ? 3 + : 4; + out property tile-height: + tile-size == TileSize.small ? 104px + : tile-size == TileSize.large ? 176px + : 136px; + out property tile-icon-size: + tile-size == TileSize.small ? 32px + : tile-size == TileSize.large ? 56px + : 44px; + // 小磁贴只留图标+名字+启动键,藏起徽标与最后游玩时间(空间不足) + out property tile-compact: tile-size == TileSize.small; +} diff --git a/crates/app/ui/theme/tokens.slint b/crates/app/ui/theme/tokens.slint new file mode 100644 index 0000000..1314af1 --- /dev/null +++ b/crates/app/ui/theme/tokens.slint @@ -0,0 +1,208 @@ +// Metro 主题令牌(D14 + D17 主题三态 + 09 §101-104「外观(高级)」):palette + 排版层级 + 字体栈 + +// 动效时长 + 外观令牌(背景图 / 面板半透明度 / 玻璃拟态 / 强调色)。 +// +// 结构约定: +// - `LightPalette` / `DarkPalette` = 两套完整色板(**不是**一套加条件),换肤只改这两处。 +// - `Theme` = 解析器:按主题三态(auto/light/dark)挑一套,并持有**共享**的 accent 与字体/排版。 +// - auto 跟随系统:`Theme.system-theme` 由 Rust 的 `core::platform::theme_probe` 灌入(启动对齐 + +// 未知时兜底轮询),后端支持时另有 `Palette.color-scheme` 实时热切;两者都不认识就按浅色。 +// - 「外观(高级)」四件全部默认关闭:`panel-alpha` 1.0 / `glass` false / `bg-image` 空 / +// `accent-override` 透明——出厂观感与 M17 骨架逐像素一致(09 §111 硬约束)。 +// - 组件只准用 `Theme.*`,不得出现裸色字面量(背景图暗化层是唯一例外,它是「遮罩」而非配色)。 + +import { ThemeMode } from "../models.slint"; +import { Palette } from "std-widgets.slint"; + +// ── 亮色色板 ──────────────────────────────────────────────────── +export global LightPalette { + out property bg-canvas: #F3F3F3; + out property bg-surface: #FFFFFF; + out property bg-elevated: #FFFFFF; + out property bg-titlebar: #EDEDED; + out property bg-hover: #E9E9E9; + out property bg-pressed: #DEDEDE; + out property bg-input: #FFFFFF; + + out property fg-primary: #1A1A1A; + out property fg-secondary: #5F5F5F; + out property fg-disabled: #9A9A9A; + + out property stroke: #E1E1E1; + out property stroke-strong: #C8C8C8; + out property shadow: #00000026; + out property scrim: #00000099; + + out property accent-soft: #E5F1FB; + out property accent-hover: #106EBE; + out property accent-pressed: #005A9E; + + out property state-success: #107C10; + out property state-success-soft: #DFF6DD; + out property state-warn: #9D5D00; + out property state-warn-soft: #FFF4CE; + out property state-danger: #C42B1C; + out property state-danger-hover: #A82214; + out property state-danger-soft: #FDE7E9; + out property state-info-soft: #E7F1FA; +} + +// ── 暗色色板 ──────────────────────────────────────────────────── +export global DarkPalette { + out property bg-canvas: #1B1B1B; + out property bg-surface: #232323; + out property bg-elevated: #2B2B2B; + out property bg-titlebar: #161616; + out property bg-hover: #2F2F2F; + out property bg-pressed: #383838; + out property bg-input: #1E1E1E; + + out property fg-primary: #F2F2F2; + out property fg-secondary: #A6A6A6; + out property fg-disabled: #6B6B6B; + + out property stroke: #3A3A3A; + out property stroke-strong: #4A4A4A; + out property shadow: #000000CC; + out property scrim: #00000099; + + out property accent-soft: #0F2A3F; + out property accent-hover: #2B8FE0; + out property accent-pressed: #005A9E; + + out property state-success: #4CCB4C; + out property state-success-soft: #16321A; + out property state-warn: #FFD24C; + out property state-warn-soft: #3A2E0F; + out property state-danger: #FF6B6B; + out property state-danger-hover: #FF8A8A; + out property state-danger-soft: #3A1A1A; + out property state-info-soft: #10263A; +} + +// ── 解析器 ────────────────────────────────────────────────────── +export global Theme { + // ── 主题三态(D17 设置页「外观」分组,出厂 auto)────────────────── + // D17 主题三态(设置页「外观」分组,出厂 auto) + in-out property theme-mode: ThemeMode.auto; + // 系统主题来源(M41):0 = 未知(此时回落到 Slint 后端 `Palette.color-scheme`), + // 1 = 浅色 / 2 = 深色。由 Rust 的 `core::platform::theme_probe` 探测后灌入: + // 启动时给首帧对齐,之后按需轮询兜底(后端不支持系统主题时 auto 仍可用)。 + in-out property system-theme: 0; + // 后端是否认得系统主题(`unknown` 表示不支持/无 portal)。 + out property system-theme-known: Palette.color-scheme != ColorScheme.unknown; + // 是否深色:显式钉死(light/dark)优先;auto 时用探测值,探测未知才问后端。 + out property dark: theme-mode == ThemeMode.dark + || (theme-mode == ThemeMode.auto + && (system-theme == 2 + || (system-theme != 1 && Palette.color-scheme == ColorScheme.dark))); + + // ── 外观(高级)令牌(09 §101-104 / §111:出厂全部关闭)───────── + // 全局面板半透明度:`bg-*` 令牌统一按 `panel-alpha` 重建(见下方「面板底色」段), + // 组件侧零改动即透出背景图。出厂 1.0 = 完全不透明,Metro 纯色外观与 M17 骨架一致。 + in-out property panel-alpha: 1.0; + // 自定义窗口背景图(空 image = 纯色背景,出厂态)。 + in-out property bg-image; + // 背景图暗化层强度(0~0.95,越大前景越清晰)。Rust 侧夹到 schema 范围。 + in-out property bg-dim: 0.35; + // 玻璃拟态:预生成模糊层(Rust 侧降采样 + box blur),卡片内按 `absolute-position` + // 反向偏移对齐整窗,得到 Slint 版「毛玻璃」(无 backdrop-filter 的替代手法)。 + in-out property glass: false; + in-out property bg-blur; + // 玻璃层对齐用的窗口逻辑尺寸(AppShell 在 init/changed 里灌入)。 + in-out property window-width: 1180px; + in-out property window-height: 760px; + + // ── 强调色(accent 令牌全域生效;两套色板共享)────────────────── + // `accent-override` 的 alpha == 0 表示「未自定义」,走 D14 出厂 Metro 蓝。 + // (color 的 `.alpha` 字段是 0~255 整数,不是 0~1 浮点。) + in-out property accent-override: transparent; + out property accent-custom: accent-override.alpha > 1; + out property accent: accent-custom ? accent-override : #0078D4; + // 自定义时由覆盖色派生 hover/pressed/soft(未自定义仍走双色板常量,出场观感不变)。 + out property accent-hover: accent-custom + ? (dark ? accent.brighter(0.18) : accent.darker(0.12)) + : (dark ? DarkPalette.accent-hover : LightPalette.accent-hover); + out property accent-pressed: accent-custom + ? (dark ? accent.brighter(0.28) : accent.darker(0.24)) + : (dark ? DarkPalette.accent-pressed : LightPalette.accent-pressed); + out property accent-soft: accent-custom + ? accent.mix(surface-solid, 0.82) + : (dark ? DarkPalette.accent-soft : LightPalette.accent-soft); + out property accent-contrast: #FFFFFF; + // accent 上的前景色按相对亮度自动选黑/白(自定义亮黄、浅青时白字会糊)。 + // 注意:Slint 的 color 通道字段(`.red` 等)是 0~255 的**整数**。 + out property accent-luma: accent.red * 0.299 + accent.green * 0.587 + accent.blue * 0.114; + out property fg-on-accent: accent-luma > 160 ? #1A1A1A : #FFFFFF; + + // ── 面板底色(`panel-alpha` 统一透出背景图)──────────────────── + // `*-solid` 是不透明原件(窗口自身底色 / accent.mix 的混合基准必须不透明, + // 否则浮点叠加会让 accent-soft 随 panel-alpha 一起变淡)。 + // 半透明色用 `rgba(r, g, b, a)` 重建而不是 `.with-alpha()`:后者返回 Brush, + // 赋给 `` 属性会类型不符(Slint 无 Brush → color 隐式回退)。 + out property surface-solid: dark ? DarkPalette.bg-surface : LightPalette.bg-surface; + out property canvas-solid: dark ? DarkPalette.bg-canvas : LightPalette.bg-canvas; + out property surface-elevated-solid: dark ? DarkPalette.bg-elevated : LightPalette.bg-elevated; + out property titlebar-solid: dark ? DarkPalette.bg-titlebar : LightPalette.bg-titlebar; + out property hover-solid: dark ? DarkPalette.bg-hover : LightPalette.bg-hover; + out property pressed-solid: dark ? DarkPalette.bg-pressed : LightPalette.bg-pressed; + out property input-solid: dark ? DarkPalette.bg-input : LightPalette.bg-input; + + out property bg-canvas: rgba( + canvas-solid.red, canvas-solid.green, canvas-solid.blue, panel-alpha); + out property bg-surface: rgba( + surface-solid.red, surface-solid.green, surface-solid.blue, panel-alpha); + out property bg-elevated: rgba( + surface-elevated-solid.red, surface-elevated-solid.green, surface-elevated-solid.blue, panel-alpha); + out property bg-titlebar: rgba( + titlebar-solid.red, titlebar-solid.green, titlebar-solid.blue, panel-alpha); + out property bg-hover: rgba( + hover-solid.red, hover-solid.green, hover-solid.blue, panel-alpha); + out property bg-pressed: rgba( + pressed-solid.red, pressed-solid.green, pressed-solid.blue, panel-alpha); + out property bg-input: rgba( + input-solid.red, input-solid.green, input-solid.blue, panel-alpha); + + out property fg-primary: dark ? DarkPalette.fg-primary : LightPalette.fg-primary; + out property fg-secondary: dark ? DarkPalette.fg-secondary : LightPalette.fg-secondary; + out property fg-disabled: dark ? DarkPalette.fg-disabled : LightPalette.fg-disabled; + + out property stroke: dark ? DarkPalette.stroke : LightPalette.stroke; + out property stroke-strong: dark ? DarkPalette.stroke-strong : LightPalette.stroke-strong; + out property shadow: dark ? DarkPalette.shadow : LightPalette.shadow; + out property scrim: dark ? DarkPalette.scrim : LightPalette.scrim; + + out property state-success: dark ? DarkPalette.state-success : LightPalette.state-success; + out property state-success-soft: dark ? DarkPalette.state-success-soft : LightPalette.state-success-soft; + out property state-warn: dark ? DarkPalette.state-warn : LightPalette.state-warn; + out property state-warn-soft: dark ? DarkPalette.state-warn-soft : LightPalette.state-warn-soft; + out property state-danger: dark ? DarkPalette.state-danger : LightPalette.state-danger; + out property state-danger-hover: dark ? DarkPalette.state-danger-hover : LightPalette.state-danger-hover; + out property state-danger-soft: dark ? DarkPalette.state-danger-soft : LightPalette.state-danger-soft; + out property state-info-soft: dark ? DarkPalette.state-info-soft : LightPalette.state-info-soft; + + // ── 字体 ──────────────────────────────────────────────────── + // Segoe UI 不可再分发(微软许可)→ Windows 用系统自带,其他平台用 Selawik + // (微软官方开源、Segoe UI 度量兼容)+ Open Sans 回退栈(01c §7 第 5 条)。 + // Slint 的 font-family 只吃单个家族名,回退链由 Rust 侧启动时按平台选择 + 注册字体族实现: + // windows → "Segoe UI",macos/linux → "Selawik"(随包)+ "Open Sans" 兜底。 + in-out property font-family: "Selawik"; + in-out property font-family-mono: "Cascadia Mono"; + // 中文回退(思源黑体/微软雅黑由系统字体匹配,这里仅作显式声明) + in-out property font-family-cjk: "Microsoft YaHei UI"; + + // 排版层级(Metro typography ramp) + out property font-size-display: 24px; + out property font-size-title: 20px; + out property font-size-subtitle: 16px; + out property font-size-body: 14px; + out property font-size-caption: 12px; + out property line-height-body: 20px; + out property line-height-caption: 16px; + + // ── 动效(Metro 快速、克制)─────────────────────────────────── + out property motion-fast: 90ms; + out property motion-normal: 160ms; + out property motion-slow: 240ms; + // D17 动画强度倍率(P0 占位,P1 接设置页滑条与「减少动画」无障碍开关) + in-out property motion-scale: 1.0; +} diff --git a/crates/app/ui/widgets/button.slint b/crates/app/ui/widgets/button.slint new file mode 100644 index 0000000..7185f87 --- /dev/null +++ b/crates/app/ui/widgets/button.slint @@ -0,0 +1,83 @@ +// Metro 按钮:扁平 + accent 填充,无圆角渐变,hover 只变底色(D14)。 +// 四种语义:accent(主操作)/ standard(次要)/ subtle(工具条)/ danger(危险确认)。 +// grow = true 时占满布局剩余宽度(磁贴的启动键),否则保持内容宽度(工具按钮)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { AppIcon, IconKind } from "../components/icons.slint"; +import { UiText, TextRole } from "../components/label.slint"; + +export enum ButtonKind { + accent, + standard, + subtle, + danger, +} + +export component MetroButton inherits Rectangle { + in property kind: ButtonKind.standard; + in property text: ""; + in property icon: IconKind.plus; + /// 是否显示图标(纯文字按钮置 false) + in property show-icon: false; + in property enabled: true; + /// 图标按钮:不画文字,尺寸压到正方形 + in property icon-only: false; + /// 在布局中占据剩余宽度 + in property grow: false; + in property button-height: Metrics.control-height; + callback clicked(); + + min-width: icon-only ? button-height : text-layout.preferred-width; + height: button-height; + horizontal-stretch: grow ? 1 : 0; + border-radius: Metrics.radius-sm; + border-width: kind == ButtonKind.standard ? 1px : 0px; + border-color: Theme.stroke-strong; + opacity: enabled ? 1.0 : 0.45; + + background: kind == ButtonKind.accent + ? (touch.has-hover ? Theme.accent-hover : Theme.accent) + : kind == ButtonKind.danger + ? (touch.has-hover ? Theme.state-danger-hover : Theme.state-danger) + : kind == ButtonKind.subtle + ? (touch.pressed ? Theme.bg-pressed : touch.has-hover ? Theme.bg-hover : transparent) + : (touch.pressed ? Theme.bg-pressed : touch.has-hover ? Theme.bg-hover : Theme.bg-surface); + animate background { duration: Theme.motion-fast; } + + text-layout := HorizontalLayout { + alignment: center; + padding-left: icon-only ? 0px : Metrics.gap-2; + padding-right: icon-only ? 0px : Metrics.gap-2; + spacing: Metrics.gap-1; + + AppIcon { + icon-size: Metrics.touch-target / 2; + kind: root.icon; + visible: root.show-icon || root.icon-only; + tint: kind == ButtonKind.accent || kind == ButtonKind.danger + ? Theme.fg-on-accent + : Theme.fg-primary; + } + UiText { + role: TextRole.bodyStrong; + text: root.text; + visible: !root.icon-only; + color: kind == ButtonKind.accent || kind == ButtonKind.danger + ? Theme.fg-on-accent + : Theme.fg-primary; + horizontal-alignment: center; + } + } + + touch := TouchArea { + mouse-cursor: root.enabled ? pointer : default; + clicked => { + if (root.enabled) { + root.clicked(); + } + } + } + + accessible-role: button; + accessible-label: root.text; +} diff --git a/crates/app/ui/widgets/dialog.slint b/crates/app/ui/widgets/dialog.slint new file mode 100644 index 0000000..a8522fa --- /dev/null +++ b/crates/app/ui/widgets/dialog.slint @@ -0,0 +1,122 @@ +// 对话框宿主:HMCL 队列语义(一次只显示一条,来源排序由 Rust DialogQueue 决定)+ Metro 化外观。 +// 类型:确认 / 输入 / 进度 / 错误(含诊断入口)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiDialog, DialogKind } from "../models.slint"; +import { UiText, TextRole } from "../components/label.slint"; +import { AppIcon, IconKind } from "../components/icons.slint"; +import { MetroButton, ButtonKind } from "button.slint"; +import { TextField } from "inputs.slint"; + +export component DialogHost inherits Rectangle { + in property current; + in property open: false; + /// 队列中剩余条数(>0 时卡片底部提示「还有 N 条待处理」,语义上是队列而非堆叠) + in property queued: 0; + in-out property input-text: ""; + callback primary(); + callback secondary(); + callback dismissed(); + + width: 100%; + height: 100%; + background: Theme.scrim; + visible: open; + + // 吞掉遮罩上的点击,避免误触下层页面 + TouchArea { + clicked => { root.dismissed(); } + } + + card := Rectangle { + x: (root.width - card.width) / 2; + y: (root.height - card.height) / 2; + width: 440px; + height: content.preferred-height; + background: Theme.bg-elevated; + border-radius: Metrics.radius-lg; + border-width: 1px; + border-color: Theme.stroke; + drop-shadow-blur: 24px; + drop-shadow-color: Theme.shadow; + drop-shadow-offset-y: 6px; + + content := VerticalLayout { + padding: Metrics.gap-5; + spacing: Metrics.gap-3; + + HorizontalLayout { + spacing: Metrics.gap-2; + + if root.current.kind == DialogKind.error : AppIcon { + kind: IconKind.error; + icon-size: 20px; + tint: Theme.state-danger; + } + if root.current.kind == DialogKind.confirm : AppIcon { + kind: IconKind.warn; + icon-size: 20px; + tint: Theme.state-warn; + } + UiText { + role: TextRole.title; + text: root.current.title; + horizontal-stretch: 1; + } + } + + body-text := UiText { + role: TextRole.body; + text: root.current.body; + wrap: word-wrap; + overflow: elide; + color: Theme.fg-secondary; + vertical-alignment: top; + height: body-text.preferred-height; + } + + if root.current.kind == DialogKind.input : TextField { + placeholder: root.current.input_label; + text <=> root.input-text; + } + + if root.current.kind == DialogKind.progress : track := Rectangle { + height: 6px; + border-radius: 3px; + background: Theme.bg-pressed; + + Rectangle { + width: track.width * Math.min(Math.max(root.current.progress, 0), 1); + height: track.height; + border-radius: 3px; + background: Theme.accent; + } + } + + HorizontalLayout { + spacing: Metrics.gap-2; + alignment: end; + padding-top: Metrics.gap-2; + + UiText { + role: TextRole.caption; + text: root.queued > 0 ? "还有 " + root.queued + " 条待处理" : ""; + horizontal-stretch: 1; + horizontal-alignment: left; + } + + if root.current.secondary != "" : MetroButton { + kind: ButtonKind.subtle; + text: root.current.secondary; + clicked => { root.secondary(); } + } + MetroButton { + kind: root.current.kind == DialogKind.error ? ButtonKind.danger : ButtonKind.accent; + text: root.current.primary; + enabled: root.current.kind != DialogKind.progress; + clicked => { root.primary(); } + } + } + } + } +} diff --git a/crates/app/ui/widgets/inputs.slint b/crates/app/ui/widgets/inputs.slint new file mode 100644 index 0000000..b522a00 --- /dev/null +++ b/crates/app/ui/widgets/inputs.slint @@ -0,0 +1,300 @@ +// 基础输入件:单行输入(含占位文本)、搜索框、开关、分段控件、复选框。 +// 一律自绘(Metro 需要方角 + accent 高亮)。占位文本手绘:内置 TextInput 没有 +// placeholder-text(那是 std-widgets 的 LineEdit,外观不可控)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { AppIcon, IconKind } from "../components/icons.slint"; +import { UiText, TextRole } from "../components/label.slint"; + +/// 单行输入:内置 TextInput + 占位文本层(Focus 态由外部读 `focused`)。 +export component PlainInput inherits Rectangle { + in-out property text: ""; + in property placeholder: ""; + in property text-color: Theme.fg-primary; + in property text-padding: 0px; + out property focused: input.has-focus; + callback edited(string); + callback accepted(string); + + background: transparent; + + input := TextInput { + x: root.text-padding; + y: 0; + width: root.width - 2 * root.text-padding; + height: root.height; + color: root.text-color; + font-family: Theme.font-family; + font-size: Theme.font-size-body; + vertical-alignment: center; + single-line: true; + text <=> root.text; + edited => { root.edited(self.text); } + accepted => { root.accepted(self.text); } + } + + if root.text == "" && root.placeholder != "" : UiText { + x: root.text-padding; + y: 0; + width: root.width - 2 * root.text-padding; + height: root.height; + role: TextRole.body; + text: root.placeholder; + color: Theme.fg-disabled; + } +} + +export component SearchField inherits Rectangle { + in-out property text: ""; + in property placeholder: "搜索"; + callback edited(string); + callback accepted(string); + + height: Metrics.control-height; + background: Theme.bg-input; + border-radius: Metrics.radius-sm; + border-width: 1px; + border-color: field.focused ? Theme.accent : Theme.stroke; + + HorizontalLayout { + padding-left: Metrics.gap-2; + padding-right: Metrics.gap-2; + spacing: Metrics.gap-2; + + AppIcon { kind: IconKind.search; icon-size: 14px; } + + field := PlainInput { + placeholder: root.placeholder; + text <=> root.text; + edited(t) => { root.edited(t); } + accepted(t) => { root.accepted(t); } + } + } +} + +export component TextField inherits Rectangle { + in-out property text: ""; + in property placeholder: ""; + callback edited(string); + + height: Metrics.control-height; + background: Theme.bg-input; + border-radius: Metrics.radius-sm; + border-width: 1px; + border-color: field.focused ? Theme.accent : Theme.stroke; + + field := PlainInput { + text-padding: Metrics.gap-2; + placeholder: root.placeholder; + text <=> root.text; + edited(t) => { root.edited(t); } + } +} + +export component ToggleSwitch inherits Rectangle { + in-out property checked: false; + callback toggled(bool); + + width: 40px; + height: 20px; + border-radius: 10px; + background: checked ? Theme.accent : Theme.stroke-strong; + animate background { duration: Theme.motion-normal; } + + Rectangle { + width: 14px; + height: 14px; + y: 3px; + x: root.checked ? root.width - self.width - 3px : 3px; + border-radius: 7px; + background: Theme.fg-on-accent; + animate x { duration: Theme.motion-normal; easing: ease-in-out; } + } + + TouchArea { + mouse-cursor: pointer; + clicked => { + root.checked = !root.checked; + root.toggled(root.checked); + } + } +} + +/// 分段控件(视图切换、磁贴尺寸、主题三态共用) +export component SegmentedControl inherits Rectangle { + in property <[string]> options; + in-out property selected: 0; + callback changed(int); + + height: Metrics.control-height; + background: Theme.bg-canvas; + border-radius: Metrics.radius-sm; + border-width: 1px; + border-color: Theme.stroke; + + HorizontalLayout { + padding: 2px; + spacing: 2px; + for o[i] in options : Rectangle { + border-radius: Metrics.radius-sm; + background: i == root.selected + ? Theme.accent + : (seg.has-hover ? Theme.bg-hover : transparent); + animate background { duration: Theme.motion-fast; } + + UiText { + role: TextRole.caption; + text: o; + horizontal-alignment: center; + color: i == root.selected ? Theme.fg-on-accent : Theme.fg-primary; + } + + seg := TouchArea { + mouse-cursor: pointer; + clicked => { + root.selected = i; + root.changed(i); + } + } + } + } +} + +export component CheckBox inherits Rectangle { in-out property checked: false; + in property text: ""; + callback toggled(bool); + + height: Metrics.touch-target; + background: transparent; + + HorizontalLayout { + spacing: Metrics.gap-2; + + Rectangle { + width: 16px; + height: 16px; + border-radius: Metrics.radius-sm; + border-width: 1px; + border-color: root.checked ? Theme.accent : Theme.stroke-strong; + background: root.checked ? Theme.accent : Theme.bg-input; + + if root.checked : AppIcon { + kind: IconKind.check; + icon-size: 12px; + x: 2px; + y: 2px; + tint: Theme.fg-on-accent; + } + } + + UiText { role: TextRole.body; text: root.text; } + + TouchArea { + mouse-cursor: pointer; + clicked => { + root.checked = !root.checked; + root.toggled(root.checked); + } + } + } +} + +/// 滑条(09 §102/§101 的「强度滑条」:暗化层强度、面板半透明度共用)。 +/// 自绘而非 std-widgets 的 Slider:Metro 需要方头轨道 + accent 填充,且要跟主题令牌一起换肤。 +export component MetroSlider inherits Rectangle { + in property minimum: 0.0; + in property maximum: 1.0; + in-out property value: 1.0; + callback changed(float); + + out property ratio: root.maximum > root.minimum + ? Math.max(0.0, Math.min(1.0, (root.value - root.minimum) / (root.maximum - root.minimum))) + : 0.0; + + width: 200px; + height: Metrics.touch-target; + background: transparent; + + /// 鼠标位置 → 取值(Slint 的 function 不能声明局部变量,所以拆成纯函数; + /// 函数名不用 kebab-case:`value-at(...)` 会被当成减法解析) + pure function valueForX(x: length) -> float { + return root.minimum + + Math.max(0.0, Math.min(1.0, x / root.width)) * (root.maximum - root.minimum); + } + + // 轨道 + 已填充段 + Rectangle { + x: 0; + y: (root.height - 4px) / 2; + width: root.width; + height: 4px; + border-radius: 2px; + background: Theme.stroke-strong; + } + Rectangle { + x: 0; + y: (root.height - 4px) / 2; + width: root.width * root.ratio; + height: 4px; + border-radius: 2px; + background: Theme.accent; + } + // 滑块 + Rectangle { + x: (root.width - 14px) * root.ratio; + y: (root.height - 14px) / 2; + width: 14px; + height: 14px; + border-radius: 7px; + background: Theme.accent; + border-width: 2px; + border-color: Theme.surface-solid; + } + + area := TouchArea { + mouse-cursor: pointer; + moved => { + root.value = root.valueForX(self.mouse-x); + root.changed(root.value); + } + clicked => { + root.value = root.valueForX(self.mouse-x); + root.changed(root.value); + } + } + + accessible-role: slider; +} + +/// 预设色板(09 §104 自定义强调色:预设色板 + 取色器,取色器由十六进制输入承担)。 +export component ColorSwatches inherits Rectangle { + in property <[color]> palette; + in property current; + /// 传**索引**而不是 color:Slint 没有「颜色 → #RRGGBB」的格式化, + /// 预设色板的十六进制串由 Rust 侧同一张表给出(一处真相)。 + callback picked(int); + + width: root.palette.length * 28px; + height: 28px; + background: transparent; + + HorizontalLayout { + spacing: 4px; + + for shade[i] in root.palette : Rectangle { + width: 24px; + height: 24px; + border-radius: Metrics.radius-sm; + background: shade; + border-width: shade == root.current ? 2px : 1px; + border-color: shade == root.current ? Theme.fg-primary : Theme.stroke; + + swatch-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.picked(i); } + } + + accessible-role: button; + } + } +} diff --git a/crates/app/ui/widgets/setting-item.slint b/crates/app/ui/widgets/setting-item.slint new file mode 100644 index 0000000..e2f1f57 --- /dev/null +++ b/crates/app/ui/widgets/setting-item.slint @@ -0,0 +1,155 @@ +// 设置项行:左标签+说明,右控件。控件类型用 ControlKind 收敛,避免为每个设置项写一遍布局。 +// (Slint 没有 slot 机制,所以用 kind 枚举 + 通用属性把控件种类收在一处。) +// +// M41 增加两种控件:`slider`(强度/透明度滑条)与 `swatches`(预设色板 + 取色器入口), +// 它们服务 09 §101-104 的「外观(高级)」四件——高级特性默认关闭,控件本身常显(关着也能调)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiText, TextRole } from "../components/label.slint"; +import { MetroButton, ButtonKind } from "button.slint"; +import { + ToggleSwitch, SegmentedControl, TextField, MetroSlider, ColorSwatches, +} from "inputs.slint"; + +export enum ControlKind { + none, + toggle, + segmented, + button, + text, + slider, + swatches, +} + +export component SettingItem inherits Rectangle { + in property label: ""; + in property description: ""; + in property kind: ControlKind.none; + in property <[string]> options; + in property button-text: ""; + in-out property selected: 0; + in-out property checked: false; + in-out property value: ""; + // 滑条 + in property min-value: 0.0; + in property max-value: 1.0; + in-out property slider-value: 1.0; + // 色板 + in property <[color]> swatch-colors; + in property swatch-current: transparent; + // 危险/提示色描述(如「对比度偏低」) + in property warn: false; + + callback toggled(bool); + callback changed(int); + callback clicked(); + callback edited(string); + callback slid(float); + callback swatch-picked(int); + + height: root.description == "" ? 44px : 60px; + background: transparent; + + HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-4; + spacing: Metrics.gap-4; + + VerticalLayout { + spacing: 0px; + vertical-stretch: 1; + + UiText { + role: TextRole.body; + text: root.label; + horizontal-stretch: 1; + } + UiText { + role: TextRole.caption; + text: root.description; + visible: root.description != ""; + color: root.warn ? Theme.state-warn : Theme.fg-secondary; + horizontal-stretch: 1; + } + } + + if root.kind == ControlKind.toggle : ToggleSwitch { + checked: root.checked; + toggled(v) => { + root.checked = v; + root.toggled(v); + } + } + + if root.kind == ControlKind.segmented : SegmentedControl { + width: root.options.length * 64px; + options: root.options; + selected: root.selected; + changed(i) => { + root.selected = i; + root.changed(i); + } + } + + if root.kind == ControlKind.button : MetroButton { + kind: ButtonKind.standard; + text: root.button-text; + button-height: 28px; + clicked => { root.clicked(); } + } + + if root.kind == ControlKind.text : TextField { + width: 240px; + placeholder: root.button-text; + text <=> root.value; + edited(t) => { root.edited(t); } + } + + if root.kind == ControlKind.slider : HorizontalLayout { + spacing: Metrics.gap-2; + alignment: end; + + UiText { + role: TextRole.caption; + text: (root.slider-value * 100).round() + "%"; + width: 40px; + horizontal-alignment: right; + color: Theme.fg-secondary; + } + MetroSlider { + minimum: root.min-value; + maximum: root.max-value; + value: root.slider-value; + changed(v) => { + root.slider-value = v; + root.slid(v); + } + } + } + + if root.kind == ControlKind.swatches : ColorSwatches { + palette: root.swatch-colors; + current: root.swatch-current; + picked(i) => { root.swatch-picked(i); } + } + } +} + +/// 分组标题 +export component SettingGroup inherits Rectangle { + in property title: ""; + + height: 36px; + background: transparent; + + UiText { + x: Metrics.gap-4; + y: 0; + width: root.width - 2 * Metrics.gap-4; + height: root.height; + role: TextRole.caption; + text: root.title; + color: Theme.fg-secondary; + font-weight: 600; + } +} diff --git a/crates/app/ui/widgets/toast.slint b/crates/app/ui/widgets/toast.slint new file mode 100644 index 0000000..d3a43c6 --- /dev/null +++ b/crates/app/ui/widgets/toast.slint @@ -0,0 +1,87 @@ +// Toast:右下角非阻塞提示(下载完成 / 复制成功 / 后台任务结论)。 +// 队列与自动消失时长由 Rust ToastCenter 管理(P0 默认 4s),这里只负责渲染与手动关闭。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiToast, ToastKind } from "../models.slint"; +import { UiText, TextRole } from "../components/label.slint"; +import { AppIcon, IconKind } from "../components/icons.slint"; + +component ToastCard inherits Rectangle { + in property data; + callback dismissed(string); + + width: 300px; + height: body-layout.preferred-height; + background: Theme.bg-elevated; + border-radius: Metrics.radius-md; + border-width: 1px; + border-color: Theme.stroke; + drop-shadow-blur: 16px; + drop-shadow-color: Theme.shadow; + drop-shadow-offset-y: 3px; + + // 左侧语义色条:Metro 用色块表达状态,不用图标喧宾夺主 + Rectangle { + x: 0px; + y: 0px; + width: 3px; + height: root.height; + background: root.data.kind == ToastKind.success ? Theme.state-success + : root.data.kind == ToastKind.warn ? Theme.state-warn + : root.data.kind == ToastKind.error ? Theme.state-danger + : Theme.accent; + } + + body-layout := HorizontalLayout { + padding-left: Metrics.gap-3; + padding-right: Metrics.gap-2; + padding-top: Metrics.gap-2; + padding-bottom: Metrics.gap-2; + spacing: Metrics.gap-2; + + UiText { + role: TextRole.body; + text: root.data.text; + wrap: word-wrap; + horizontal-stretch: 1; + } + + AppIcon { + kind: IconKind.close; + icon-size: 14px; + tint: Theme.fg-secondary; + + TouchArea { + mouse-cursor: pointer; + clicked => { root.dismissed(root.data.id); } + } + } + } +} + +export component ToastHost inherits Rectangle { + in property <[UiToast]> toasts; + callback dismissed(string); + + width: 100%; + height: 100%; + background: transparent; + + stack-box := Rectangle { + x: root.width - stack-box.width - Metrics.gap-4; + y: root.height - stack-box.height - Metrics.gap-4; + width: 300px; + height: stack.preferred-height; + background: transparent; + + stack := VerticalLayout { + width: stack-box.width; + spacing: Metrics.gap-2; + + for t in root.toasts : ToastCard { + data: t; + dismissed(id) => { root.dismissed(id); } + } + } + } +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 0000000..dc84ef3 --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "unlucky-cli" +description = "Unlucky-Launcher 命令行(bin `ull`):headless 消费者,零 UI 框架依赖(D18)" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +publish.workspace = true + +# 产物名钉死 `ull`(D18 定案):单文件 CLI,与 GUI 的 `unlucky-launcher` 并列。 +[[bin]] +name = "ull" +path = "src/main.rs" + +[lib] +name = "unlucky_cli" +path = "src/lib.rs" + +# 依赖纪律(10-cli-design.md §2):core + agent,**不引 Slint**。 +# 参数解析手写(不引 clap):子命令集小,没必要为它多引一棵依赖树(Win10+ 单基线, +# 但 CLI 是 headless/服务器场景的产物,体积与启动开销仍是关注项)。 +[dependencies] +unlucky-core = { path = "../core", version = "0.1" } +# agent:`redact`(输出脱敏)+ `ull agent serve`(M39 的 MCP server:注册表 → rmcp 双投影, +# core 侧五个端口在 src/commands/agent/ports/ 装配)。 +unlucky-agent = { path = "../agent", version = "0.1" } +# `ull agent serve` 要长驻跑 rmcp 的 stdio / Streamable HTTP 传输(tokio 运行时)。 +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true + +[lints] +workspace = true diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs new file mode 100644 index 0000000..5f8b5a0 --- /dev/null +++ b/crates/cli/src/cli/help.rs @@ -0,0 +1,155 @@ +//! 帮助文案(`ull --help` / `ull help <命令>`)。 +//! +//! 命令集与 `10-cli-design.md` §3 对齐;`agent serve|config`(M39 的 MCP server)已列, +//! `agent ask`(内置对话的 headless 形态)仍未列。 + +/// 总帮助。 +#[must_use] +pub const fn general() -> &'static str { + "\ +ull · Unlucky-Launcher 命令行(D18) + +用法: + ull [全局选项] <命令> [参数] + +命令: + instances list 列出实例与已安装版本 + install [--loader X] [--loader-version V] [--name N] [--dry-run] + 下载并安装版本本体(--loader fabric|quilt|neoforge|forge + 时再叠一层加载器)→ 登记实例;--dry-run 只出计划 + launch [--dry-run] [--account ] [--java ] [--username ] + [--max-memory ] 启动实例;--dry-run 只打印组装后的命令行 + accounts list 列出账户(名称 / 轨道 / UUID,**不含凭据**) + accounts add offline 添加离线账户(HMCL 兼容 UUID) + accounts add msa 添加微软账户(设备码交互式登录) + accounts remove 删除账户(连同凭据) + sessions list [--limit N] 列出会话记录(倒序) + logs [--tail N] 打印会话事件流末尾 + diag [--tail N] 对会话跑规则引擎诊断 + config get [] [--show-secret] 读配置(生效值;无 key 时输出整份) + config set 写配置(走 setting schema 校验) + java list 列出可用 Java(含每个 JDK 的实际落点) + java install [--vendor X] 安装托管 JDK(缺省按地区回退链选发行版; + --vendor temurin|oracle|microsoft|zulu 钉死一家) + java vendors 列出四家发行版的镜像/支持版本/当前优先级 + agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP; + [--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读) + agent config [--port N] 打印 Claude Code / Cursor 的配置样例 + version 版本 + 系统摘要(同 --version) + help [命令] 本帮助 + +全局选项: + --json 机器可读输出:成功 {\"ok\":true,\"data\":…},失败 {\"ok\":false,\"error\":…} + --data-dir P 覆盖数据根(等价 custom 模式的根目录,即含 .ULL/ 与 .minecraft/ 的父目录) + --no-color 关闭颜色(v1 输出不含 ANSI,参数保留以兼容脚本) + --quiet 静默人类可读输出(错误与提示仍进 stderr) + --help, -h 帮助(有位置参数时按该命令给帮助) + --version, -V 版本 + 系统摘要 + +退出码: 0 成功 / 1 用户错误 / 2 内部错误" +} + +/// 单个命令的帮助;未知主题返回 `None`(调用方退回总帮助)。 +#[must_use] +pub fn topic(name: &str) -> Option<&'static str> { + match name { + "instances" => Some( + "\ +ull instances list + +列出 profiles.json 里的实例与 versions/ 下已安装的版本。 + --json 输出 {\"data\":{\"instances\":[…],\"installed_versions\":[…],\"data_root\":…}}", + ), + "install" => Some( + "\ +ull install [--loader X] [--loader-version V] [--name N] [--dry-run] + +解析版本清单与继承链 → 组下载计划 → 落盘(versions/ assets/ libraries/)→ 登记实例 +(profiles.json + config/instances/.json)。已存在且校验通过的文件会跳过, +重复装同一版本不重复下载、不重复建实例。 +--dry-run:只输出计划(文件清单 + 总大小 + 每类资源的源选择),不下载、不登记。 +--loader:vanilla(缺省)/ fabric / quilt / neoforge / forge。装加载器时先生成 +versions/<原版>-<加载器>-<版本>/<…>.json(inheritsFrom 原版 + 主类 + loader 库), +原版没装会先装原版,实例的 versionId 指向 loader 版本。 +--loader-version:加载器版本;缺省取官方默认(Fabric 系首个稳定版,maven 系最新)。 + Forge 可写 47.2.0 或 1.20.1-47.2.0 两种形态。 + NeoForge/Forge 里需要跑官方 installer processors 的版本会真跑一遍(M35 已实装): + 缺 Java 时按 D23 的回退链自动装一个托管 JDK;--dry-run 只列出要跑的 processor 与依赖库。 +--json:stdout 是单个结果信封 {\"data\":{\"plan\":…,\"result\":…,\"instance\":…}}, +实时进度改走 stderr 的 JSONL(一行一条 {\"event\":\"progress\"|\"file_failed\"}); +--quiet 时连进度也不打。", + ), + "launch" => Some( + "\ +ull launch [--dry-run] [--account ] [--java ] [--username ] + [--max-memory ] + + 可以是实例 id、实例名,或直接是版本 id。 +--dry-run(D18):只组装命令行并打印,不 spawn 进程、不写会话文件、不刷新令牌。 +账户:--account 指定账户(玩家名或条目 id);不给时用唯一账户,一个都没有则回落 +离线占位(--username 或 Player),有多个账户会报错并列出候选。 +accessToken 一律脱敏(输出里出现的是 )。", + ), + "accounts" => Some( + "\ +ull accounts list +ull accounts add offline +ull accounts add msa +ull accounts remove + +账户存在 <数据根>/.ULL/data/auth/:accounts.json(可公开元数据)与 credentials.dat(凭据) +**分文件**存放,先写凭据后写元数据、原子写、凭据文件 POSIX 侧 0600。 +凭据永不打印:list 只输出名称/轨道/UUID。 +add msa 走微软 device-code(用户码打在 stderr,去 https://www.microsoft.com/link 输码), +需要先配置 ULL_MSA_CLIENT_ID(Azure 公共客户端 id)。 +authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CLI 不开这条通道)。", + ), + "sessions" => Some("ull sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"), + "logs" => Some("ull logs [--tail N] 允许唯一前缀;输出会话 JSONL 末尾 N 条事件(缺省 200)"), + "diag" => Some( + "ull diag [--tail N] 把会话的退出信号与日志喂给规则引擎(内嵌规则集),输出结构化诊断", + ), + "config" => Some( + "\ +ull config get [] [--show-secret] +ull config set + +读写 <数据根>/.ULL/config/launcher.json,走 core::setting 的 schema(类型/枚举校验、 +原子写)。敏感键 get 时脱敏,--show-secret 才给明文;值先按 JSON 解析,失败按字符串。", + ), + "java" => Some( + "\ +ull java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java + 并打印每个 JDK 的实际落点(managed 项带目录) +ull java install [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证 +ull java vendors 列出四家发行版的镜像/支持版本/当前优先级顺序 + +落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走): + portable(默认) <数据根>/.ULL/data/java-managed/jdk--/ + standard Windows %APPDATA%\\Java\\<发行方>\\<版本>\\;macOS ~/Library/Application Support/Java/… + Linux $XDG_DATA_HOME/java/… + custom 设置 java.installRoot 后按 /<发行方>/<版本>/(覆盖前两者) + +发行版回退链(D23):国内 temurin(TUNA) → oracle(华为云→南大) → microsoft(aka.ms) +→ zulu;国外 temurin → microsoft → oracle → zulu。设置 java.vendorPriority 可改顺序 +(只写几家也行,其余按默认序补齐);--vendor 钉死一家、不自动降级。 +每家失败的原因(版本不存在 / 镜像全 4xx / 校验失败)进 --json 的 chain 块与人读的「降级:」行。", + ), + "agent" => Some( + "\ +ull agent serve [--stdio] [--port N] [--host H] [--allow-write|--readonly] +ull agent config [--port N] + +把 agent 的工具注册表挂成 **MCP server**(02 文档 §1 的第二形态:同一注册表、同一安全策略、 +同一审计)。默认走 Streamable HTTP,只绑回环地址(127.0.0.1,默认端口 47823); +--stdio 是给 llama.cpp server 的内置 MCP client 用的形态。 + +写权限默认**关**(只读子集):要放开需设置键 agent.mcp.allow_write=true 或 --allow-write。 +敏感键(High 分级)在 MCP 场景**默认拒绝**(外部客户端没有确认弹窗):让用户去设置页改, +或在设置 → AI 里打开 YOLO(审计照记 yolo=true)。 +令牌:设置键 agent.mcp.token;非空时要求 Authorization: Bearer 。 +`agent config` 直接给出 Claude Code {\"type\":\"http\",\"url\":…} 与 Cursor {\"url\":…} 的样例。", + ), + _ => None, + } +} diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs new file mode 100644 index 0000000..aee3416 --- /dev/null +++ b/crates/cli/src/cli/mod.rs @@ -0,0 +1,17 @@ +//! 命令行解析(手写,不引 clap —— 子命令集小,省一棵依赖树)。 +//! +//! 语法:`ull [全局选项] <命令> [子命令/参数]`。全局选项可以出现在任意位置 +//! (脚本里 `ull instances list --json` 与 `ull --json instances list` 等价)。 +//! +//! 帮助与版本是「提前返回」的伪命令:`--help` / `-h` 出现在任何位置都立即给帮助, +//! 有位置参数时按该主题给帮助。 + +pub mod help; +pub mod model; +pub mod parse; + +pub use model::{ + AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs, + DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, LogsArgs, +}; +pub use parse::parse; diff --git a/crates/cli/src/cli/model.rs b/crates/cli/src/cli/model.rs new file mode 100644 index 0000000..702889d --- /dev/null +++ b/crates/cli/src/cli/model.rs @@ -0,0 +1,196 @@ +//! 命令模型(纯数据):解析结果就是这里的一个 [`Invocation`]。 + +use std::path::PathBuf; + +/// 全局选项(所有命令共享)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Globals { + /// `--json`:机器可读输出。 + pub json: bool, + /// `--no-color`:关闭颜色。v1 输出本身不含 ANSI,参数保留以兼容脚本。 + pub no_color: bool, + /// `--quiet`:静默人类可读输出(错误仍进 stderr)。 + pub quiet: bool, + /// `--data-dir `:覆盖数据根(等价 custom 模式的 `custom_root`)。 + pub data_dir: Option, +} + +/// 一条命令。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + /// 无子命令:版本摘要 + 帮助(用户原话「无子命令时 --version + --help」)。 + Welcome, + /// 只打印版本 + 系统摘要。 + Version, + /// 帮助(`Some(topic)` 给该子命令的帮助)。 + Help(Option), + /// `instances list`。 + InstancesList, + /// `install [--loader X] [--name N]`。 + Install(InstallArgs), + /// `launch [--dry-run]`。 + Launch(LaunchArgs), + /// `sessions list [--limit N]`。 + SessionsList { + /// 最多返回几条。 + limit: usize, + }, + /// `logs [--tail N]`。 + Logs(LogsArgs), + /// `diag `。 + Diag(DiagArgs), + /// `config get []`。 + ConfigGet(ConfigGetArgs), + /// `config set `。 + ConfigSet(ConfigSetArgs), + /// `java list`。 + JavaList, + /// `java install [--vendor X]`。 + JavaInstall { + /// 目标 JDK 主版本(8 / 17 / 21)。 + major: u32, + /// 钉死的发行版(缺省走 D23 的回退链)。 + vendor: Option, + }, + /// `java vendors`:列出各发行版的可用性 / 镜像 / 当前优先级。 + JavaVendors, + /// `accounts list`。 + AccountsList, + /// `accounts add []`。 + AccountsAdd(AccountsAddArgs), + /// `accounts remove `。 + AccountsRemove { + /// 账户名(或条目 id)。 + name: String, + }, + /// `agent serve`:对外 MCP server(长驻)。 + AgentServe(AgentServeArgs), + /// `agent config`:打印 Claude Code / Cursor 的客户端配置样例。 + AgentConfig { + /// 覆盖端点端口(默认取设置键 `agent.mcp.port`)。 + port: Option, + }, +} + +/// `agent serve` 的参数(命令行只做覆盖,默认值来自设置键 `agent.mcp.*`)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AgentServeArgs { + /// 走 stdio 形态(llama.cpp 内置 MCP client 用);缺省是 Streamable HTTP。 + pub stdio: bool, + /// 覆盖端口。 + pub port: Option, + /// 覆盖监听地址(只接受回环地址,非回环会被 server 拒绝)。 + pub host: Option, + /// 本次运行放开写工具(仍需设置键 `agent.mcp.allow_write` 或本 flag 之一)。 + pub allow_write: bool, +} + +/// `accounts add` 的轨道。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccountsAddKind { + /// 离线(`add offline `)。 + Offline, + /// 微软 device-code(`add msa`,交互式)。 + Microsoft, +} + +impl AccountsAddKind { + /// 稳定名(帮助文案与 JSON 输出用)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Offline => "offline", + Self::Microsoft => "msa", + } + } +} + +/// `accounts add` 的参数。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountsAddArgs { + /// 轨道。 + pub kind: AccountsAddKind, + /// 离线轨的玩家名(微软轨不需要)。 + pub name: Option, +} + +/// `install` 的参数。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallArgs { + /// 目标版本 id(`1.20.1` / `1.20.1-forge-47.2.0`)。 + pub version: String, + /// 加载器(`vanilla` 缺省 / `fabric` / `quilt` / `neoforge` / `forge`)。 + pub loader: Option, + /// 加载器版本(缺省取官方默认:Fabric 系首个稳定版,maven 系该原版下最新)。 + pub loader_version: Option, + /// 实例显示名(缺省用版本 id)。 + pub name: Option, + /// 只出安装计划(列清单 + 总大小 + 源选择),不下载、不登记。 + pub dry_run: bool, + /// 是否往 stderr 打实时进度(`--quiet` 下关掉)。 + pub progress: bool, + /// 实时进度用 JSONL 形态(`--json` 时开,stderr 一行一条,stdout 仍是单个信封)。 + pub json_progress: bool, +} + +/// `launch` 的参数。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaunchArgs { + /// 实例 id 或实例名;也可以直接给版本 id。 + pub instance: String, + /// 只组装命令行,不 spawn(D18 的 `--dry-run`)。 + pub dry_run: bool, + /// Java 可执行文件覆盖。 + pub java: Option, + /// 离线账户名(缺省 `Player`)。 + pub username: Option, + /// 账户名(`accounts` 里的玩家名或条目 id);缺省用唯一账户。 + pub account: Option, + /// 最大堆(MB)。 + pub max_memory: Option, +} + +/// `logs` 的参数。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogsArgs { + /// 会话 id(允许唯一前缀)。 + pub session_id: String, + /// 只打印末尾 N 条事件(缺省 200)。 + pub tail: usize, +} + +/// `diag` 的参数。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiagArgs { + /// 会话 id(允许唯一前缀)。 + pub session_id: String, + /// 送入规则引擎的日志行上限(缺省 2000)。 + pub tail: usize, +} + +/// `config get` 的参数。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigGetArgs { + /// 点分键路径;`None` 表示整份生效配置。 + pub key: Option, + /// 敏感键也输出明文(默认脱敏)。 + pub show_secret: bool, +} + +/// `config set` 的参数。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigSetArgs { + /// 点分键路径。 + pub key: String, + /// 值的原始文本(先按 JSON 解析,失败按字符串)。 + pub value: String, +} + +/// 一次调用的完整输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Invocation { + /// 全局选项。 + pub globals: Globals, + /// 要执行的命令。 + pub command: Command, +} diff --git a/crates/cli/src/cli/parse.rs b/crates/cli/src/cli/parse.rs new file mode 100644 index 0000000..1c1b4d6 --- /dev/null +++ b/crates/cli/src/cli/parse.rs @@ -0,0 +1,365 @@ +//! 手写解析器:全局选项 → 子命令 → 子命令内的选项。 + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use unlucky_core::game::java::JdkVendor; + +use crate::error::CliError; + +mod accounts; +mod agent; + +use super::model::{ + Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, + LogsArgs, +}; + +/// `logs` 缺省尾巴行数。 +const DEFAULT_LOG_TAIL: usize = 200; +/// `diag` 缺省送入规则引擎的行数。 +const DEFAULT_DIAG_TAIL: usize = 2000; +/// `sessions list` 缺省条数。 +const DEFAULT_SESSION_LIMIT: usize = 20; + +/// 解析整条命令行(不含 argv[0])。 +/// +/// # Errors +/// +/// 用法错误(未知命令/选项、缺参数、数字解析失败)返回 [`CliError`](退出码 1)。 +pub fn parse(args: &[String]) -> Result { + let mut globals = Globals::default(); + let mut rest: Vec = Vec::new(); + let mut help_requested = false; + let mut version_requested = false; + let mut index = 0; + + while index < args.len() { + let arg = args[index].as_str(); + match arg { + "--json" => globals.json = true, + "--no-color" => globals.no_color = true, + "--quiet" | "-q" => globals.quiet = true, + // 帮助/版本是「伪命令」,但不提前返回:`--help` 出现在命令前面时, + // 主题要从后面收完的位置参数里取(`ull --help launch` 与 `ull launch --help` 等价)。 + "--help" | "-h" => help_requested = true, + "--version" | "-V" => version_requested = true, + "--data-dir" => { + index += 1; + globals.data_dir = Some(PathBuf::from(take_value(args, index, "data-dir")?)); + } + _ => match arg.strip_prefix("--data-dir=") { + Some(value) => globals.data_dir = Some(PathBuf::from(value)), + None => rest.push(arg.to_string()), + }, + } + index += 1; + } + + if help_requested { + return Ok(Invocation { + globals, + command: Command::Help(rest.first().cloned()), + }); + } + if version_requested { + return Ok(Invocation { + globals, + command: Command::Version, + }); + } + + let command = parse_command(&rest, &globals)?; + Ok(Invocation { globals, command }) +} + +fn parse_command(rest: &[String], globals: &Globals) -> Result { + let Some(name) = rest.first() else { + return Ok(Command::Welcome); + }; + let tail = &rest[1..]; + match name.as_str() { + "version" => Ok(Command::Version), + "help" => Ok(Command::Help(tail.first().cloned())), + "instances" => parse_instances(tail), + "install" => parse_install(tail, globals), + "launch" => parse_launch(tail), + "sessions" => parse_sessions(tail), + "logs" => parse_logs(tail), + "diag" => parse_diag(tail), + "config" => parse_config(tail), + "java" => parse_java(tail), + "accounts" => accounts::parse_accounts(tail), + "agent" => agent::parse_agent(tail), + other => { + Err(CliError::usage(format!("未知命令 `{other}`")).with_hint("ull --help 查看命令列表")) + } + } +} + +fn parse_instances(tail: &[String]) -> Result { + let args = match tail.first().map(String::as_str) { + None | Some("list") => tail.get(1..).unwrap_or_default(), + Some(other) => { + return Err(CliError::usage(format!( + "instances 的子命令只有 list,收到 `{other}`" + ))); + } + }; + let parsed = parse_flags(args, &[], &[])?; + ensure_no_positionals(&parsed, "instances list")?; + Ok(Command::InstancesList) +} + +fn parse_install(tail: &[String], globals: &Globals) -> Result { + let parsed = parse_flags(tail, &["loader", "loader-version", "name"], &["dry-run"])?; + let version = require_single_positional(&parsed, "install ")?; + Ok(Command::Install(InstallArgs { + version, + loader: parsed.value("loader"), + loader_version: parsed.value("loader-version"), + name: parsed.value("name"), + dry_run: parsed.has("dry-run"), + // 实时进度进 stderr:人类模式是文本行,`--json` 模式是 JSONL(stdout 保持干净), + // `--quiet` 一律不打。 + progress: !globals.quiet, + json_progress: globals.json, + })) +} + +fn parse_launch(tail: &[String]) -> Result { + let parsed = parse_flags( + tail, + &["java", "username", "account", "max-memory"], + &["dry-run"], + )?; + let instance = require_single_positional(&parsed, "launch ")?; + let max_memory = match parsed.value("max-memory") { + Some(raw) => Some( + raw.parse::() + .map_err(|_| CliError::usage(format!("--max-memory 需要整数 MB,收到 `{raw}`")))?, + ), + None => None, + }; + Ok(Command::Launch(LaunchArgs { + instance, + dry_run: parsed.has("dry-run"), + java: parsed.value("java").map(PathBuf::from), + username: parsed.value("username"), + account: parsed.value("account"), + max_memory, + })) +} + +fn parse_sessions(tail: &[String]) -> Result { + let args = match tail.first().map(String::as_str) { + None | Some("list") => tail.get(1..).unwrap_or_default(), + Some(other) => { + return Err(CliError::usage(format!( + "sessions 的子命令只有 list,收到 `{other}`" + ))); + } + }; + let parsed = parse_flags(args, &["limit"], &[])?; + ensure_no_positionals(&parsed, "sessions list")?; + let limit = match parsed.value("limit") { + Some(raw) => raw + .parse::() + .map_err(|_| CliError::usage(format!("--limit 需要整数,收到 `{raw}`")))?, + None => DEFAULT_SESSION_LIMIT, + }; + Ok(Command::SessionsList { limit }) +} + +fn parse_logs(tail: &[String]) -> Result { + let parsed = parse_flags(tail, &["tail"], &[])?; + let session_id = require_single_positional(&parsed, "logs ")?; + let tail_lines = optional_count(&parsed, "tail", DEFAULT_LOG_TAIL)?; + Ok(Command::Logs(LogsArgs { + session_id, + tail: tail_lines, + })) +} + +fn parse_diag(tail: &[String]) -> Result { + let parsed = parse_flags(tail, &["tail"], &[])?; + let session_id = require_single_positional(&parsed, "diag ")?; + let tail_lines = optional_count(&parsed, "tail", DEFAULT_DIAG_TAIL)?; + Ok(Command::Diag(DiagArgs { + session_id, + tail: tail_lines, + })) +} + +fn parse_config(tail: &[String]) -> Result { + let Some(sub) = tail.first().map(String::as_str) else { + return Err(CliError::usage("config 需要子命令 get|set") + .with_hint("ull config get / ull config set ")); + }; + let rest = tail.get(1..).unwrap_or_default(); + match sub { + "get" => { + let parsed = parse_flags(rest, &[], &["show-secret"])?; + if parsed.positionals.len() > 1 { + return Err(CliError::usage("config get 最多接受一个键路径")); + } + Ok(Command::ConfigGet(ConfigGetArgs { + key: parsed.positionals.first().cloned(), + show_secret: parsed.has("show-secret"), + })) + } + "set" => { + let parsed = parse_flags(rest, &[], &[])?; + if parsed.positionals.len() != 2 { + return Err(CliError::usage( + "config set 需要键路径与值:ull config set ", + )); + } + Ok(Command::ConfigSet(ConfigSetArgs { + key: parsed.positionals[0].clone(), + value: parsed.positionals[1].clone(), + })) + } + other => Err(CliError::usage(format!( + "config 的子命令只有 get|set,收到 `{other}`" + ))), + } +} + +fn parse_java(tail: &[String]) -> Result { + match tail.first().map(String::as_str) { + None | Some("list") => { + ensure_no_extra(tail.get(1..).unwrap_or_default(), "java list")?; + Ok(Command::JavaList) + } + Some("vendors") => { + ensure_no_extra(tail.get(1..).unwrap_or_default(), "java vendors")?; + Ok(Command::JavaVendors) + } + Some("install") => { + let parsed = parse_flags(tail.get(1..).unwrap_or_default(), &["vendor"], &[])?; + let raw = require_single_positional(&parsed, "java install ")?; + let major = raw + .parse::() + .map_err(|_| CliError::usage(format!("JDK 主版本需要整数,收到 `{raw}`")))?; + let vendor = match parsed.value("vendor") { + Some(text) => Some(JdkVendor::parse(&text).ok_or_else(|| { + CliError::usage(format!( + "--vendor 只有 temurin|oracle|microsoft|zulu,收到 `{text}`" + )) + })?), + None => None, + }; + Ok(Command::JavaInstall { major, vendor }) + } + Some(other) => Err(CliError::usage(format!( + "java 的子命令只有 list|install|vendors,收到 `{other}`" + ))), + } +} + +/// 单个位置参数(多一个少一个都算用法错误)。 +fn require_single_positional(parsed: &ParsedArgs, usage: &str) -> Result { + match parsed.positionals.as_slice() { + [only] => Ok(only.clone()), + [] => Err(CliError::usage(format!("缺少参数:ull {usage}"))), + _ => Err(CliError::usage(format!("参数过多:ull {usage}"))), + } +} + +fn optional_count(parsed: &ParsedArgs, name: &str, default: usize) -> Result { + match parsed.value(name) { + Some(raw) => raw + .parse::() + .map_err(|_| CliError::usage(format!("--{name} 需要整数,收到 `{raw}`"))), + None => Ok(default), + } +} + +fn ensure_no_extra(args: &[String], usage: &str) -> Result<(), CliError> { + if args.is_empty() { + Ok(()) + } else { + Err(CliError::usage(format!( + "{usage} 不接受参数:{}", + args.join(" ") + ))) + } +} + +fn ensure_no_positionals(parsed: &ParsedArgs, usage: &str) -> Result<(), CliError> { + ensure_no_extra(&parsed.positionals, usage) +} + +/// 解析后的一条子命令参数。 +struct ParsedArgs { + positionals: Vec, + flags: BTreeMap>, +} + +impl ParsedArgs { + fn value(&self, name: &str) -> Option { + self.flags.get(name).cloned().flatten() + } + + fn has(&self, name: &str) -> bool { + self.flags.contains_key(name) + } +} + +/// 取值形 flag 的下一 token。 +/// +/// 下一个 token 若以 `--` 开头,说明用户漏了值(`ull launch x --java --dry-run`), +/// 此时报用法错误,而不是把 flag 当值吞掉后在下游报出费解的「找不到 Java」。 +/// 负数(`-1`)不以 `--` 开头,不会被误伤。 +fn take_value(args: &[String], index: usize, name: &str) -> Result { + let value = args + .get(index) + .ok_or_else(|| CliError::usage(format!("--{name} 需要一个值")))?; + if value.starts_with("--") { + return Err(CliError::usage(format!( + "--{name} 需要一个值,但收到选项 `{value}`" + ))); + } + Ok(value.clone()) +} + +/// `--flag` / `--flag=value` / `--flag value` + 位置参数。 +fn parse_flags(args: &[String], valued: &[&str], flags: &[&str]) -> Result { + let mut parsed = ParsedArgs { + positionals: Vec::new(), + flags: BTreeMap::new(), + }; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + let Some(body) = arg.strip_prefix("--") else { + parsed.positionals.push(arg.clone()); + index += 1; + continue; + }; + let (name, inline) = match body.split_once('=') { + Some((name, value)) => (name, Some(value.to_string())), + None => (body, None), + }; + if valued.contains(&name) { + let value = match inline { + Some(value) => value, + None => { + index += 1; + take_value(args, index, name)? + } + }; + parsed.flags.insert(name.to_string(), Some(value)); + } else if flags.contains(&name) { + if inline.is_some() { + return Err(CliError::usage(format!("--{name} 不接受值"))); + } + parsed.flags.insert(name.to_string(), None); + } else { + return Err(CliError::usage(format!("未知选项 --{name}"))); + } + index += 1; + } + Ok(parsed) +} diff --git a/crates/cli/src/cli/parse/accounts.rs b/crates/cli/src/cli/parse/accounts.rs new file mode 100644 index 0000000..c24b96f --- /dev/null +++ b/crates/cli/src/cli/parse/accounts.rs @@ -0,0 +1,68 @@ +//! `ull accounts` 子命令的解析(从 `parse.rs` 拆出,避免解析器主文件顶到 400 行)。 +//! +//! 语法: +//! - `ull accounts [list]` +//! - `ull accounts add offline ` +//! - `ull accounts add msa`(别名 `microsoft`) +//! - `ull accounts remove `(别名 `rm`) +//! +//! 只开这三条轨道入口:authlib 的密码登录不走 CLI(密码进 argv 会泄露,见 +//! `DevDocs/SubAgentsTalk/auth-接口约定.md` §4)。 + +use super::{ensure_no_positionals, parse_flags, require_single_positional}; +use crate::cli::model::{AccountsAddArgs, AccountsAddKind, Command}; +use crate::error::CliError; + +/// 解析 `accounts ...`。 +pub(super) fn parse_accounts(tail: &[String]) -> Result { + let Some(sub) = tail.first().map(String::as_str) else { + return Ok(Command::AccountsList); + }; + let rest = tail.get(1..).unwrap_or_default(); + match sub { + "list" => { + let parsed = parse_flags(rest, &[], &[])?; + ensure_no_positionals(&parsed, "accounts list")?; + Ok(Command::AccountsList) + } + "add" => parse_add(rest), + "remove" | "rm" => { + let parsed = parse_flags(rest, &[], &[])?; + let name = require_single_positional(&parsed, "accounts remove ")?; + Ok(Command::AccountsRemove { name }) + } + other => Err(CliError::usage(format!( + "accounts 的子命令只有 list|add|remove,收到 `{other}`" + ))), + } +} + +fn parse_add(rest: &[String]) -> Result { + let Some(kind) = rest.first().map(String::as_str) else { + return Err( + CliError::usage("accounts add 需要轨道:offline|msa").with_hint( + "ull accounts add offline <玩家名> / ull accounts add msa(设备码交互式登录)", + ), + ); + }; + let parsed = parse_flags(rest.get(1..).unwrap_or_default(), &[], &[])?; + match kind { + "offline" => { + let name = require_single_positional(&parsed, "accounts add offline ")?; + Ok(Command::AccountsAdd(AccountsAddArgs { + kind: AccountsAddKind::Offline, + name: Some(name), + })) + } + "msa" | "microsoft" => { + ensure_no_positionals(&parsed, "accounts add msa")?; + Ok(Command::AccountsAdd(AccountsAddArgs { + kind: AccountsAddKind::Microsoft, + name: None, + })) + } + other => Err(CliError::usage(format!( + "accounts add 的轨道只有 offline|msa,收到 `{other}`" + ))), + } +} diff --git a/crates/cli/src/cli/parse/agent.rs b/crates/cli/src/cli/parse/agent.rs new file mode 100644 index 0000000..74df2f4 --- /dev/null +++ b/crates/cli/src/cli/parse/agent.rs @@ -0,0 +1,66 @@ +//! `agent` 子命令解析(`serve` / `config`)。 +//! +//! 与 `parse::accounts` 同样的拆分理由:`parse.rs` 挨着 400 行硬上限, +//! 而且「MCP server 的参数」本来就是一撮独立的东西,混在主解析器里迟早长成一片。 + +use crate::cli::model::{AgentServeArgs, Command}; +use crate::cli::parse::ParsedArgs; +use crate::error::CliError; + +use super::{ensure_no_positionals, parse_flags}; + +/// 解析 `ull agent `。 +/// +/// # Errors +/// 缺子命令、未知子命令、未知选项、端口不是 1~65535 时返回用法错误。 +pub fn parse_agent(tail: &[String]) -> Result { + let Some(sub) = tail.first().map(String::as_str) else { + return Err(CliError::usage("agent 需要子命令 serve|config") + .with_hint("ull agent serve / ull agent config")); + }; + let rest = tail.get(1..).unwrap_or_default(); + match sub { + "serve" => parse_serve(rest), + "config" => parse_config(rest), + other => Err(CliError::usage(format!( + "agent 的子命令只有 serve|config,收到 `{other}`" + ))), + } +} + +fn parse_serve(rest: &[String]) -> Result { + let parsed = parse_flags( + rest, + &["port", "host"], + &["stdio", "allow-write", "readonly"], + )?; + ensure_no_positionals(&parsed, "agent serve")?; + if parsed.has("allow-write") && parsed.has("readonly") { + return Err(CliError::usage( + "--allow-write 与 --readonly 不能同时给(二选一)", + )); + } + Ok(Command::AgentServe(AgentServeArgs { + stdio: parsed.has("stdio"), + port: optional_port(&parsed)?, + host: parsed.value("host"), + allow_write: parsed.has("allow-write"), + })) +} + +fn parse_config(rest: &[String]) -> Result { + let parsed = parse_flags(rest, &["port"], &[])?; + ensure_no_positionals(&parsed, "agent config")?; + Ok(Command::AgentConfig { + port: optional_port(&parsed)?, + }) +} + +fn optional_port(parsed: &ParsedArgs) -> Result, CliError> { + match parsed.value("port") { + Some(raw) => Ok(Some(raw.parse::().map_err(|_| { + CliError::usage(format!("--port 需要 1~65535,收到 `{raw}`")) + })?)), + None => Ok(None), + } +} diff --git a/crates/cli/src/commands/accounts.rs b/crates/cli/src/commands/accounts.rs new file mode 100644 index 0000000..9f133d6 --- /dev/null +++ b/crates/cli/src/commands/accounts.rs @@ -0,0 +1,198 @@ +//! `ull accounts list|add|remove`:账户元数据操作,**凭据永不打印**。 +//! +//! 设计约束(D9/Q21 + D4 凭据红线): +//! - 只开四条路径:`list` / `add offline ` / `add msa` / `remove `; +//! authlib 的密码登录**故意不开 CLI 通道**(密码进 argv 会直接进 shell 历史与 +//! `ps` 输出,破坏红线;留给 GUI 输入框); +//! - 输出只经 `AccountMeta`(可公开字段),`Report`/`--json` 里没有任何 token 字段; +//! - device-code 的 `user_code`/URL 打到 **stderr**(不管 `--quiet` 都要打:不打用户没法完成登录)。 + +use serde_json::json; +use unlucky_core::auth::account::{AccountKind, AccountMeta, Credentials}; +use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth}; +use unlucky_core::auth::offline::offline_meta; +use unlucky_core::auth::{AccountStore, AuthJournal}; + +use crate::cli::{AccountsAddArgs, AccountsAddKind}; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::{progress_line, Report}; + +/// `accounts list`:列出账户(名称/轨道/UUID/创建时间)。 +/// +/// # Errors +/// +/// 数据根解析失败(在 `Ctx`)或读盘 IO 失败。 +pub fn list(ctx: &Ctx) -> Result { + let store = open_store(ctx); + let mut human = Vec::new(); + if store.is_empty() { + human.push("还没有账户。".to_string()); + human.push(" 离线:ull accounts add offline <玩家名>".to_string()); + human.push(" 微软:ull accounts add msa(设备码交互式登录)".to_string()); + } else { + human.push(format!("账户({} 个):", store.accounts().len())); + for meta in store.accounts() { + human.push(format!( + " {} · {} · {}", + meta.display_name(), + meta.kind.label(), + meta.profile_id + )); + } + } + for warning in store.warnings() { + human.push(format!("提示: {warning}")); + } + let accounts: Vec = store.accounts().iter().map(account_json).collect(); + let data = json!({ + "accounts": accounts, + "count": accounts.len(), + "auth_dir": store.paths().root().display().to_string(), + "warnings": store.warnings(), + }); + Ok(Report::new(data, human)) +} + +/// `accounts add offline `。 +/// +/// # Errors +/// +/// 名字不合法、同名账户已存在、落盘失败。 +pub fn add(ctx: &Ctx, args: &AccountsAddArgs) -> Result { + match args.kind { + AccountsAddKind::Offline => add_offline(ctx, args.name.as_deref().unwrap_or_default()), + AccountsAddKind::Microsoft => add_microsoft(ctx), + } +} + +fn add_offline(ctx: &Ctx, name: &str) -> Result { + let meta = offline_meta(name, now_ms()).map_err(super::map_auth_error)?; + let mut store = open_store(ctx); + store + .insert(meta.clone(), Credentials::Offline) + .map_err(super::map_auth_error)?; + store.save().map_err(super::map_auth_error)?; + let human = vec![ + format!( + "已添加离线账户 {}(HMCL 兼容 UUID {})", + meta.display_name(), + meta.profile_id + ), + format!("账户文件:{}", store.paths().accounts_file().display()), + ]; + Ok(Report::new(added_json(&meta), human)) +} + +/// `accounts add msa`:device-code 交互式登录。 +fn add_microsoft(ctx: &Ctx) -> Result { + // 失败日志落**本次命令的实际数据根**(`ctx.layout().logs()`):`MicrosoftAuth::from_env()` + // 默认挂的是便携根(exe 同级 `.ULL/logs`),`--data-dir` / launcher.json 的 standard 模式下 + // 那是别人的目录,排障时找不到 auth.jsonl(M38 遗留 finding)。 + let auth = MicrosoftAuth::from_env() + .map_err(super::map_auth_error)? + .with_journal(Some(AuthJournal::in_logs_dir(ctx.layout().logs()))); + // 设备码要先给用户看,再开始等:core 的 `present` 回调正好卡在这个时间点。 + let session = unlucky_core::download::blocking::block_on(auth.device_code_login(|prompt| { + let (headline, code) = prompt.lines(); + progress_line(&headline); + progress_line(&code); + })) + .map_err(super::map_auth_error)?; + + let meta = AccountMeta::new( + AccountKind::Microsoft, + session.profile_id.clone(), + session.profile_name.clone(), + now_ms(), + ); + let mut store = open_store(ctx); + store + .insert(meta.clone(), Credentials::Microsoft(session)) + .map_err(super::map_auth_error)?; + store.save().map_err(super::map_auth_error)?; + let human = vec![ + format!( + "已添加微软账户 {}(档案 UUID {})", + meta.display_name(), + meta.profile_id + ), + format!("账户文件:{}", store.paths().accounts_file().display()), + ]; + Ok(Report::new(added_json(&meta), human)) +} + +/// `accounts remove `。 +/// +/// # Errors +/// +/// 找不到该账户、落盘失败。 +pub fn remove(ctx: &Ctx, name: &str) -> Result { + let mut store = open_store(ctx); + let meta = store.remove(name).map_err(super::map_auth_error)?; + store.save().map_err(super::map_auth_error)?; + let human = vec![format!( + "已删除账户 {}({})", + meta.display_name(), + meta.kind.label() + )]; + let data = json!({ + "removed": account_json(&meta), + "count": store.accounts().len(), + }); + Ok(Report::new(data, human)) +} + +fn open_store(ctx: &Ctx) -> AccountStore { + AccountStore::open(ctx.auth_data()) +} + +fn added_json(meta: &AccountMeta) -> serde_json::Value { + json!({ + "account": account_json(meta), + "account_id": meta.id.as_str(), + }) +} + +/// 账户元数据 → JSON(**只含可公开字段**)。 +fn account_json(meta: &AccountMeta) -> serde_json::Value { + json!({ + "name": meta.display_name(), + "profile_name": meta.profile_name, + "id": meta.id.as_str(), + "kind": meta.kind.as_str(), + "profile_id": meta.profile_id, + "login_name": meta.login_name, + "server_url": meta.server_url, + "created_at_ms": meta.created_at_ms, + }) +} + +#[cfg(test)] +mod tests { + use super::account_json; + use unlucky_core::auth::account::{AccountKind, AccountMeta}; + + #[test] + fn account_json_has_no_credential_fields() { + let mut meta = AccountMeta::new( + AccountKind::Microsoft, + "0f8fad5bd9cb469fa16570867728950e", + "Steve", + 7, + ); + meta.login_name = Some("me@example.com".to_string()); + let value = account_json(&meta); + let keys: Vec<&str> = value + .as_object() + .expect("对象") + .keys() + .map(String::as_str) + .collect(); + for forbidden in ["access_token", "refresh_token", "token", "password"] { + assert!(!keys.contains(&forbidden), "不该出现 {forbidden}"); + } + assert_eq!(value["name"], "Steve"); + assert_eq!(value["kind"], "microsoft"); + } +} diff --git a/crates/cli/src/commands/agent/mod.rs b/crates/cli/src/commands/agent/mod.rs new file mode 100644 index 0000000..59edf29 --- /dev/null +++ b/crates/cli/src/commands/agent/mod.rs @@ -0,0 +1,135 @@ +//! `ull agent`:对外 MCP server 的 headless 入口(02 文档 §1 的第二形态)。 +//! +//! 两个子命令: +//! - `agent serve`:长驻服务(`--stdio` 或 Streamable HTTP on 127.0.0.1); +//! - `agent config`:打印 Claude Code / Cursor 的配置样例(含当前端口与令牌状态)。 +//! +//! 为什么 `serve` 不能走 `commands::run` 的「返回 Report 再打印」套路:stdio 形态下 +//! **stdout 就是 JSON-RPC 流**,任何一行多余输出都会让客户端解析失败。所以 runner +//! 里对 `Command::AgentServe` 提前分流,不走 `output::print_report`。 + +pub mod ports; +pub mod serve; + +use serde_json::{json, Value}; +use unlucky_agent::mcp::config_samples; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +use crate::cli::AgentServeArgs; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +/// `ull agent serve`:起服务(阻塞)。 +/// +/// # Errors +/// 见 [`serve::run`]。 +pub fn run_serve(ctx: &Ctx, args: &AgentServeArgs) -> Result { + serve::run(ctx, args) +} + +/// runner 的分流入口:解析数据根 → 起服务。错误由 runner 统一打印。 +/// +/// # Errors +/// 数据根缺失、平台不达基线、端口占用、非回环监听等返回用户错误。 +pub fn run_serve_with( + globals: &crate::cli::Globals, + args: &AgentServeArgs, + err: &mut dyn std::io::Write, +) -> Result { + let ctx = Ctx::resolve(globals)?; + ctx.ensure_supported()?; + for note in ctx.notes() { + let _ = writeln!(err, "提示: {note}"); + } + run_serve(&ctx, args) +} + +/// `ull agent config`:输出两份可粘贴的客户端配置 + 当前设置状态。 +/// +/// # Errors +/// 配置不可读时返回用户错误(schema 不认识的键不影响)。 +pub fn config_report(ctx: &Ctx, port: Option) -> Result { + let store = SettingStore::load(ctx.launcher_config(), launcher_schema()); + let doc = store.doc(); + let enabled = doc + .effective("agent.mcp.enabled") + .and_then(Value::as_bool) + .unwrap_or(false); + let allow_write = doc + .effective("agent.mcp.allow_write") + .and_then(Value::as_bool) + .unwrap_or(false); + let yolo = doc + .effective("ai.yolo") + .and_then(Value::as_bool) + .unwrap_or(false); + let token = doc + .effective_str("agent.mcp.token") + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_string); + let configured_port = doc + .effective("agent.mcp.port") + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) + .unwrap_or(47823); + let port = port.unwrap_or(configured_port); + let url = format!("http://127.0.0.1:{port}/mcp"); + let samples = config_samples(&url, token.as_deref()); + let registry = unlucky_agent::tools::build_v1_registry(&ports::build(ctx)) + .map_err(|error| CliError::internal("agent_registry", error.to_string()))?; + let read_only_tools = registry.read_only_definitions().len(); + let all_tools = registry.len(); + + let data = json!({ + "url": url, + "enabled": enabled, + "allow_write": allow_write, + "yolo": yolo, + "token_configured": token.is_some(), + "read_only_tools": read_only_tools, + "all_tools": all_tools, + "claude_code": samples.claude_code, + "cursor": samples.cursor, + }); + + let mut human = vec![ + format!("MCP 端点:{url}"), + format!( + "开关:{} · 写权限:{} · YOLO:{} · 令牌:{}", + if enabled { + "已启用" + } else { + "未启用(serve 仍可手动起)" + }, + if allow_write { + "开" + } else { + "关(只读)" + }, + if yolo { "开" } else { "关" }, + if token.is_some() { + "已设置" + } else { + "未设置" + } + ), + "Claude Code(~/.claude.json 或 `claude mcp add --json`):".to_string(), + ]; + human.extend(pretty_lines(&samples.claude_code)); + human.push("Cursor(.cursor/mcp.json):".to_string()); + human.extend(pretty_lines(&samples.cursor)); + human.push( + "启动:ull agent serve(默认 Streamable HTTP) / ull agent serve --stdio".to_string(), + ); + Ok(Report::new(data, human)) +} + +fn pretty_lines(value: &Value) -> Vec { + match serde_json::to_string_pretty(value) { + Ok(text) => text.lines().map(|line| format!(" {line}")).collect(), + Err(_) => vec![" (序列化失败)".to_string()], + } +} diff --git a/crates/cli/src/commands/agent/ports/accounts.rs b/crates/cli/src/commands/agent/ports/accounts.rs new file mode 100644 index 0000000..39673d4 --- /dev/null +++ b/crates/cli/src/commands/agent/ports/accounts.rs @@ -0,0 +1,49 @@ +//! 账户端口:只读 `AccountStore::accounts()`(公开字段)。**凭据红线在这里落闸**。 +//! +//! `AccountStore::credentials()` 存在,但本文件**不调用**它——agent 侧也没有能承接 +//! `Credentials` 的类型([`unlucky_agent::port::AccountSummary`])。这条界线不是靠自觉: +//! 只要有人想从工具结果里带出 token,就必须先改 `AccountSummary`,而 `port::game` 的 +//! 单测会先炸(序列化结果里不许出现 token/password/secret)。 + +use unlucky_agent::port::game::{AccountPort, AccountSummary}; +use unlucky_agent::port::PortResult; +use unlucky_core::auth::AccountStore; + +/// M31 账户库的只读端口。 +#[derive(Debug, Clone)] +pub struct CoreAccounts { + root: std::path::PathBuf, +} + +impl CoreAccounts { + /// `root` = `<数据根>/.ULL/data/auth`。 + #[must_use] + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } +} + +impl AccountPort for CoreAccounts { + fn list_accounts(&self) -> PortResult> { + let store = AccountStore::open(self.root.clone()); + if !store.warnings().is_empty() { + // 账户文件有坏条目:不阻断(用户还能看到其余账户),但要留痕到 stderr。 + eprintln!( + "[unlucky-agent] 账户库有 {} 条提示(只读本工具不受影响)", + store.warnings().len() + ); + } + Ok(store + .accounts() + .iter() + .map(|meta| AccountSummary { + id: meta.id.as_str().to_string(), + kind: meta.kind.as_str().to_string(), + profile_id: meta.profile_id.clone(), + profile_name: meta.profile_name.clone(), + login_name: meta.login_name.clone(), + created_at_ms: meta.created_at_ms, + }) + .collect()) + } +} diff --git a/crates/cli/src/commands/agent/ports/diagnose.rs b/crates/cli/src/commands/agent/ports/diagnose.rs new file mode 100644 index 0000000..0143f0e --- /dev/null +++ b/crates/cli/src/commands/agent/ports/diagnose.rs @@ -0,0 +1,187 @@ +//! 诊断端口:调用 M15 的规则引擎(**规则先跑,模型补位**,02 §3.2)。 +//! +//! 工具层只把「规则结论 + 相关片段」交给模型,不把裸日志倒进去——这是 token 预算 +//! 与结论质量的双重考虑,也是 analyse/02 §8.3 的分层思路。 + +use std::collections::BTreeMap; + +use unlucky_agent::port::{ + CrashDiagnosis, DiagnosePort, DiagnosisCandidate, ExitSummary, FixAction, FixActionKind, + PortResult, +}; +use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin}; +use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput, Finding}; +use unlucky_core::diagnose::loader::RuleLoader; +use unlucky_core::diagnose::rules::FixActionKind as CoreFixKind; + +use super::events::{tail_join, SessionSource}; + +/// 送进规则引擎的默认尾巴行数(与 CLI `diag` 同值,避免两条路看到不同证据)。 +pub const DEFAULT_TAIL: usize = 2000; + +/// M15 规则引擎的端口实现。 +#[derive(Debug, Clone)] +pub struct CoreDiagnose { + source: SessionSource, + tail: usize, +} + +impl CoreDiagnose { + /// `root` = `<数据根>/.ULL/data/agent/sessions`。 + #[must_use] + pub fn new(root: impl Into) -> Self { + Self { + source: SessionSource::new(root), + tail: DEFAULT_TAIL, + } + } +} + +fn map_kind(kind: CoreFixKind) -> FixActionKind { + match kind { + CoreFixKind::ChangeJava => FixActionKind::ChangeJava, + CoreFixKind::AdjustMemory => FixActionKind::AdjustMemory, + CoreFixKind::ChangeGraphics => FixActionKind::ChangeGraphics, + CoreFixKind::RemoveMod => FixActionKind::RemoveMod, + CoreFixKind::Reinstall => FixActionKind::Reinstall, + CoreFixKind::SeekHelp => FixActionKind::SeekHelp, + } +} + +fn candidate_of(finding: &Finding) -> DiagnosisCandidate { + let core_candidate = finding.to_candidate(); + DiagnosisCandidate { + id: core_candidate.id, + severity: core_candidate.severity.to_string(), + category: core_candidate.category.to_string(), + confidence: core_candidate.confidence, + message_key: core_candidate.message_key, + message: core_candidate.message, + detail: core_candidate.detail.unwrap_or_default(), + evidence: core_candidate.evidence, + fix_actions: core_candidate + .fix_actions + .into_iter() + .map(|action| FixAction { + kind: map_kind(action.kind), + tool: action.tool.to_string(), + label: String::new(), + args: action + .args + .into_iter() + .collect::>(), + }) + .collect(), + } +} + +impl DiagnosePort for CoreDiagnose { + fn diagnose(&self, session_id: &str) -> PortResult { + let entry = self.source.find(session_id)?; + let events = self.source.events(&entry)?; + let (stdout, stderr, error_lines) = SessionSource::split_text(&events); + + let exit = ExitSignal::classify( + entry.exit_code, + Some(entry.duration_ms), + error_lines, + &stderr.join("\n"), + ); + let mut input = DiagnosisInput::new().with_exit(exit); + let stdout_text = tail_join(&stdout, self.tail); + if !stdout_text.is_empty() { + input.push_text(SignalOrigin::Stdout, stdout_text); + } + let stderr_text = tail_join(&stderr, self.tail); + if !stderr_text.is_empty() { + input.push_text(SignalOrigin::Stderr, stderr_text); + } + + let loader = RuleLoader::embedded(); + let diagnoser = Diagnoser::new(loader.rule_set().rules().to_vec()); + let report = diagnoser.analyze(&input); + + Ok(CrashDiagnosis { + session_id: entry.session_id.clone(), + source: "rules".to_string(), + needs_more_info: report.needs_more_info(), + candidates: report.findings.iter().map(candidate_of).collect(), + secondary: report.secondary.iter().map(candidate_of).collect(), + evidence: stderr.iter().rev().take(3).cloned().collect(), + exit: ExitSummary { + code: entry.exit_code, + exit_type: entry.exit_type.clone(), + silent_failure: entry.silent_failure, + }, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn seed(root: &std::path::Path, stderr_line: &str) { + let game = root.join("game"); + std::fs::create_dir_all(&game).expect("建目录"); + let entry = json!({ + "v": 1, + "session_id": "20261029T124501-9f3c2a", + "instance_id": "inst-1", + "instance_name": "1.20.1 Forge", + "game_version": "1.20.1", + "started_at": "2026-10-29T12:45:01.123Z", + "ended_at": "2026-10-29T12:45:05.123Z", + "duration_ms": 4000, + "exit_code": 1, + "exit_type": "application_error", + "silent_failure": false, + "window_detected": false, + "event_count": 1, + "log_path": "20261029T124501-9f3c2a.jsonl", + "crash_found": true, + "diagnosis_top": null + }); + std::fs::write(game.join("index.jsonl"), format!("{entry}\n")).expect("写索引"); + let event = json!({ + "seq": 0, "ts": "2026-10-29T12:45:01.123Z", "t_ms": 0, + "kind": "stderr", + "payload": { "line": stderr_line, "level": "error" } + }); + std::fs::write( + game.join("20261029T124501-9f3c2a.jsonl"), + format!("{event}\n"), + ) + .expect("写事件"); + } + + #[test] + fn runs_the_rule_engine_over_a_session() { + let root = std::env::temp_dir().join(format!("ull-mcp-diagnose-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + seed( + &root, + "java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher", + ); + + let port = CoreDiagnose::new(root.clone()); + let report = port.diagnose("20261029T124501-9f3c2a").expect("应能诊断"); + assert_eq!(report.session_id, "20261029T124501-9f3c2a"); + assert_eq!(report.source, "rules"); + assert_eq!(report.exit.code, Some(1)); + // 规则集里应有 JVM/完整性类结论命中这条 stderr + assert!(!report.candidates.is_empty(), "应至少命中一条规则"); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn unknown_session_is_a_readable_error() { + let root = + std::env::temp_dir().join(format!("ull-mcp-diagnose-none-{}", std::process::id())); + let port = CoreDiagnose::new(root); + let err = port.diagnose("nope").unwrap_err(); + assert!(err.contains("找不到会话"), "{err}"); + } +} diff --git a/crates/cli/src/commands/agent/ports/events.rs b/crates/cli/src/commands/agent/ports/events.rs new file mode 100644 index 0000000..d313200 --- /dev/null +++ b/crates/cli/src/commands/agent/ports/events.rs @@ -0,0 +1,136 @@ +//! 会话数据的读取底座:索引(`index.jsonl`)+ 事件流(`.jsonl`)。 +//! +//! 三个端口([`super::session::CoreSessions`] 的列表/切片、[`super::diagnose::CoreDiagnose`] +//! 的规则跑批)共用这一份读取逻辑——「同一份 jsonl 两种读法」若各写一遍, +//! 迟早出现「列表说有 5 条事件、诊断只看到 3 条」这种对不上的账。 +//! +//! 形状依据 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§5。 + +use std::path::{Path, PathBuf}; + +use serde_json::Value; +use unlucky_core::launch::session_index::{self, SessionIndexEntry}; + +use unlucky_agent::port::PortResult; + +/// 一个数据根下的会话索引 + 事件流。 +#[derive(Debug, Clone)] +pub struct SessionSource { + root: PathBuf, +} + +impl SessionSource { + /// `root` = `<数据根>/.ULL/data/agent/sessions`。 + #[must_use] + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + /// 索引文件路径。 + #[must_use] + pub fn index(&self) -> PathBuf { + session_index::index_path(&self.root) + } + + /// 全部会话(按 `started_at` 倒序,同 id 取最后一行)。 + /// + /// # Errors + /// 索引读不了(权限/IO)时返回用户可读错误;不存在视为空列表。 + pub fn entries(&self) -> PortResult> { + let index = self.index(); + let mut entries = session_index::read_all(&index) + .map_err(|error| format!("读会话索引 {} 失败:{error}", index.display()))?; + entries.sort_by(|a, b| b.started_at.cmp(&a.started_at)); + Ok(entries) + } + + /// 按完整 id 或唯一前缀找会话。 + /// + /// # Errors + /// 找不到 / 前缀歧义时返回用户可读错误。 + pub fn find(&self, key: &str) -> PortResult { + let entries = self.entries()?; + if let Some(entry) = entries.iter().find(|entry| entry.session_id == key) { + return Ok(entry.clone()); + } + let matches: Vec<&SessionIndexEntry> = entries + .iter() + .filter(|entry| entry.session_id.starts_with(key)) + .collect(); + match matches.as_slice() { + [only] => Ok((*only).clone()), + [] => Err(format!( + "找不到会话 `{key}`(索引:{})", + self.index().display() + )), + _ => Err(format!( + "`{key}` 匹配到 {} 个会话,请给更长的前缀", + matches.len() + )), + } + } + + /// 一个会话的原始事件行(解析失败的行跳过,容忍被 kill 时截断的尾行)。 + /// + /// # Errors + /// 事件流文件读不了时返回用户可读错误。 + pub fn events(&self, entry: &SessionIndexEntry) -> PortResult> { + let path = self.event_path(entry); + let text = std::fs::read_to_string(&path) + .map_err(|error| format!("读会话事件流 {} 失败:{error}", path.display()))?; + Ok(text + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect()) + } + + /// 事件流文件路径。 + #[must_use] + pub fn event_path(&self, entry: &SessionIndexEntry) -> PathBuf { + self.root.join("game").join(&entry.log_path) + } + + /// 事件里的 stdout / stderr 文本(诊断与日志阅读共用)。 + /// + /// 返回 `(stdout 行, stderr 行, stderr 里 level=error 的条数)`。 + #[must_use] + pub fn split_text(events: &[Value]) -> (Vec, Vec, u32) { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut error_lines = 0u32; + for event in events { + let Some(line) = event.pointer("/payload/line").and_then(Value::as_str) else { + continue; + }; + match event.get("kind").and_then(Value::as_str) { + Some("stdout") => stdout.push(line.to_string()), + Some("stderr") => { + stderr.push(line.to_string()); + if event.pointer("/payload/level").and_then(Value::as_str) == Some("error") { + error_lines += 1; + } + } + _ => {} + } + } + (stdout, stderr, error_lines) + } +} + +/// 取末尾 `tail` 行并拼成文本(诊断输入用)。 +#[must_use] +pub fn tail_join(lines: &[String], tail: usize) -> String { + let start = lines.len().saturating_sub(tail.max(1)); + let mut text = lines[start..].join("\n"); + if !text.is_empty() { + text.push('\n'); + } + text +} + +/// 索引里有没有这个会话(存在性判断,不读事件流)。 +#[must_use] +pub fn index_exists(root: &Path) -> bool { + session_index::index_path(root).is_file() +} diff --git a/crates/cli/src/commands/agent/ports/game.rs b/crates/cli/src/commands/agent/ports/game.rs new file mode 100644 index 0000000..cd845e6 --- /dev/null +++ b/crates/cli/src/commands/agent/ports/game.rs @@ -0,0 +1,204 @@ +//! 实例 / 版本 / Java 端口(M14 + M33)。 + +use std::path::PathBuf; + +use unlucky_agent::port::game::{ + GamePort, InstanceSummary, JavaRuntimeSummary, JavaStatus, VersionSummary, +}; +use unlucky_agent::port::PortResult; +use unlucky_core::game::java::{self, JavaOrigin}; +use unlucky_core::game::{install::instance::read_profiles, GameLayout}; +use unlucky_core::platform::data_dir::DataMode; +use unlucky_core::platform::layout::LauncherLayout; +use unlucky_core::platform::paths::{Platform, ProcessEnv}; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +/// 实例 / 版本 / Java 的只读端口。 +#[derive(Debug, Clone)] +pub struct CoreGame { + layout: LauncherLayout, + game: GameLayout, + launcher_config: PathBuf, + mode: DataMode, + platform: Platform, +} + +impl CoreGame { + /// 装配:`layout` 是 `.ULL/`,`game` 是 `.minecraft/`。 + #[must_use] + pub fn new( + layout: LauncherLayout, + game: GameLayout, + mode: DataMode, + platform: Platform, + ) -> Self { + let launcher_config = layout.launcher_config(); + Self { + layout, + game, + launcher_config, + mode, + platform, + } + } + + fn store(&self) -> SettingStore { + SettingStore::load(&self.launcher_config, launcher_schema()) + } + + fn managed_roots(&self) -> Vec { + let store = self.store(); + let install_root = store + .doc() + .effective_str("java.installRoot") + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(PathBuf::from); + java::location::scan_roots( + self.mode, + &self.layout, + install_root.as_deref(), + self.platform, + &ProcessEnv, + ) + } +} + +impl GamePort for CoreGame { + fn list_instances(&self) -> PortResult> { + let profiles = + read_profiles(&self.layout).map_err(|error| format!("读实例清单失败:{error}"))?; + let mut out: Vec = profiles + .profiles + .iter() + .map(|profile| InstanceSummary { + id: profile.id.as_str().to_string(), + name: profile.name.clone(), + version_id: profile.version_id.clone(), + version_present: self.game.is_version_installed(&profile.version_id), + game_dir: profile.game_dir.as_ref().map(|p| p.display().to_string()), + java_path: profile.java_path.as_ref().map(|p| p.display().to_string()), + last_played_at: profile.last_played_at.as_deref().and_then(parse_rfc3339_ms), + }) + .collect(); + // `profiles.json` 的顺序是 UI 顺序;这里按最近启动时间倒序(最近的先看)。 + out.sort_by_key(|instance| std::cmp::Reverse(instance.last_played_at)); + Ok(out) + } + + fn list_versions(&self) -> PortResult> { + let versions = self + .game + .installed_versions() + .map_err(|error| format!("读已安装版本失败:{error}"))?; + let profiles = read_profiles(&self.layout).map_err(|error| error.to_string())?; + Ok(versions + .into_iter() + .map(|id| { + let instance_count = profiles + .profiles + .iter() + .filter(|profile| profile.version_id == id) + .count(); + VersionSummary { + id, + kind: None, + instance_count, + } + }) + .collect()) + } + + fn java_status(&self) -> PortResult { + let roots = self.managed_roots(); + let mut candidates = java::candidates_in_roots(&roots); + if let Ok(profiles) = read_profiles(&self.layout) { + for profile in &profiles.profiles { + if let Some(path) = &profile.java_path { + candidates.push(java::probe(path, JavaOrigin::Instance)); + } + } + } + let candidates = java::discover::dedupe(candidates); + let summary: Vec = candidates + .iter() + .map(|runtime| JavaRuntimeSummary { + binary: runtime.binary.display().to_string(), + version: runtime.version.clone(), + major: runtime.major, + vendor: runtime.vendor.clone(), + origin: runtime.origin.category().to_string(), + }) + .collect(); + + let default_memory = self + .store() + .effective("java.default_memory_mb") + .and_then(serde_json::Value::as_u64); + + Ok(JavaStatus { + default_memory_mb: default_memory, + managed_roots: roots + .iter() + .map(|root| root.display().to_string()) + .collect(), + candidates: summary, + }) + } +} + +/// RFC3339 → Unix 毫秒(解析失败返回 `None`,不让一个坏时间戳毁掉整个列表)。 +/// +/// 只处理 launch 写出来的那一种形态(总是 UTC、总是 `Z` 结尾)。不引第三方日期库: +/// agent 侧只需要「排序用的毫秒」,多一棵依赖树不值当。 +fn parse_rfc3339_ms(text: &str) -> Option { + let body = text.trim().trim_end_matches('Z'); + let (date, time) = body.split_once('T')?; + let mut date_parts = date.split('-'); + let year: i64 = date_parts.next()?.parse().ok()?; + let month: i64 = date_parts.next()?.parse().ok()?; + let day: i64 = date_parts.next()?.parse().ok()?; + let mut time_parts = time.split(':'); + let hour: i64 = time_parts.next()?.parse().ok()?; + let minute: i64 = time_parts.next()?.parse().ok()?; + let seconds_text = time_parts.next()?; + let (secs, millis) = match seconds_text.split_once('.') { + Some((secs, frac)) => (secs.parse::().ok()?, { + let padded = format!("{frac:0<3}"); + padded.get(..3)?.parse::().ok()? + }), + None => (seconds_text.parse::().ok()?, 0), + }; + + let days = days_from_civil(year, month, day); + let total_ms = (((days * 24 + hour) * 60 + minute) * 60 + secs) * 1000 + millis; + u64::try_from(total_ms).ok() +} + +/// Howard Hinnant 的 `days_from_civil`(1970-01-01 为 0)。 +fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { + let y = if month <= 2 { year - 1 } else { year }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = (month + 9) % 12; + let doy = (153 * mp + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_launch_timestamps() { + assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:00.000Z"), Some(0)); + // 与既有审计测试同源:1_793_304_753_123 = 2026-10-29T20:12:33.123Z + assert_eq!( + parse_rfc3339_ms("2026-10-29T20:12:33.123Z"), + Some(1_793_304_753_123) + ); + assert_eq!(parse_rfc3339_ms("not-a-time"), None); + } +} diff --git a/crates/cli/src/commands/agent/ports/mod.rs b/crates/cli/src/commands/agent/ports/mod.rs new file mode 100644 index 0000000..195b067 --- /dev/null +++ b/crates/cli/src/commands/agent/ports/mod.rs @@ -0,0 +1,58 @@ +//! 端口装配:把 `unlucky-core` 的实现塞进 agent 的五个端口。 +//! +//! 为什么这一层在 CLI 而不在 agent:agent 只知道 trait(`#!` 依赖倒置), +//! `crates/app` 是 GUI 的装配处,而 `ull agent serve` 是**headless 的另一个装配入口**。 +//! 放这里让 CLI 自洽,也让 GUI 后续复用同一批适配器(把本目录上移到 app 是一次纯搬家)。 +//! +//! 每个文件一个端口,与 `crates/agent/src/port*.rs` 一一对应,读起来不用跳来跳去。 + +pub mod accounts; +pub mod diagnose; +pub mod events; +pub mod game; +pub mod session; +pub mod setting; + +use std::sync::Arc; + +use unlucky_agent::port::Ports; +use unlucky_core::platform::data_dir::DataMode; +use unlucky_core::platform::paths::Platform; + +use crate::context::Ctx; + +pub use accounts::CoreAccounts; +pub use diagnose::CoreDiagnose; +pub use game::CoreGame; +pub use session::CoreSessions; +pub use setting::CoreSetting; + +/// 按一次 CLI 上下文装配五个端口。 +#[must_use] +pub fn build(ctx: &Ctx) -> Ports { + Ports { + // 数据根键(`core.*`)落便携目录那份,与 `Ctx::resolve` 同源——CLI `config set` + // 与 AI `set_config` 共用 `Ctx` 给出的两个落点(finding …-agent-settingport-core-ai)。 + setting: Arc::new(CoreSetting::split( + ctx.launcher_config(), + ctx.portable_launcher_config(), + )), + sessions: Arc::new(CoreSessions::new(ctx.sessions_root())), + diagnose: Arc::new(CoreDiagnose::new(ctx.sessions_root())), + game: Arc::new(CoreGame::new( + ctx.layout(), + ctx.game(), + data_mode(ctx), + platform(ctx), + )), + accounts: Arc::new(CoreAccounts::new(ctx.auth_data())), + } +} + +pub(crate) fn data_mode(ctx: &Ctx) -> DataMode { + ctx.data.mode +} + +pub(crate) fn platform(ctx: &Ctx) -> Platform { + ctx.platform +} diff --git a/crates/cli/src/commands/agent/ports/session.rs b/crates/cli/src/commands/agent/ports/session.rs new file mode 100644 index 0000000..773fc85 --- /dev/null +++ b/crates/cli/src/commands/agent/ports/session.rs @@ -0,0 +1,201 @@ +//! 会话端口:索引 → `list_game_sessions`,事件流切片 → `read_game_log`。 + +use serde_json::{json, Value}; + +use unlucky_agent::port::{ + GameSessionPort, GameSessionSummary, LogEvent, LogPage, LogQuery, PortResult, +}; + +use super::events::SessionSource; + +/// M18 的会话索引 + 事件流。 +#[derive(Debug, Clone)] +pub struct CoreSessions { + source: SessionSource, +} + +impl CoreSessions { + /// `root` = `<数据根>/.ULL/data/agent/sessions`。 + #[must_use] + pub fn new(root: impl Into) -> Self { + Self { + source: SessionSource::new(root), + } + } +} + +fn summary_of( + entry: &unlucky_core::launch::session_index::SessionIndexEntry, +) -> GameSessionSummary { + GameSessionSummary { + session_id: entry.session_id.clone(), + instance_id: Some(entry.instance_id.clone()).filter(|id| !id.is_empty()), + instance_name: Some(entry.instance_name.clone()).filter(|name| !name.is_empty()), + game_version: Some(entry.game_version.clone()).filter(|v| !v.is_empty()), + started_at: Some(entry.started_at.clone()).filter(|v| !v.is_empty()), + ended_at: Some(entry.ended_at.clone()).filter(|v| !v.is_empty()), + duration_ms: entry.duration_ms, + exit_code: entry.exit_code, + exit_type: entry.exit_type.clone(), + silent_failure: entry.silent_failure, + window_detected: entry.window_detected, + event_count: entry.event_count, + crash_found: entry.crash_found, + diagnosis_top: entry.diagnosis_top.clone(), + } +} + +fn to_log_event(event: &Value, fallback_seq: u64) -> LogEvent { + LogEvent { + seq: event + .get("seq") + .and_then(Value::as_u64) + .unwrap_or(fallback_seq), + ts: event + .get("ts") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + t_ms: event.get("t_ms").and_then(Value::as_u64).unwrap_or(0), + kind: event + .get("kind") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + payload: event.get("payload").cloned().unwrap_or(Value::Null), + } +} + +impl GameSessionPort for CoreSessions { + fn list_sessions( + &self, + limit: usize, + instance_id: Option<&str>, + ) -> PortResult> { + let mut entries = self.source.entries()?; + if let Some(instance) = instance_id { + entries.retain(|entry| entry.instance_id == instance); + } + entries.truncate(limit); + Ok(entries.iter().map(summary_of).collect()) + } + + fn read_log(&self, session_id: &str, query: &LogQuery) -> PortResult { + let entry = self.source.find(session_id)?; + let raw = self.source.events(&entry)?; + let events: Vec = raw + .iter() + .enumerate() + .map(|(index, event)| to_log_event(event, index as u64)) + .collect(); + + let mut kind_counts = std::collections::BTreeMap::new(); + for event in &events { + *kind_counts.entry(event.kind.clone()).or_insert(0u64) += 1; + } + let matched: Vec<&LogEvent> = events + .iter() + .filter(|e| query.from_seq.is_none_or(|from| e.seq >= from)) + .filter(|e| query.to_seq.is_none_or(|to| e.seq <= to)) + .filter(|e| query.kinds.is_empty() || query.kinds.contains(&e.kind)) + .collect(); + let max = if query.max_events == 0 { + matched.len() + } else { + query.max_events + }; + let truncated = matched.len() > max; + + Ok(LogPage { + session_id: entry.session_id, + total_events: events.len() as u64, + matched_events: matched.len() as u64, + truncated, + kind_counts, + first_ts: events.first().map(|e| e.ts.clone()), + last_ts: events.last().map(|e| e.ts.clone()), + events: matched.into_iter().take(max).cloned().collect(), + }) + } +} + +/// 会话摘要 + 事件流 → JSON 里用得到的稳定形状(`read_game_log` 的 payload 直接透传)。 +#[must_use] +pub fn log_event_json(event: &LogEvent) -> Value { + json!({ + "seq": event.seq, + "ts": event.ts, + "t_ms": event.t_ms, + "kind": event.kind, + "payload": event.payload, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn seed_session(root: &Path) { + let game_dir = root.join("game"); + std::fs::create_dir_all(&game_dir).expect("建目录"); + let entry = json!({ + "v": 1, + "session_id": "20261029T124501-9f3c2a", + "instance_id": "inst-1", + "instance_name": "1.20.1 Forge", + "game_version": "1.20.1", + "started_at": "2026-10-29T12:45:01.123Z", + "ended_at": "2026-10-29T12:46:31.123Z", + "duration_ms": 90000, + "exit_code": 1, + "exit_type": "application_error", + "silent_failure": false, + "window_detected": true, + "event_count": 2, + "log_path": "20261029T124501-9f3c2a.jsonl", + "crash_found": true, + "diagnosis_top": "integrity.forge_missing_library" + }); + std::fs::write(game_dir.join("index.jsonl"), format!("{entry}\n")).expect("写索引"); + let events = [ + json!({"seq":0,"ts":"2026-10-29T12:45:01.123Z","t_ms":0,"kind":"stdout","payload":{"line":"hi","level":"info"}}), + json!({"seq":1,"ts":"2026-10-29T12:45:02.123Z","t_ms":1000,"kind":"stderr","payload":{"line":"boom","level":"error"}}), + ]; + let text: String = events + .iter() + .map(|event| format!("{event}\n")) + .collect::(); + std::fs::write(game_dir.join("20261029T124501-9f3c2a.jsonl"), text).expect("写事件"); + } + + #[test] + fn lists_and_slices_a_session() { + let root = std::env::temp_dir().join(format!("ull-mcp-sessions-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + seed_session(&root); + + let port = CoreSessions::new(root.clone()); + let sessions = port.list_sessions(10, None).expect("应能列出"); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].exit_code, Some(1)); + assert!(sessions[0].crash_found); + + let page = port + .read_log( + "20261029T124501", + &LogQuery { + max_events: 1, + kinds: vec!["stderr".to_string()], + ..LogQuery::default() + }, + ) + .expect("应能读"); + assert_eq!(page.total_events, 2); + assert_eq!(page.events.len(), 1); + assert_eq!(page.events[0].kind, "stderr"); + assert!(page.kind_counts.contains_key("stdout")); + + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/crates/cli/src/commands/agent/ports/setting.rs b/crates/cli/src/commands/agent/ports/setting.rs new file mode 100644 index 0000000..46f846b --- /dev/null +++ b/crates/cli/src/commands/agent/ports/setting.rs @@ -0,0 +1,211 @@ +//! 配置端口:`core::setting` 的直译(**唯一真相源**在 core,这里不复制校验规则)。 +//! +//! 三条纪律: +//! 1. 每次调用都重新 `SettingStore::load`——外部 MCP 客户端可能刚改了配置, +//! 缓存一份快照会让审计里的 `old` 骗人; +//! 2. **敏感值一律掩码**(M12 `Sensitivity::High` + agent 的凭据词表两道): +//! 工具结果会回灌模型、也可能发往远端 LLM,AI 只需要知道「这个键是什么、怎么改」; +//! 3. **落点与 `ull config` 同源**(分流规则仍只在 `crate::context` 一处):数据根键 +//! 读写便携目录那份 `launcher.json`,其余键读写解析后的数据根。只持一份路径时 +//! standard / `--data-dir` 模式下 AI 改 `core.data_mode` 会「写了没反应」 +//! (finding 20261031-worker-chore40-improve-agent-settingport-core-ai)。 + +use std::path::{Path, PathBuf}; + +use serde_json::{json, Map, Value}; +use unlucky_agent::confirm::Sensitivity; +use unlucky_agent::port::{PortResult, SettingChange, SettingKey, SettingPort}; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::spec::{Sensitivity as CoreSensitivity, ValueKind}; +use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason}; + +use crate::context::is_data_root_key; + +/// 掩码占位(与 CLI `config get` 同一串,读输出的人不用学两套)。 +pub const MASK: &str = ""; + +/// 把 `core::setting` 包成 agent 的 [`SettingPort`]。 +#[derive(Debug, Clone)] +pub struct CoreSetting { + /// 解析后的数据根那份 `launcher.json`(非数据根键的落点)。 + path: PathBuf, + /// 便携目录那份 `launcher.json`(数据根键的落点);与 `path` 重合或拿不到 + /// exe 目录时为 `None`。 + core_path: Option, +} + +impl CoreSetting { + /// 只指向数据根那份 `<数据根>/.ULL/config/launcher.json`(两份落点重合时够用)。 + #[must_use] + pub fn new(path: impl Into) -> Self { + Self::split(path, None) + } + + /// 数据根那份 + 便携目录那份(`Ctx::portable_launcher_config`,与数据根解析同源)。 + /// + /// 两份路径重合(便携模式)时只留一份,免得同一个文件被加载两次。 + #[must_use] + pub fn split(path: impl Into, core_path: Option) -> Self { + let path = path.into(); + let core_path = core_path.filter(|core| core.as_path() != path.as_path()); + Self { path, core_path } + } + + /// 某个键的落点文件(数据根键 → 便携目录那份,其余 → 数据根那份)。 + fn path_for(&self, key: &str) -> &Path { + match &self.core_path { + Some(core) if is_data_root_key(key) => core.as_path(), + _ => self.path.as_path(), + } + } + + /// 按落点加载 store(读写都用它,`get`/`set` 不会各看一份文件)。 + fn store_for(&self, key: &str) -> SettingStore { + SettingStore::load(self.path_for(key), launcher_schema()) + } + + /// 数据根那份(schema / 白名单等与落点无关的元数据都从它出)。 + fn store(&self) -> SettingStore { + SettingStore::load(&self.path, launcher_schema()) + } +} + +/// `summary` 里某个键的**原始**取值来源(掩码在 `summary` 里做):数据根键取便携目录 +/// 那份——也就是 `Ctx::resolve` 读的同一份,否则概要和「真正生效值」是两套真相。 +fn raw_value<'a>( + data: &'a SettingStore, + core: Option<&'a SettingStore>, + key: &str, +) -> Option<&'a Value> { + match core { + Some(core) if is_data_root_key(key) => core.effective(key), + _ => data.effective(key), + } +} + +fn sensitivity_of(value: CoreSensitivity) -> Sensitivity { + match value { + CoreSensitivity::Low => Sensitivity::Low, + CoreSensitivity::Medium => Sensitivity::Medium, + CoreSensitivity::High => Sensitivity::High, + } +} + +fn kind_label(kind: &ValueKind) -> &'static str { + match kind { + ValueKind::Bool => "boolean", + ValueKind::Int { .. } => "integer", + ValueKind::Float { .. } => "number", + ValueKind::Text { .. } => "string", + ValueKind::Path => "path", + ValueKind::Enum(_) => "enum", + ValueKind::TextList => "string[]", + ValueKind::EnumList(_) => "enum[]", + ValueKind::Any => "json", + } +} + +/// 该键的值要不要在 AI 侧掩码:凭据词表命中,或 schema 标 `High`。 +fn mask(value: Value, sensitive: bool) -> Value { + if sensitive { + json!(MASK) + } else { + value + } +} + +impl SettingPort for CoreSetting { + /// 全量概要:每个键的生效值都取自**它自己的落点文件**(数据根键取便携目录那份)。 + fn summary(&self) -> Value { + let store = self.store(); + let core = self + .core_path + .as_ref() + .map(|path| SettingStore::load(path, launcher_schema())); + let schema = store.doc().schema().clone(); + let mut map = Map::new(); + for spec in schema.specs() { + if spec.is_deprecated() { + continue; + } + let sensitive = spec.ai.sensitivity == CoreSensitivity::High + || unlucky_agent::redact::is_sensitive_key(spec.key); + let value = raw_value(&store, core.as_ref(), spec.key) + .cloned() + .unwrap_or(Value::Null); + map.insert(spec.key.to_string(), mask(value, sensitive)); + } + Value::Object(map) + } + + fn get(&self, path: &str) -> PortResult { + if unlucky_agent::redact::is_sensitive_key(path) { + return Err(format!( + "`{path}` 是凭据类配置,AI 不可读取也不可写入;请让用户自己在设置页查看" + )); + } + let store = self.store_for(path); + let schema = store.doc().schema().clone(); + let Some(value) = store.effective(path).cloned() else { + return Err(format!("配置键 `{path}` 不在 schema 里")); + }; + let sensitive = schema.sensitivity(path) == Some(CoreSensitivity::High); + Ok(mask(value, sensitive)) + } + + fn set(&self, path: &str, value: &Value) -> PortResult { + let file = self.path_for(path); + let mut store = self.store_for(path); + let schema = store.doc().schema().clone(); + let sensitive = schema.sensitivity(path) == Some(CoreSensitivity::High); + let old = store + .effective(path) + .cloned() + .map(|old| mask(old, sensitive)); + store + .set(path, value.clone()) + .map_err(|error| error.to_string())?; + match store.save() { + Ok(SaveOutcome::Saved(_)) | Ok(SaveOutcome::Skipped(SkipReason::NotDirty)) => {} + // 只读/不可信:**不能**报成功——外部客户端会以为改完了。 + Ok(SaveOutcome::Skipped(reason)) => { + return Err(format!( + "{} 当前不可写({});请让用户检查文件权限或内容", + file.display(), + reason.as_str() + )) + } + Err(error) => return Err(format!("写 {} 失败:{error}", file.display())), + } + Ok(SettingChange { + path: path.to_string(), + old, + new: mask(value.clone(), sensitive), + }) + } + + fn writable_keys(&self) -> Vec { + let store = self.store(); + let schema = store.doc().schema().clone(); + schema + .specs() + .iter() + .filter(|spec| spec.ai.writable && !spec.is_deprecated()) + .map(|spec| SettingKey { + path: spec.key.to_string(), + sensitivity: sensitivity_of(spec.ai.sensitivity), + value_type: kind_label(&spec.kind).to_string(), + description: spec.doc.to_string(), + }) + .collect() + } + + fn schema(&self) -> Value { + // core 的 `to_json_schema()` 只遍历 `ai.writable && !deprecated`, + // 凭据键(`locked`)天然不在其中——这正是 `get_config_schema` 要的东西。 + self.store().doc().schema().to_json_schema() + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/cli/src/commands/agent/ports/setting/tests.rs b/crates/cli/src/commands/agent/ports/setting/tests.rs new file mode 100644 index 0000000..2e84077 --- /dev/null +++ b/crates/cli/src/commands/agent/ports/setting/tests.rs @@ -0,0 +1,226 @@ +//! [`super`] 的单测:数据根键与其余键的落点分流、AI 侧写入真的生效。 +//! +//! 与 `commands/config/tests.rs` 同一套造场手法:临时目录 + `Ctx::resolve_in` +//! 把便携目录钉死在临时路径里,不动真实数据根。 + +use std::sync::atomic::{AtomicU64, Ordering}; + +use super::*; +use crate::cli::Globals; +use crate::commands::agent::ports::build; +use crate::context::Ctx; + +fn temp_config() -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-core-setting-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建临时目录"); + dir.join("launcher.json") +} + +#[test] +fn schema_omits_credential_keys_and_writable_keys_match_policy() { + let port = CoreSetting::new(temp_config()); + let schema = port.schema(); + let text = schema.to_string(); + assert!(!text.contains("ai.api_key"), "凭据键不得进 AI schema"); + assert!(text.contains("agent.mcp.port"), "M39 的新键应可写:{text}"); + + let paths: Vec = port.writable_keys().into_iter().map(|k| k.path).collect(); + assert!(paths.contains(&"download.concurrency".to_string())); + assert!(!paths.contains(&"ai.api_key".to_string())); +} + +#[test] +fn summary_masks_high_sensitivity_but_keeps_low() { + let port = CoreSetting::new(temp_config()); + let summary = port.summary(); + assert_eq!(summary["ai.base_url"], json!(MASK), "High 键要掩码"); + assert_ne!( + summary["download.concurrency"], + json!(MASK), + "Low 键不必掩码" + ); +} + +#[test] +fn credential_key_get_is_refused() { + let port = CoreSetting::new(temp_config()); + let err = port.get("ai.api_key").unwrap_err(); + assert!(err.contains("凭据类配置"), "{err}"); +} + +#[test] +fn set_then_get_round_trips_a_low_key() { + let path = temp_config(); + let port = CoreSetting::new(path.clone()); + let change = port + .set("download.concurrency", &json!(16)) + .expect("应写入"); + assert_eq!(change.new, json!(16)); + assert_eq!(port.get("download.concurrency").unwrap(), json!(16)); + assert!(path.is_file(), "应落盘 {}", path.display()); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。 +struct TempDir(PathBuf); + +impl TempDir { + fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = + std::env::temp_dir().join(format!("ull-ai-setting-{tag}-{}-{seq}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("建临时目录"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// 正确的 `$schema`(认不出的前缀会让加载降级成只读)。 +fn schema_url() -> String { + use unlucky_core::setting::keys::launcher_version; + use unlucky_core::setting::schema::{SchemaId, SchemaRef}; + SchemaRef::new(SchemaId::LAUNCHER, launcher_version()).url() +} + +fn write_json(path: &Path, value: &Value) { + std::fs::create_dir_all(path.parent().expect("有父目录")).expect("建目录"); + std::fs::write(path, value.to_string()).expect("写配置"); +} + +fn read_json(path: &Path) -> Value { + let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("读 {} 失败:{e}", path.display())); + serde_json::from_slice(&bytes).expect("合法 JSON") +} + +/// standard 模式现场:便携目录那份说 standard + 自定义 launcher home(数据根留在临时目录里)。 +fn standard_fixture(temp: &TempDir) -> (PathBuf, PathBuf, PathBuf) { + let exe = temp.path().join("exe"); + let launcher_home = temp.path().join("lhome"); + let portable = exe.join(".ULL").join("config").join("launcher.json"); + write_json( + &portable, + &json!({ + "$schema": schema_url(), + "schemaVersion": 1, + "core": { + "data_mode": "standard", + "launcher_home": launcher_home.display().to_string(), + }, + }), + ); + (exe, portable, launcher_home) +} + +/// **AI 面改 `core.data_mode` 真的生效**:写便携目录那份,下一次解析换模式。 +/// +/// 走的是 `ports::build` 装出来的同一个端口(`set_config` 工具背后就是它)。 +#[test] +fn ai_set_of_core_data_mode_takes_effect_on_the_next_resolve() { + let temp = TempDir::new("core-ai"); + let (exe, portable, launcher_home) = standard_fixture(&temp); + let ctx = Ctx::resolve_in(&Globals::default(), Some(exe.clone())).expect("解析数据根"); + assert_eq!(ctx.data.mode.as_str(), "standard"); + let ports = build(&ctx); + + ports + .setting + .set("core.data_mode", &json!("portable")) + .expect("AI 侧写 core.data_mode"); + + assert_eq!( + read_json(&portable)["core"]["data_mode"], + json!("portable"), + "数据根键要落在便携目录那份" + ); + let data_file = launcher_home.join("config").join("launcher.json"); + assert!( + !data_file.exists() || read_json(&data_file)["core"].get("data_mode").is_none(), + "数据根那份不该被写入 core.data_mode" + ); + + let next = Ctx::resolve_in(&Globals::default(), Some(exe)).expect("重新解析"); + assert_eq!( + next.data.mode.as_str(), + "portable", + "改了没反应到此为止:下一次解析必须换模式" + ); + + // 其余键仍走数据根那份,且不污染便携目录那份。 + ports + .setting + .set("download.concurrency", &json!(16)) + .expect("AI 侧写普通键"); + assert_eq!(read_json(&data_file)["download"]["concurrency"], json!(16)); + assert!( + read_json(&portable)["download"] + .get("concurrency") + .is_none(), + "普通键不该写进便携目录那份" + ); +} + +/// 概要的取值来源与解析同源:数据根键取便携那份(哪怕数据根那份留着陈旧值)。 +#[test] +fn summary_takes_data_root_keys_from_the_portable_file() { + let temp = TempDir::new("core-summary"); + let (exe, portable, launcher_home) = standard_fixture(&temp); + // 老版本 AI 写错文件留下的现场:数据根那份有个陈旧的 core.data_mode。 + let data_file = launcher_home.join("config").join("launcher.json"); + write_json( + &data_file, + &json!({ + "$schema": schema_url(), + "schemaVersion": 1, + "core": { "data_mode": "custom" }, + "download": { "concurrency": 4 }, + }), + ); + + let ctx = Ctx::resolve_in(&Globals::default(), Some(exe)).expect("解析数据根"); + let port = CoreSetting::split(ctx.launcher_config(), ctx.portable_launcher_config()); + let data = port.store(); + let core = SettingStore::load(&portable, launcher_schema()); + + assert_eq!( + raw_value(&data, Some(&core), "core.data_mode"), + Some(&json!("standard")), + "数据根键取便携那份 = 解析读到的那份" + ); + assert_eq!( + raw_value(&data, Some(&core), "download.concurrency"), + Some(&json!(4)), + "其余键仍取数据根那份" + ); + // `core.*` 是 M12 的 High 键,概要里照旧掩码(值不可见,但来源必须对)。 + assert_eq!(port.summary()["core.data_mode"], json!(MASK)); +} + +/// 便携模式下两份落点本就是同一份文件:不留第二份,免得重复加载。 +#[test] +fn portable_mode_keeps_a_single_landing_file() { + let temp = TempDir::new("core-portable"); + let ctx = + Ctx::resolve_in(&Globals::default(), Some(temp.path().to_path_buf())).expect("解析数据根"); + assert_eq!(ctx.data.mode.as_str(), "portable"); + let port = CoreSetting::split(ctx.launcher_config(), ctx.portable_launcher_config()); + assert_eq!( + port.path_for("core.data_mode"), + ctx.launcher_config().as_path() + ); +} diff --git a/crates/cli/src/commands/agent/serve.rs b/crates/cli/src/commands/agent/serve.rs new file mode 100644 index 0000000..397b7b3 --- /dev/null +++ b/crates/cli/src/commands/agent/serve.rs @@ -0,0 +1,218 @@ +//! `ull agent serve`:把注册表挂成 MCP server,长驻运行。 +//! +//! 装配顺序(每一步都对应 02 文档的一条约束): +//! 1. 设置 → `McpConfig`(`agent.mcp.*`,命令行可覆盖); +//! 2. 端口([`super::ports`])→ 注册表(`tools::build_v1_registry`); +//! 3. 只读模式 → `read_only_subset()`(物理上不含写工具,列表/调用天然一致); +//! 4. 执行器:`JsonlAudit`(落盘 `/agent/audit`)+ fail-closed 闸门 + +//! `ConfirmPolicy { yolo }`(读 `ai.yolo`)+ `DenyGuidance::External`(没有弹窗可点); +//! 5. 传输:stdio 或 Streamable HTTP(127.0.0.1)。 + +use std::sync::Arc; + +use serde_json::Value; +use unlucky_agent::audit::{audit_dir, JsonlAudit}; +use unlucky_agent::brain::exec::ToolExecutor; +use unlucky_agent::clock::SystemClock; +use unlucky_agent::confirm::{ConfirmPolicy, DenyGuidance, NoUiGate}; +use unlucky_agent::event::NoopSink; +use unlucky_agent::mcp::{serve_http, serve_stdio, McpConfig, RegistryServer}; +use unlucky_agent::tools::build_v1_registry; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +use crate::cli::AgentServeArgs; +use crate::context::Ctx; +use crate::error::CliError; + +/// 起一次 server(阻塞直到进程被杀/stdio 对端关闭)。 +/// +/// # Errors +/// 注册表装配失败、端口被占用、非回环监听等返回用户错误。 +pub fn run(ctx: &Ctx, args: &AgentServeArgs) -> Result { + let settings = Settings::load(ctx); + let config = settings.mcp_config(args); + let yolo = settings.yolo(); + let read_only = config.read_only; + if !settings.enabled { + eprintln!( + "[unlucky-agent] 提示:设置键 agent.mcp.enabled=false,本次是手动启动(GUI 不会自动起)" + ); + } + + let ports = super::ports::build(ctx); + let registry = Arc::new( + build_v1_registry(&ports) + .map_err(|error| CliError::internal("agent_registry", error.to_string()))?, + ); + // 路由注册表始终是完整的那份;「哪些工具真能跑」由执行器持有的注册表决定 + // (只读模式下是 read_only_subset):硬调写工具会得到可读的 tool-level 错误 + 审计, + // 而不是 rmcp 的协议级 -32602(客户端只会显示 "internal error")。 + let exposed = if read_only { + Arc::new(registry.read_only_subset()) + } else { + Arc::clone(®istry) + }; + + let audit = Arc::new(JsonlAudit::new( + audit_dir(&ctx.layout().data()), + Arc::new(SystemClock), + )); + let executor = Arc::new( + ToolExecutor::new( + Arc::clone(&exposed), + audit, + Arc::new(NoUiGate), + ConfirmPolicy { yolo }, + Arc::new(SystemClock), + Arc::new(NoopSink), + ) + .with_guidance(DenyGuidance::External), + ); + + let server = RegistryServer::new(registry, executor, transport_label(args), read_only); + if args.stdio { + // stdio:stdout 是协议流,人类提示一律走 stderr。 + eprintln!( + "[unlucky-agent] MCP server 走 stdio({} 个工具;写权限:{};YOLO:{})", + server.tool_count(), + if read_only { "关(只读)" } else { "开" }, + yolo + ); + return block_on(async move { serve_stdio(server).await }) + .map(|()| crate::error::EXIT_OK) + .map_err(|error| CliError::user("mcp_stdio", error.to_string())); + } + + let url = config.url(); + eprintln!("[unlucky-agent] MCP 端点:{url}"); + block_on(async move { serve_http(server, config).await }) + .map(|()| crate::error::EXIT_OK) + .map_err(|error| CliError::user("mcp_http", error.to_string())) +} + +fn transport_label(args: &AgentServeArgs) -> &'static str { + if args.stdio { + "mcp-stdio" + } else { + "mcp-http" + } +} + +/// 在独立线程里跑一个 tokio 运行时(stdio 形态要独占 stdin/stdout,不能被主线程抢)。 +fn block_on(future: F) -> Result +where + F: std::future::Future> + Send + 'static, + T: Send + 'static, +{ + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| unlucky_agent::mcp::McpServeError::Io(error.to_string()))?; + runtime.block_on(future) +} + +/// 从 `launcher.json` 读 MCP 相关设置(坏文件退化成默认值,不阻断启动)。 +struct Settings { + enabled: bool, + port: u16, + allow_write: bool, + token: Option, + yolo: bool, +} + +impl Settings { + fn load(ctx: &Ctx) -> Self { + let store = SettingStore::load(ctx.launcher_config(), launcher_schema()); + let doc = store.doc(); + let port = doc + .effective("agent.mcp.port") + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) + .unwrap_or(47823); + Self { + enabled: doc + .effective("agent.mcp.enabled") + .and_then(Value::as_bool) + .unwrap_or(false), + port, + allow_write: doc + .effective("agent.mcp.allow_write") + .and_then(Value::as_bool) + .unwrap_or(false), + token: doc + .effective_str("agent.mcp.token") + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_string), + yolo: doc + .effective("ai.yolo") + .and_then(Value::as_bool) + .unwrap_or(false), + } + } + + fn mcp_config(&self, args: &AgentServeArgs) -> McpConfig { + McpConfig { + host: args.host.clone().unwrap_or_else(|| "127.0.0.1".to_string()), + port: args.port.unwrap_or(self.port), + token: self.token.clone(), + // 命令行 --allow-write 只能**收紧或放开到设置允许的程度**: + // 设置里没开写权限时,命令行也不放开(设置是用户的意思表示,命令行是运行参数)。 + read_only: !(self.allow_write || args.allow_write), + } + } + + fn yolo(&self) -> bool { + self.yolo + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args() -> AgentServeArgs { + AgentServeArgs::default() + } + + #[test] + fn settings_default_to_read_only_on_47823() { + // Settings 需要 Ctx(要真实数据根),这里只测「无设置时的合成规则」: + // 用 Default 的字段逐项对照,避免为此造一个假 Ctx。 + let defaults = Settings { + enabled: false, + port: 47823, + allow_write: false, + token: None, + yolo: false, + }; + let config = defaults.mcp_config(&args()); + assert_eq!(config.port, 47823); + assert!(config.read_only); + assert_eq!(config.host, "127.0.0.1"); + } + + #[test] + fn cli_flags_override_port_and_write_switch() { + let settings = Settings { + enabled: true, + port: 47823, + allow_write: true, + token: Some("t".to_string()), + yolo: true, + }; + let overridden = settings.mcp_config(&AgentServeArgs { + port: Some(50000), + allow_write: false, + ..AgentServeArgs::default() + }); + assert_eq!(overridden.port, 50000); + assert!(!overridden.read_only, "设置里允许写时命令行不必再开"); + assert_eq!(overridden.token.as_deref(), Some("t")); + assert!(settings.yolo()); + // `enabled` 只是 GUI 的自动启动开关:命令行显式 `agent serve` 不看它 + // (所以这里 enabled=true 既不影响 mcp_config,也不影响能不能起服务)。 + assert!(settings.enabled); + } +} diff --git a/crates/cli/src/commands/config.rs b/crates/cli/src/commands/config.rs new file mode 100644 index 0000000..f6598f1 --- /dev/null +++ b/crates/cli/src/commands/config.rs @@ -0,0 +1,341 @@ +//! `config get|set`:走 `core::setting` 的 schema API(单一真相源)。 +//! +//! # 落点(M40:与数据根解析同源) +//! +//! - **数据根键**([`DATA_ROOT_KEYS`]:`core.data_mode` / `core.data_custom_root` / +//! `core.launcher_home` / `core.game_home`)→ 便携目录那份 `launcher.json` +//! (`/.ULL/config/launcher.json`)。这正是 `Ctx::resolve` 决定数据根时读的 +//! 同一份文件(落点由 `Ctx::setting_path_for` 给出),所以 `config get core.data_mode` +//! 展示的就是真正生效的值,`config set` 也真的影响下一次解析; +//! - 其余键 → 解析后的数据根(`/.ULL/config/launcher.json`)。 +//! +//! 之前两条路各看一份文件:`--data-dir` / standard 模式下「改了 `core.data_mode` 没反应、 +//! `config get` 还报另一份文件里的值」(finding `…-improve-ull-config-get-set-launcher-json`)。 +//! +//! 实例级配置(`config/instances/.json`)留给实例管理 mission,这里不半途实现。 +//! +//! **凭据纪律**(D9 / `02-ai-agent-design.md` §3.1):schema 标 `High` 的敏感键 +//! (`ai.api_key` / `download.proxy_url` / `java.extra_args` …)在 read 路径上一律掩码—— +//! 单键 get、无键全量 dump、set 的旧值回显**三条**都要掩,`--show-secret` 才放行明文。 +//! 判定叠两道:schema 的 `Sensitivity::High` + agent 的 `redact::is_sensitive_key` 词表。 + +use serde_json::{json, Map, Value}; +use unlucky_core::setting::catalog::SettingSchema; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::spec::Sensitivity; +use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason}; + +use crate::context::{is_data_root_key, Ctx, DATA_ROOT_KEYS}; +use crate::error::CliError; +use crate::output::Report; + +/// 敏感值在输出里的占位。 +const MASK: &str = ""; + +/// `ull config get [] [--show-secret]`。 +/// +/// # Errors +/// +/// 未知键、配置不可读时返回用户错误。 +pub fn get(ctx: &Ctx, key: Option<&str>, show_secret: bool) -> Result { + match key { + Some(key) => get_one(ctx, key, show_secret), + None => dump(ctx, show_secret), + } +} + +/// 单键 get:读该键自己的落点文件(数据根键走便携目录那份)。 +fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result { + let path = ctx.setting_path_for(key); + let store = SettingStore::load(&path, launcher_schema()); + let schema = store.doc().schema().clone(); + + let Some(value) = store.effective(key) else { + return Err( + CliError::user("unknown_key", format!("配置键 `{key}` 不在 schema 里")) + .with_hint("`ull config get`(不带键)可以看到全部生效键"), + ); + }; + + let sensitive = is_sensitive(&schema, key); + let hidden = sensitive && !show_secret; + let shown = if hidden { + Value::String(MASK.to_string()) + } else { + value.clone() + }; + let problems = render_problems(&store); + let data = json!({ + "key": key, + "value": shown, + "redacted": hidden, + "sensitive": sensitive, + "path": path.display().to_string(), + "problems": problems, + }); + let mut human = vec![format!( + "{key} = {}{}", + render_value(&shown), + if hidden { + "(敏感键;加 --show-secret 显示明文)" + } else { + "" + } + )]; + human.push(format!("来自 {}", path.display())); + for problem in &problems { + human.push(format!("问题:{problem}")); + } + human.extend(source_note(ctx, key)); + Ok(Report::new(data, human)) +} + +/// 无键全量 dump:以数据根那份为底,`core.*` 数据根键换成便携目录那份的生效值。 +fn dump(ctx: &Ctx, show_secret: bool) -> Result { + let path = ctx.launcher_config(); + let store = SettingStore::load(&path, launcher_schema()); + let schema = store.doc().schema().clone(); + + let core_path = ctx + .portable_launcher_config() + .filter(|core| core.as_path() != path.as_path()); + let core_store = core_path + .as_ref() + .map(|core| SettingStore::load(core, launcher_schema())); + + let mut document = store.doc().to_value(); + if let Some(core) = &core_store { + // 只叠加**便携目录那份里真有的键**(默认值不落盘,dump 也只展示文件里有的键); + // 生效数据根在下面的 `data_root` 里单独给,不靠这里的默认值推。 + for key in DATA_ROOT_KEYS { + if let Some(value) = core.doc().get(key) { + overlay(&mut document, key, value.clone()); + } + } + } + let mut problems = render_problems(&store); + if let Some(core) = &core_store { + problems.extend(render_problems(core)); + } + + let (document, masked) = if show_secret { + (document, false) + } else { + mask_document(&schema, &document) + }; + + let text = match serde_json::to_string_pretty(&document) { + Ok(text) => text, + Err(error) => return Err(CliError::internal("config_serialize", error.to_string())), + }; + let mut human: Vec = Vec::new(); + human.push(format!("配置 {}({:?})", path.display(), store.compat())); + if let Some(core) = &core_path { + human.push(format!("数据根键(core.*)读的是 {}", core.display())); + } + human.push(format!( + "数据根:{}(launcher home {},游戏目录 {})", + ctx.data.mode.as_str(), + ctx.data.launcher_home.display(), + ctx.data.game_home.display() + )); + human.extend(text.lines().map(str::to_string)); + for problem in &problems { + human.push(format!("问题:{problem}")); + } + if masked { + human.push("(敏感值已掩码;加 --show-secret 显示明文)".to_string()); + } + Ok(Report::new( + json!({ + "path": path.display().to_string(), + "core_path": core_path.as_ref().map(|core| core.display().to_string()), + "config": document, + "redacted": masked, + "problems": problems, + "data_root": { + "mode": ctx.data.mode.as_str(), + "launcher_home": ctx.data.launcher_home.display().to_string(), + "game_home": ctx.data.game_home.display().to_string(), + }, + }), + human, + )) +} + +/// `ull config set `。 +/// +/// 敏感键的**旧值回显要掩码**(新值是用户刚敲进来的,照常回显)。 +/// +/// # Errors +/// +/// 键不在 schema、值不合法、文件只读/不可读、写盘失败时返回用户错误。 +pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result { + let path = ctx.setting_path_for(key); + let mut store = SettingStore::load(&path, launcher_schema()); + let sensitive = is_sensitive(store.doc().schema(), key); + let value = parse_value(raw); + let old = store.effective(key).cloned().map(|old| { + if sensitive { + Value::String(MASK.to_string()) + } else { + old + } + }); + store.set(key, value.clone()).map_err(|error| { + CliError::user("config_set", error.to_string()) + .with_hint(format!("键与取值范围见 {}", path.display())) + })?; + + let outcome = store.save().map_err(|error| { + CliError::user( + "config_save", + format!("写 {} 失败:{error}", path.display()), + ) + })?; + match &outcome { + SaveOutcome::Saved(saved) => { + let data = json!({ + "key": key, + "old": old, + "new": value, + "saved": saved.display().to_string(), + }); + let mut human = vec![ + format!( + "{key} = {}(原值 {})", + render_value(&value), + render_value(&old.unwrap_or(Value::Null)) + ), + format!("已保存:{}", saved.display()), + ]; + human.extend(source_note(ctx, key)); + Ok(Report::new(data, human)) + } + SaveOutcome::Skipped(SkipReason::NotDirty) => Ok(Report::new( + json!({ "key": key, "new": value, "saved": null }), + vec![format!("{key} 已经是该值,未写盘")], + )), + SaveOutcome::Skipped(reason) => { + let error = CliError::user( + "config_read_only", + format!("{} 当前不可写({})", path.display(), reason.as_str()), + ); + // 不可写常常不是权限问题,而是文件内容让加载降级(`$schema` 认不出 / 版本更高)。 + let problems = render_problems(&store); + Err(if problems.is_empty() { + error.with_hint("修好文件权限/内容后重试") + } else { + error.with_hint(problems.join(";")) + }) + } + } +} + +/// 数据根键的落点说明(其余键不需要:它们的落点就是数据根本身)。 +fn source_note(ctx: &Ctx, key: &str) -> Option { + if !is_data_root_key(key) { + return None; + } + if ctx.data_dir_forced() { + return Some( + "本次由 --data-dir 指定数据根:该键在下次不带 --data-dir 运行时生效".to_string(), + ); + } + if ctx.setting_path_for(key) != ctx.launcher_config() { + return Some("该键决定数据根,落点是便携目录那份(与数据根解析同源)".to_string()); + } + None +} + +/// 加载问题 → 人读一行(`problems` 通道与人类可读输出共用)。 +fn render_problems(store: &SettingStore) -> Vec { + store.problems().iter().map(ToString::to_string).collect() +} + +/// 把 `key` 的生效值写进 dump 文档(点分键 → 嵌套对象)。 +fn overlay(root: &mut Value, key: &str, value: Value) { + let mut segments = key.split('.').filter(|part| !part.is_empty()); + let Some(head) = segments.next() else { + return; + }; + let rest: Vec<&str> = segments.collect(); + let Some(map) = root.as_object_mut() else { + return; + }; + if rest.is_empty() { + map.insert(head.to_string(), value); + return; + } + overlay( + map.entry(head.to_string()) + .or_insert_with(|| Value::Object(Map::new())), + &rest.join("."), + value, + ); +} + +/// 值先按 JSON 解析(数字/布尔/对象),失败按字符串。 +fn parse_value(raw: &str) -> Value { + serde_json::from_str::(raw).unwrap_or_else(|_| Value::String(raw.to_string())) +} + +/// 敏感键:schema 标 `High`,或键名本身命中 agent 的敏感词表(双保险)。 +fn is_sensitive(schema: &SettingSchema, key: &str) -> bool { + matches!(schema.sensitivity(key), Some(Sensitivity::High)) + || unlucky_agent::redact::is_sensitive_key(key) +} + +/// 递归掩码渲染后的配置文档,返回(掩码后的文档,是否掩过)。 +/// +/// `to_value()` 是**嵌套对象**(点分键展开成层级),所以按路径下钻; +/// 命中敏感路径时整棵子树一起掩码,数组元素沿用父路径继续下钻。 +fn mask_document(schema: &SettingSchema, document: &Value) -> (Value, bool) { + mask_at(schema, "", document) +} + +fn mask_at(schema: &SettingSchema, path: &str, value: &Value) -> (Value, bool) { + if !path.is_empty() && is_sensitive(schema, path) { + return (Value::String(MASK.to_string()), true); + } + match value { + Value::Object(map) => { + let mut out = Map::with_capacity(map.len()); + let mut masked = false; + for (key, child) in map { + let child_path = if path.is_empty() { + key.clone() + } else { + format!("{path}.{key}") + }; + let (child, child_masked) = mask_at(schema, &child_path, child); + masked |= child_masked; + out.insert(key.clone(), child); + } + (Value::Object(out), masked) + } + Value::Array(items) => { + let mut masked = false; + let redacted: Vec = items + .iter() + .map(|item| { + let (item, item_masked) = mask_at(schema, path, item); + masked |= item_masked; + item + }) + .collect(); + (Value::Array(redacted), masked) + } + other => (other.clone(), false), + } +} + +fn render_value(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/cli/src/commands/config/tests.rs b/crates/cli/src/commands/config/tests.rs new file mode 100644 index 0000000..62a7a22 --- /dev/null +++ b/crates/cli/src/commands/config/tests.rs @@ -0,0 +1,256 @@ +//! [`super`] 的单测:数据根键与其余键的落点分流、dump 合并、`problems` 通道。 + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{json, Value}; + +use super::{dump, get, set}; +use crate::cli::Globals; +use crate::context::Ctx; + +/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。 +struct TempDir(PathBuf); + +impl TempDir { + fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "ull-config-test-{tag}-{}-{seq}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("建临时目录"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// 正确的 `$schema`(写成真前缀,加载才不会降级)。 +fn schema_url() -> String { + use unlucky_core::setting::keys::launcher_version; + use unlucky_core::setting::schema::{SchemaId, SchemaRef}; + SchemaRef::new(SchemaId::LAUNCHER, launcher_version()).url() +} + +fn write_json(path: &Path, value: &Value) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("建目录"); + } + std::fs::write(path, value.to_string()).expect("写配置"); +} + +fn read_json(path: &Path) -> Value { + let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("读 {} 失败:{e}", path.display())); + serde_json::from_slice(&bytes).expect("合法 JSON") +} + +fn ctx_with(globals: Globals, exe_dir: &Path) -> Ctx { + Ctx::resolve_in(&globals, Some(exe_dir.to_path_buf())).expect("解析数据根") +} + +/// 便携模式:两个落点是同一份文件,`core.data_mode` 改了立刻是生效值。 +#[test] +fn portable_mode_keeps_core_keys_in_one_file() { + let temp = TempDir::new("portable"); + let ctx = ctx_with(Globals::default(), temp.path()); + assert_eq!(ctx.data.mode.as_str(), "portable"); + assert_eq!( + ctx.setting_path_for("core.data_mode"), + ctx.launcher_config(), + "便携模式下两份路径本来就该重合" + ); + + set(&ctx, "core.data_mode", "standard").expect("写 core.data_mode"); + let file = temp + .path() + .join(".ULL") + .join("config") + .join("launcher.json"); + assert!(file.is_file(), "应落在 {}", file.display()); + assert_eq!(read_json(&file)["core"]["data_mode"], json!("standard")); + + let report = get(&ctx, Some("core.data_mode"), true).expect("读回来"); + // `core.*` 在 schema 里标了 `AiPolicy::sensitive()`(High),读路径默认掩码成 + // ``,所以要看生效值得像用户那样加 `--show-secret`。 + assert_eq!(report.data["value"], json!("standard")); + assert_eq!( + report.data["path"], + json!(file.display().to_string()), + "read 与 write 必须同一份" + ); +} + +/// standard 模式:数据根键走便携目录那份(与解析同源),其余键走数据根。 +#[test] +fn standard_mode_routes_core_keys_to_the_portable_file() { + let temp = TempDir::new("standard"); + let exe = temp.path().join("exe"); + let portable = exe.join(".ULL").join("config").join("launcher.json"); + let launcher_home = temp.path().join("lhome"); + write_json( + &portable, + &json!({ + "$schema": schema_url(), + "schemaVersion": 1, + "core": { + "data_mode": "standard", + "launcher_home": launcher_home.display().to_string(), + }, + }), + ); + + let ctx = ctx_with(Globals::default(), &exe); + assert_eq!(ctx.data.mode.as_str(), "standard"); + assert_eq!(ctx.data.launcher_home, launcher_home); + assert_ne!( + ctx.setting_path_for("core.data_mode"), + ctx.launcher_config() + ); + + let report = get(&ctx, Some("core.data_mode"), true).expect("读 core.data_mode"); + assert_eq!( + report.data["value"], + json!("standard"), + "get 展示的必须是真正生效的值" + ); + assert_eq!(report.data["path"], json!(portable.display().to_string())); + + // 普通键仍写数据根。 + set(&ctx, "download.concurrency", "16").expect("写 download.concurrency"); + let core_file = ctx.launcher_config(); + assert_eq!(read_json(&core_file)["download"]["concurrency"], json!(16)); + + // 数据根键写便携目录那份,且不动数据根那份的 core.*。 + set(&ctx, "core.data_mode", "portable").expect("写 core.data_mode"); + assert_eq!(read_json(&portable)["core"]["data_mode"], json!("portable")); + assert!( + read_json(&core_file)["core"].get("data_mode").is_none(), + "数据根那份不该被写入 core.data_mode" + ); +} + +/// `--data-dir` 模式:数据根由命令行钉死,core.* 仍落便携目录那份并给出提示。 +#[test] +fn data_dir_flag_routes_core_keys_to_portable_and_says_so() { + let temp = TempDir::new("datadir"); + let exe = temp.path().join("exe"); + std::fs::create_dir_all(&exe).expect("建 exe 目录"); + let data = temp.path().join("data"); + let globals = Globals { + data_dir: Some(data.clone()), + ..Globals::default() + }; + let ctx = ctx_with(globals, &exe); + assert_eq!(ctx.data.mode.as_str(), "custom"); + assert_eq!(ctx.data.launcher_home, data.join(".ULL")); + + let portable = exe.join(".ULL").join("config").join("launcher.json"); + set(&ctx, "core.data_mode", "standard").expect("写 core.data_mode"); + assert!(portable.is_file(), "应落在 {}", portable.display()); + assert!(!data + .join(".ULL") + .join("config") + .join("launcher.json") + .exists()); + + let report = get(&ctx, Some("core.data_mode"), true).expect("读回来"); + assert_eq!(report.data["value"], json!("standard")); + assert!( + report.human.iter().any(|line| line.contains("--data-dir")), + "{:?}", + report.human + ); +} + +/// 全量 dump:`core.*` 取便携目录那份的生效值,并标明两份文件与数据根。 +#[test] +fn dump_merges_core_keys_from_the_portable_file() { + let temp = TempDir::new("dump"); + let exe = temp.path().join("exe"); + let portable = exe.join(".ULL").join("config").join("launcher.json"); + let launcher_home = temp.path().join("home2"); + write_json( + &portable, + &json!({ + "$schema": schema_url(), + "schemaVersion": 1, + "core": { + "data_mode": "standard", + "launcher_home": launcher_home.display().to_string(), + }, + }), + ); + // 数据根那份写一个普通键(默认值不落盘,dump 里只有文件里真有的键)。 + write_json( + &launcher_home.join("config").join("launcher.json"), + &json!({ + "$schema": schema_url(), + "schemaVersion": 1, + "download": { "concurrency": 4 }, + }), + ); + let ctx = ctx_with(Globals::default(), &exe); + + let report = dump(&ctx, true).expect("全量 dump"); + assert_eq!( + report.data["config"]["core"]["data_mode"], + json!("standard") + ); + assert_eq!( + report.data["core_path"], + json!(portable.display().to_string()) + ); + assert_eq!(report.data["data_root"]["mode"], json!("standard")); + assert_eq!( + report.data["config"]["download"]["concurrency"], + json!(4), + "非 core.* 键仍取数据根那份" + ); +} + +/// 认不出的 `$schema` 前缀:值照读 + `problems` 里有 UnknownSchema,不再静默回落。 +#[test] +fn unknown_schema_prefix_warns_and_still_shows_file_values() { + let temp = TempDir::new("badschema"); + let exe = temp.path().join("exe"); + let portable = exe.join(".ULL").join("config").join("launcher.json"); + write_json( + &portable, + &json!({ + "$schema": "https://unlucky-launcher.local/schema/launcher/1.2.0.json", + "schemaVersion": 1, + "core": { "data_mode": "portable" }, + "download": { "concurrency": 32 }, + }), + ); + let ctx = ctx_with(Globals::default(), &exe); + + let report = get(&ctx, Some("download.concurrency"), false).expect("读普通键"); + assert_eq!(report.data["value"], json!(32), "值不该被回落成默认 8"); + let problems = report.data["problems"].as_array().expect("problems 数组"); + assert!( + problems + .iter() + .any(|problem| problem.as_str().is_some_and(|p| p.contains("$schema"))), + "{problems:?}" + ); + assert!( + report.human.iter().any(|line| line.contains("$schema")), + "{:?}", + report.human + ); +} diff --git a/crates/cli/src/commands/identity.rs b/crates/cli/src/commands/identity.rs new file mode 100644 index 0000000..3594e79 --- /dev/null +++ b/crates/cli/src/commands/identity.rs @@ -0,0 +1,216 @@ +//! `ull launch` 的账户选择:三轨账户 → [`AuthPlaceholders`](唯一的身份出口)。 +//! +//! 选择规则(headless 没有交互,必须确定): +//! - `--account `:按玩家名 / 登录名 / 条目 id 精确找(大小写不敏感); +//! - 不给 `--account`:**唯一账户自动选中**;零账户回落离线占位(`--username` 或 `Player`, +//! 与 M21 的行为一致,保证没有账户时 `ull launch` 照旧能跑);多账户报错并列出候选 +//! (猜一个等于让用户拿错身份进服务器,宁可让他选)。 +//! +//! 令牌策略:`--dry-run` **不刷新、不写盘**(M30 的 dry-run 纯读语义),只对过期状态给告警; +//! 真启动前对微软轨做一次 `ensure_fresh`(静默续期),刷新成功立刻回写凭据 +//! (refresh_token 轮换后旧值就作废了,不能丢)。 + +use unlucky_core::auth::account::{AccountKind, AccountMeta, 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::context::Ctx; +use crate::error::CliError; + +/// 解析结果。 +pub struct Identity { + /// 喂给 launch 的占位符集合。 + pub placeholders: AuthPlaceholders, + /// 选中的账户(`None` = 离线占位,没有账户)。 + pub account: Option, + /// 需要展示给用户的说明(令牌过期、authlib 注入未落地等)。 + pub notes: Vec, +} + +/// 解析本次启动的身份。 +/// +/// # Errors +/// +/// 账户不存在/重名冲突/多账户未指定/凭据过期且刷新失败/凭据文件缺条目。 +pub fn resolve( + ctx: &Ctx, + account: Option<&str>, + username: Option<&str>, + dry_run: bool, +) -> Result { + let store = AccountStore::open(ctx.auth_data()); + let chosen: Option = match account { + Some(key) => Some( + store + .find(key) + .ok_or_else(|| unknown_account(key, &store))? + .clone(), + ), + None => match store.accounts() { + [] => None, + [only] => Some(only.clone()), + many => { + return Err(CliError::user( + "account_ambiguous", + format!("有 {} 个账户,需要指定用哪个", many.len()), + ) + .with_hint(format!( + "ull launch --account <名字>;候选:{}", + names_of(&store) + ))) + } + }, + }; + + let Some(meta) = chosen else { + // 零账户:回落离线占位(保留 M21 行为)。 + return Ok(offline_fallback(username)); + }; + if username.is_some() { + return Err(CliError::usage(format!( + "已选中账户 {},`--username` 只用于没有账户时的离线占位", + meta.display_name() + )) + .with_hint("去掉 --username,或用 `ull accounts add offline <名字>` 建账户")); + } + + let mut notes = Vec::new(); + let placeholders = match meta.kind { + AccountKind::Offline => offline_login(&meta).map_err(super::map_auth_error)?, + AccountKind::Microsoft => microsoft_identity(ctx, &store, &meta, dry_run, &mut notes)?, + AccountKind::AuthlibInjector => authlib_identity(&store, &meta, &mut notes)?, + }; + Ok(Identity { + placeholders, + account: Some(meta), + notes, + }) +} + +/// 零账户时的离线占位(uuid 随机,userType 用 HMCL 的 `legacy` 占位口径不变)。 +fn offline_fallback(username: Option<&str>) -> Identity { + Identity { + placeholders: AuthPlaceholders { + player_name: username.unwrap_or("Player").to_string(), + uuid: ProfileId::generate().as_str().replace('-', ""), + user_type: "legacy".to_string(), + user_properties: "{}".to_string(), + ..AuthPlaceholders::default() + }, + account: None, + notes: vec![ + "没有账户,按离线占位启动(ull accounts add offline <名字> 可建账户)".to_string(), + ], + } +} + +fn microsoft_identity( + ctx: &Ctx, + store: &AccountStore, + meta: &AccountMeta, + dry_run: bool, + notes: &mut Vec, +) -> Result { + let Some(Credentials::Microsoft(session)) = store.credentials(&meta.id) else { + return Err(CliError::user( + "account_credentials", + format!("账户 {} 的微软凭据缺失或类型不符", meta.display_name()), + ) + .with_hint("重新登录:ull accounts add msa")); + }; + + if dry_run { + // dry-run 不刷新:令牌过期只提示,命令行里的 token 反正会被脱敏。 + if session.is_expired(unlucky_core::auth::microsoft::now_ms()) { + notes.push("微软令牌已过期(--dry-run 不刷新,真启动会自动续期)".to_string()); + } + return session.to_placeholders().map_err(super::map_auth_error); + } + + let auth = MicrosoftAuth::from_env().map_err(super::map_auth_error)?; + let (refreshed, reason) = + block_on(auth.ensure_fresh(session)).map_err(super::map_auth_error)?; + if let Some(reason) = reason { + notes.push(format!("{}(已静默续期)", reason.message())); + persist_refresh(ctx, meta, &refreshed)?; + } + refreshed.to_placeholders().map_err(super::map_auth_error) +} + +/// 刷新成功后立刻回写凭据(refresh_token 已轮换,旧值作废)。 +fn persist_refresh( + ctx: &Ctx, + meta: &AccountMeta, + session: &unlucky_core::auth::MicrosoftSession, +) -> Result<(), CliError> { + let mut store = AccountStore::open(ctx.auth_data()); + store + .update_credentials(&meta.id, Credentials::Microsoft(session.clone())) + .map_err(super::map_auth_error)?; + let mut updated = meta.clone(); + // profile_name 属于可变的私有缓存,刷新后顺手把元数据里的展示名补上。 + if !session.profile_name.trim().is_empty() { + updated.profile_name = session.profile_name.clone(); + store.update_meta(updated).map_err(super::map_auth_error)?; + } + store.save().map_err(super::map_auth_error) +} + +fn authlib_identity( + store: &AccountStore, + meta: &AccountMeta, + notes: &mut Vec, +) -> Result { + let Some(Credentials::AuthlibInjector(session)) = store.credentials(&meta.id) else { + return Err(CliError::user( + "account_credentials", + format!("账户 {} 的 authlib 凭据缺失或类型不符", meta.display_name()), + )); + }; + if !session.has_profile() { + return Err(CliError::user( + "account_credentials", + format!("账户 {} 还没有选中角色", meta.display_name()), + ) + .with_hint("多角色服务器要在图形界面里选角色")); + } + notes.push("authlib-injector 的 -javaagent 参数属后续 mission,本次启动不会注入".to_string()); + session.to_placeholders().map_err(super::map_auth_error) +} + +fn names_of(store: &AccountStore) -> String { + store + .accounts() + .iter() + .map(|meta| meta.display_name().to_string()) + .collect::>() + .join("、") +} + +fn unknown_account(key: &str, store: &AccountStore) -> CliError { + let hint = if store.is_empty() { + "还没有账户:ull accounts add offline <名字> 或 ull accounts add msa".to_string() + } else { + format!("可用账户:{}", names_of(store)) + }; + CliError::user("not_found", format!("找不到账户 `{key}`")).with_hint(hint) +} + +#[cfg(test)] +mod tests { + use super::offline_fallback; + + #[test] + fn fallback_uses_username_or_player() { + let identity = offline_fallback(Some("Alex")); + assert_eq!(identity.placeholders.player_name, "Alex"); + assert!(identity.account.is_none()); + assert_eq!(identity.placeholders.user_type, "legacy"); + assert_eq!(identity.placeholders.uuid.len(), 32); + assert_eq!(offline_fallback(None).placeholders.player_name, "Player"); + } +} diff --git a/crates/cli/src/commands/install/loader.rs b/crates/cli/src/commands/install/loader.rs new file mode 100644 index 0000000..9e979f6 --- /dev/null +++ b/crates/cli/src/commands/install/loader.rs @@ -0,0 +1,159 @@ +//! `ull install --loader [--loader-version ]`:加载器层的编排。 +//! +//! ```text +//! LoaderSource::resolve(官方 meta:版本列表 → profile JSON / installer profile) +//! → LoaderInstallPlan::build(纯计算:库清单 + loader 版本 JSON 落点) +//! → [--dry-run 到此为止,但多带一段 processors 计划] +//! → 原版已装?否 → vanilla::plan_and_install(先装原版) +//! → 需要 installer?→ 依赖库 → 选 Java → 串行跑 processors(M35) +//! → execute(loader 库并发下载 → 全量就绪才写 versions//.json) +//! → register(实例 versionId 指向 loader 版本) +//! ``` +//! +//! 「先装原版」是**硬前置**:loader 版本 JSON 只声明 `inheritsFrom`,jar 与资源都在原版那层, +//! 原版缺失时启动链必然断裂。已装则跳过(`is_version_installed` 判据 = 版本 JSON 在盘上)。 +//! +//! processors 段的顺序也是硬约束:**先跑 processors,再写版本 JSON**。版本清单存在是 +//! "这版装完了"的判据,抢在 processors 前写会留下一个启动必崩却被报成已安装的实例。 + +use unlucky_core::addon::loader::{ + LoaderError, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderSource, +}; +use unlucky_core::download::blocking::block_on; +use unlucky_core::download::cache::now_ms; +use unlucky_core::game::arguments::Environment; +use unlucky_core::game::install::execute; + +use super::report_loader::ProcessorSection; +use super::{ + build_engine, ensure_name_free, failure_error, loader_processors, map_install_error, + register_instance, report, report_loader, vanilla, CONCURRENCY, +}; +use crate::cli::InstallArgs; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +/// 执行加载器安装。 +/// +/// # Errors +/// +/// 用法错误、加载器未知/不支持、元数据读取失败、原版装不上、库下载失败、 +/// installer processors 失败、实例登记失败。 +pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result { + let version = args.version.trim().to_string(); + if version.is_empty() { + return Err(CliError::usage( + "install 需要版本 id:ull install --loader fabric", + )); + } + + let engine = build_engine(ctx); + let layout = ctx.layout(); + let game = ctx.game(); + let meta_cache = layout.meta_cache(); + let now = now_ms(); + + let release = block_on(async { + let source = LoaderSource::new(&engine, &meta_cache); + source + .resolve(kind, &version, args.loader_version.as_deref()) + .await + }) + .map_err(|error: LoaderError| map_loader_error(&error, ctx))?; + + let name = args + .name + .clone() + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| release.version_id.clone()); + ensure_name_free(ctx, &name, &release.version_id)?; + + let plan = LoaderInstallPlan::build(LoaderPlanInput { + layout: &game, + release: &release, + environment: &Environment::host(), + vanilla_installed: game.is_version_installed(&version), + }); + let processor_plan = loader_processors::plan(ctx, &release)?; + + if args.dry_run { + let section = processor_plan.as_ref().map(|plan| ProcessorSection { + plan, + libraries: None, + run: None, + skipped: false, + }); + return Ok(report_loader::build( + ctx, &engine, now, true, &plan, None, None, &name, section, + )); + } + + if plan.vanilla_install_needed { + let (_, vanilla_report) = vanilla::plan_and_install( + ctx, + &engine, + &version, + false, + args.progress, + args.json_progress, + )?; + if let Some(report) = &vanilla_report { + if !report.is_complete() { + return Err(failure_error(report, ctx)); + } + } + } + + // installer processors 段:版本 JSON 已在盘上 = 上一轮早已跑完,整段跳过。 + let mut processor_libs = None; + let mut processor_run = None; + let mut processor_skipped = false; + if let Some(processor_plan) = &processor_plan { + if game.is_version_installed(&release.version_id) { + processor_skipped = true; + } else { + let (libraries, run) = loader_processors::execute(ctx, &engine, processor_plan, args)?; + processor_libs = Some(libraries); + processor_run = Some(run); + } + } + + let sink = report::progress_sink(args.progress, args.json_progress, plan.files().len()); + let install_report = block_on(execute(&engine, &plan.install, CONCURRENCY, sink)) + .map_err(|error| map_install_error(&error, ctx))?; + if !install_report.is_complete() { + return Err(failure_error(&install_report, ctx)); + } + + let registration = register_instance(ctx, &name, &release.version_id)?; + + let section = processor_plan + .as_ref() + .map(|processor_plan| ProcessorSection { + plan: processor_plan, + libraries: processor_libs.as_ref(), + run: processor_run.as_ref(), + skipped: processor_skipped, + }); + Ok(report_loader::build( + ctx, + &engine, + now, + false, + &plan, + Some(&install_report), + Some(®istration), + &name, + section, + )) +} + +/// 加载器错误 → CLI 错误(稳定码 `loader_*`)。 +fn map_loader_error(error: &LoaderError, ctx: &Ctx) -> CliError { + let hint = match error.hint() { + Some(hint) => format!("{hint}(游戏根:{})", ctx.game().root().display()), + None => format!("游戏根:{}", ctx.game().root().display()), + }; + CliError::user(error.kind().as_str(), error.message().to_string()).with_hint(hint) +} diff --git a/crates/cli/src/commands/install/loader_processors.rs b/crates/cli/src/commands/install/loader_processors.rs new file mode 100644 index 0000000..338bf49 --- /dev/null +++ b/crates/cli/src/commands/install/loader_processors.rs @@ -0,0 +1,181 @@ +//! 加载器的 installer processors 段(M35):依赖库下载 → 选 Java → 串行执行。 +//! +//! 只在 `LoaderSupport::RequiresInstaller` 的版本上出现(Forge 1.13~1.20.x 主流段、 +//! NeoForge 1.20.5+)。其余加载器(Fabric/Quilt、Forge spec 0)走纯 json 路线,这一段整块跳过。 +//! +//! ```text +//! ProcessorPlan::build(core,纯计算:变量表 + 每步命令行 + 依赖库清单) +//! → download_libraries(M27 引擎:镜像/健康度/退避/校验) +//! → select_java(M33 解析链:托管 > JAVA_HOME > PATH > 扫盘) +//! → run(串行 java -cp,产物存在性硬校验、摘要软校验) +//! ``` +//! +//! 顺序上与版本 JSON 的关系是**硬约束**:processors 全部成功之后才让 M27 的 `execute` +//! 写 `versions//.json`——那份清单是"这版装完了"的判据,抢在 processors 前写会 +//! 留下一个启动必崩却被报成"已安装"的实例。 + +use std::sync::Arc; + +use unlucky_core::addon::loader::processors::{ + self, ProcessorError, ProcessorPlan, ProcessorPlanInput, ProcessorRun, StepOutcome, StepSink, +}; +use unlucky_core::addon::loader::LoaderRelease; +use unlucky_core::download::blocking::block_on; +use unlucky_core::download::DownloadEngine; +use unlucky_core::game::arguments::Environment; +use unlucky_core::game::install::InstallReport; + +use super::{failure_error, report, CONCURRENCY}; +use crate::cli::InstallArgs; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::progress_line; + +/// 组执行计划;不需要跑 installer 的加载器返回 `None`。 +/// +/// # Errors +/// +/// install profile 的 `data` 表/`processors` 形状不对时返回 `processor_plan` 错误。 +pub(super) fn plan(ctx: &Ctx, release: &LoaderRelease) -> Result, CliError> { + if !release.requires_installer() { + return Ok(None); + } + let (Some(profile), Some(installer)) = ( + release.install_profile.as_ref(), + release.installer.as_deref(), + ) else { + return Err(CliError::user( + "loader_metadata", + format!( + "{} {} 需要跑 installer processors,但没拿到 install profile / installer 路径", + release.kind.label(), + release.loader_version + ), + ) + .with_hint("重跑一次(元数据缓存可能不完整)")); + }; + let game = ctx.game(); + let scratch = ctx + .layout() + .cache() + .join("processors") + .join(safe_name(&release.version_id)); + let plan = ProcessorPlan::build(ProcessorPlanInput { + kind: release.kind, + mc_version: &release.mc_version, + version_id: &release.version_id, + profile, + layout: &game, + installer, + scratch: &scratch, + environment: &Environment::host(), + }) + .map_err(map_processor_error)?; + Ok(Some(plan)) +} + +/// 下载依赖库 + 执行 processors。 +/// +/// # Errors +/// +/// 依赖库下载失败(稳定码沿用 `install_*`)、找不到可用 Java、processor 非零退出、 +/// 产物没落盘。 +pub(super) fn execute( + ctx: &Ctx, + engine: &DownloadEngine, + plan: &ProcessorPlan, + args: &InstallArgs, +) -> Result<(InstallReport, ProcessorRun), CliError> { + let sink = report::progress_sink(args.progress, args.json_progress, plan.libraries.len()); + let libraries = block_on(processors::download_libraries( + engine, + &plan.libraries, + CONCURRENCY, + sink, + )); + if !libraries.is_complete() { + return Err(failure_error(&libraries, ctx)); + } + + let managed = ctx.layout().java_managed(); + let Some(java) = processors::select_java(&managed, &plan.java_requirement) else { + return Err(CliError::user( + "processor_java_missing", + format!( + "跑 installer processors 需要 {},但一个可用的 Java 都没找到", + plan.java_requirement.describe() + ), + ) + .with_hint(format!( + "`ull java install {}` 装一个托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})", + plan.java_requirement.minimum, + managed.display() + ))); + }; + let sink = step_sink(args.progress, args.json_progress, plan.step_count()); + let run = processors::run(plan, &java, sink).map_err(map_processor_error)?; + Ok((libraries, run)) +} + +/// 每步一行的实时进度(只写 stderr)。 +fn step_sink(enabled: bool, json_mode: bool, total: usize) -> Option { + if !enabled { + return None; + } + Some(Arc::new(move |outcome: &StepOutcome| { + let record = serde_json::json!({ + "event": "processor", + "index": outcome.index, + "total": total, + "jar": outcome.label, + "status": outcome.status.as_str(), + "elapsed_ms": outcome.elapsed_ms, + }); + let text = format!( + "processor {}/{} {} → {}(输出 {} 个{})", + outcome.index, + total, + outcome.label, + if outcome.status.as_str() == "skipped" { + "跳过(产物已就绪)" + } else { + "执行完成" + }, + outcome.outputs, + if outcome.elapsed_ms > 0 { + format!(",{} ms", outcome.elapsed_ms) + } else { + String::new() + } + ); + if json_mode { + progress_line(&record.to_string()); + } else { + progress_line(&text); + } + })) +} + +/// processor 执行错误 → CLI 错误(稳定码 `processor_*`)。 +fn map_processor_error(error: ProcessorError) -> CliError { + let cli = CliError::user(error.kind().as_str(), error.message().to_string()); + match error.hint() { + Some(hint) => cli.with_hint(hint.to_string()), + None => cli, + } +} + +/// 版本 id → 可做目录名的片段(`+` 与路径分隔符都换掉)。 +fn safe_name(raw: &str) -> String { + raw.chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { + ch + } else { + '_' + } + }) + .collect::() + .trim_end_matches('.') + .to_string() +} diff --git a/crates/cli/src/commands/install/mod.rs b/crates/cli/src/commands/install/mod.rs new file mode 100644 index 0000000..b9a63b5 --- /dev/null +++ b/crates/cli/src/commands/install/mod.rs @@ -0,0 +1,151 @@ +//! `ull install [--loader ] [--loader-version ]`:装版本本体 / 装加载器层。 +//! +//! ```text +//! --loader 缺省 → vanilla:M27 原版安装(resolve → plan → execute → register) +//! --loader fabric|quilt → addon::loader:原版(已装则跳)→ loader 版本 JSON → 补 loader 库 → register +//! --loader neoforge|forge → 同上;需要 installer processors 的版本再跑 M35 执行器(依赖库 → java → 版本 JSON) +//! ``` +//! +//! CLI 只做四件事:建下载引擎、把计划/结果排版成人读与 `--json` 两种形态 +//! ([`report`] / [`report_loader`])、把错误翻译成稳定错误码、往 stderr 打实时进度。 +//! 所有版本/继承/库/下载逻辑都在 core(CLI 不复制任何路径或镜像规则)。 + +mod loader; +mod loader_processors; +mod report; +mod report_loader; +mod vanilla; + +use std::sync::Arc; + +use unlucky_core::addon::loader::LoaderKind; +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, DownloadErrorKind, EngineConfig, HealthRegistry, MirrorTable, +}; +use unlucky_core::game::install::{InstallError, InstallReport, Registration}; + +use crate::cli::InstallArgs; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +/// 文件级并发上限:资源是海量小文件,16 路足够压满带宽又不至于打爆镜像。 +pub(super) const CONCURRENCY: usize = 16; + +/// 执行 `install`:按 `--loader` 分发。 +/// +/// # Errors +/// +/// 用法错误、加载器未知、版本不存在、网络失败、校验不符、写盘失败、实例清单损坏。 +pub fn run(ctx: &Ctx, args: &InstallArgs) -> Result { + match args + .loader + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + None | Some("vanilla") | Some("none") => vanilla::run(ctx, args), + Some(raw) => match LoaderKind::parse(raw) { + Some(kind) => loader::run(ctx, args, kind), + None => Err( + CliError::user("loader_unknown", format!("未知加载器 `{raw}`")) + .with_hint("--loader 取值:vanilla / fabric / quilt / neoforge / forge"), + ), + }, + } +} + +/// 登记实例(幂等):`profiles.json` + `config/instances/.json`。 +/// +/// 原版安装与加载器安装共用:前者 `version_id` = 原版 id,后者 = loader 版本 id +/// (实例的 `versionId` 必须指向 loader 版本,否则启动会跑成原版)。 +pub(super) fn register_instance( + ctx: &Ctx, + name: &str, + version_id: &str, +) -> Result { + let created_at = + unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now()); + unlucky_core::game::install::instance::register( + &ctx.layout(), + name, + version_id, + Some(&created_at), + ) + .map_err(|error| CliError::user("instance_register", format!("登记实例失败:{error}"))) +} + +/// 建下载引擎:内容寻址缓存落 `.ULL/cache/`,地区判定走 24h 缓存 + 环境信号。 +/// +/// 地区判定**不做网络探测**(P2 首版;探测开关属设置层接线), +/// 因此中文环境默认镜像优先、其它环境直连——与 D7 的"不探测时"约定一致。 +pub(super) fn build_engine(ctx: &Ctx) -> DownloadEngine { + let layout = ctx.layout(); + let cache_root = 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(super) fn ensure_name_free(ctx: &Ctx, name: &str, version: &str) -> Result<(), CliError> { + let profiles = super::instances::read_profiles(ctx)?; + let conflict = profiles + .profiles + .iter() + .find(|profile| profile.name == name && profile.version_id != version); + match conflict { + Some(profile) => Err(CliError::user( + "instance_exists", + format!("同名实例已存在:{name}(绑定版本 {})", profile.version_id), + ) + .with_hint("用 --name 换个名字,或 `ull instances list` 看看现有实例")), + None => Ok(()), + } +} + +/// 安装期错误 → CLI 错误(保留稳定错误码与提示)。 +pub(super) fn map_install_error(error: &InstallError, ctx: &Ctx) -> CliError { + let hint = match error.hint() { + Some(hint) => format!("{hint}(游戏根:{})", ctx.game().root().display()), + None => format!("游戏根:{}", ctx.game().root().display()), + }; + CliError::user(error.kind().as_str(), error.message().to_string()).with_hint(hint) +} + +/// 有文件失败 → 按"最严重的失败类别"给稳定码,并列出前几条明细。 +pub(super) fn failure_error(report: &InstallReport, ctx: &Ctx) -> CliError { + let code = match report.failure_kind() { + Some(DownloadErrorKind::Integrity) => "install_integrity", + Some(DownloadErrorKind::Io) => "install_io", + _ => "install_network", + }; + let mut message = format!( + "{} 个文件没下成(共 {} 个)", + report.failed(), + report.files.len() + ); + for outcome in report.failures(3) { + if let Some(failure) = &outcome.failure { + message.push_str(&format!(";{}:{}", outcome.name, failure.message)); + } + } + CliError::user(code, message).with_hint(format!( + "重跑 `ull install` 会续传并跳过已就绪文件;游戏根 {}", + ctx.game().root().display() + )) +} diff --git a/crates/cli/src/commands/install/report.rs b/crates/cli/src/commands/install/report.rs new file mode 100644 index 0000000..af04893 --- /dev/null +++ b/crates/cli/src/commands/install/report.rs @@ -0,0 +1,262 @@ +//! 安装结果的两种呈现形态:人类可读行 + `--json` 载荷,外加 stderr 实时进度。 +//! +//! 拆出来是因为 `install` 的流程(规划 → 下载 → 登记)与排版彼此独立: +//! 前者要能读懂,后者要能被脚本断言。`--dry-run` 与真实安装共用同一份排版, +//! 差别只是多出逐文件清单与源选择。 + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use serde_json::json; +use unlucky_core::download::DownloadEngine; +use unlucky_core::game::install::{ + FileOutcome, InstallPlan, InstallReport, ProgressSink, Registration, +}; + +use crate::context::Ctx; +use crate::output::{progress_line, Report}; + +/// 每处理多少个文件打一行进度(3000 个资源下约 90 行,够用又不刷屏)。 +const PROGRESS_EVERY: usize = 32; + +/// 进度里最多逐条打印几条失败(其余交给最终错误汇总)。 +const MAX_PROGRESS_FAILURES: usize = 5; + +/// 实时进度回调(只写 stderr,不污染 `--json` 的 stdout)。 +/// +/// 两种形态:人类模式打文本行;`--json` 模式打 **JSONL**(一行一条事件,`event` 字段分流), +/// 这样脚本既能吃 stdout 的最终信封,也能实时跟进而不必解析文本。 +/// +/// 失败行只打前几条:整源不可达时会一口气失败上千个文件,全打出来既没用又淹没原因。 +pub fn progress_sink(enabled: bool, json: bool, total: usize) -> Option { + if !enabled { + return None; + } + let done = Arc::new(AtomicUsize::new(0)); + let failures = Arc::new(AtomicUsize::new(0)); + Some(Arc::new(move |outcome: &FileOutcome| { + let index = done.fetch_add(1, Ordering::Relaxed) + 1; + if let Some(failure) = &outcome.failure { + let seen = failures.fetch_add(1, Ordering::Relaxed); + if seen < MAX_PROGRESS_FAILURES { + emit( + json, + json!({ + "event": "file_failed", + "done": index, + "total": total, + "name": outcome.name, + "role": outcome.role.as_str(), + "kind": failure.kind.as_str(), + "message": failure.message, + }), + &format!("失败 {index}/{total} {}:{}", outcome.name, failure.message), + ); + } else if seen == MAX_PROGRESS_FAILURES { + emit( + json, + json!({"event": "progress_truncated", "suppressed_after": MAX_PROGRESS_FAILURES}), + "(更多失败不再逐条打印,见最终错误汇总)", + ); + } + return; + } + if index.is_multiple_of(PROGRESS_EVERY) || index == total { + emit( + json, + json!({"event": "progress", "done": index, "total": total}), + &format!("下载 {index}/{total}"), + ); + } + })) +} + +/// 一条进度:JSONL 模式打序列化后的一行,人类模式打短文本。 +fn emit(json_mode: bool, record: serde_json::Value, text: &str) { + if json_mode { + progress_line(&record.to_string()); + } else { + progress_line(text); + } +} + +/// 排版一次安装结果。 +#[allow(clippy::too_many_arguments)] +pub fn build( + ctx: &Ctx, + engine: &DownloadEngine, + now: u64, + dry_run: bool, + plan: &InstallPlan, + report: Option<&InstallReport>, + registration: Option<&Registration>, + name: &str, + version: &str, +) -> Report { + let samples = if dry_run { + plan.source_samples(engine, now) + } else { + Vec::new() + }; + let mut data = json!({ + "dry_run": dry_run, + "version_id": plan.version_id, + "jar_id": plan.jar_id, + "game_root": plan.game_root.display().to_string(), + "plan": plan.summary_json(), + "result": report.map(|report| json!({ + "downloaded": report.downloaded(), + "skipped": report.skipped(), + "failed": report.failed(), + "bytes": report.bytes(), + "version_json": report.version_json.as_ref().map(|path| path.display().to_string()), + "warnings": report.warnings, + "files": report.files.iter().map(outcome_json).collect::>(), + })), + "instance": registration.map(|registration| json!({ + "id": registration.profile.id.as_str(), + "name": registration.profile.name, + "version_id": registration.profile.version_id, + "created": registration.created, + "config_path": registration.config_path.display().to_string(), + "profiles_path": ctx.profiles_config().display().to_string(), + })), + }); + if dry_run { + data["files"] = json!(plan.files_json()); + data["sources"] = json!(samples); + } + + Report::new( + data, + human_lines(dry_run, plan, report, registration, &samples, name, version), + ) +} + +/// 单个文件的执行结果(`--json` 的 `result.files`;脚本据此分流"哪几个没下成")。 +fn outcome_json(outcome: &FileOutcome) -> serde_json::Value { + json!({ + "role": outcome.role.as_str(), + "name": outcome.name, + "dest": outcome.dest.display().to_string(), + "status": outcome.status.as_str(), + "bytes": outcome.bytes, + "source": outcome.source, + "error": outcome.failure.as_ref().map(|failure| json!({ + "kind": failure.kind.as_str(), + "message": failure.message, + })), + }) +} + +fn human_lines( + dry_run: bool, + plan: &InstallPlan, + report: Option<&InstallReport>, + registration: Option<&Registration>, + samples: &[serde_json::Value], + name: &str, + version: &str, +) -> Vec { + let mut human = Vec::new(); + let (total_bytes, unknown) = plan.total_bytes(); + human.push(if dry_run { + format!("安装计划:{}(jar {})", plan.version_id, plan.jar_id) + } else { + format!("已安装 {}(jar {})", plan.version_id, plan.jar_id) + }); + for (role, (count, bytes)) in plan.by_role() { + human.push(format!( + " {:<8} {} 个 / {}", + role.label(), + count, + human_bytes(bytes) + )); + } + human.push(format!( + " 合计 {} 个文件 / {}{}(游戏根 {})", + plan.file_count(), + human_bytes(total_bytes), + if unknown > 0 { + format!(" + {unknown} 个大小未知") + } else { + String::new() + }, + plan.game_root.display() + )); + for sample in samples { + let sources: Vec<&str> = sample["candidates"] + .as_array() + .map(|list| { + list.iter() + .filter_map(|candidate| candidate["source"].as_str()) + .collect() + }) + .unwrap_or_default(); + human.push(format!( + " 源选择 {} → {}", + sample["label"].as_str().unwrap_or_default(), + sources.join(" → ") + )); + } + for warning in &plan.warnings { + human.push(format!("告警: {warning}")); + } + if let Some(report) = report { + human.push(format!( + "下载 {} 个 / 跳过 {} 个已就绪 / 失败 {} 个(本次 {})", + report.downloaded(), + report.skipped(), + report.failed(), + human_bytes(report.bytes()) + )); + if let Some(path) = &report.version_json { + human.push(format!("版本清单: {}", path.display())); + } + } + match registration { + Some(registration) => human.push(format!( + "已登记实例 {}({})→ 版本 {}({})", + name, + registration.profile.id.as_str(), + version, + if registration.created { + "新建" + } else { + "已存在,复用" + } + )), + None => human.push("(--dry-run:未下载任何文件,未登记实例)".to_string()), + } + human +} + +/// 字节 → 人读(MB/GB 一位小数,KB 取整;不做假精度)。 +pub fn human_bytes(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = 1024 * KB; + const GB: u64 = 1024 * MB; + if bytes >= GB { + format!("{:.1} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.1} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{} KB", bytes / KB) + } else { + format!("{bytes} B") + } +} + +#[cfg(test)] +mod tests { + use super::human_bytes; + + #[test] + fn formats_sizes_without_fake_precision() { + assert_eq!(human_bytes(0), "0 B"); + assert_eq!(human_bytes(1023), "1023 B"); + assert_eq!(human_bytes(1024), "1 KB"); + assert_eq!(human_bytes(1024 * 1024), "1.0 MB"); + assert_eq!(human_bytes(3 * 1024 * 1024 * 1024), "3.0 GB"); + } +} diff --git a/crates/cli/src/commands/install/report_loader.rs b/crates/cli/src/commands/install/report_loader.rs new file mode 100644 index 0000000..d94d215 --- /dev/null +++ b/crates/cli/src/commands/install/report_loader.rs @@ -0,0 +1,274 @@ +//! 加载器安装的呈现形态(人类可读行 + `--json` 载荷)。 +//! +//! 与 [`super::report`] 的分工:那边是"原版安装"(客户端 jar + 资源 + 库),这边是 +//! "叠在原版上的 loader 层"(只有库)。共用同一套进度回调与字节格式化。 + +use serde_json::json; +use unlucky_core::addon::loader::processors::{ProcessorPlan, ProcessorRun}; +use unlucky_core::addon::loader::LoaderInstallPlan; +use unlucky_core::download::DownloadEngine; +use unlucky_core::game::install::{InstallReport, Registration}; + +use super::report::human_bytes; +use crate::context::Ctx; +use crate::output::Report; + +/// processors 段(M35)的报告输入:计划 + 依赖库下载结果 + 执行结果。 +/// +/// `--dry-run` 只有 `plan`;幂等跳过时 `libraries`/`run` 都是 `None` 且 `skipped = true`。 +pub struct ProcessorSection<'a> { + /// 执行计划(dry-run 也带上:将执行的 processor 清单 + 所需 Java)。 + pub plan: &'a ProcessorPlan, + /// processor 依赖库的下载结果。 + pub libraries: Option<&'a InstallReport>, + /// 执行结果。 + pub run: Option<&'a ProcessorRun>, + /// 整段跳过(版本 JSON 已在盘上 = processors 早已跑完)。 + pub skipped: bool, +} + +/// 排版一次加载器安装结果。 +/// +/// 参数多是因为载荷本身就多:计划(loader 版本 + 库)、processors 段、执行结果、实例登记、 +/// `--dry-run` 的源选择样本。拆成结构体会让调用方多写十行,不如保持扁平。 +#[allow(clippy::too_many_arguments)] +pub fn build( + ctx: &Ctx, + engine: &DownloadEngine, + now: u64, + dry_run: bool, + plan: &LoaderInstallPlan, + report: Option<&InstallReport>, + registration: Option<&Registration>, + name: &str, + processors: Option>, +) -> Report { + let samples = if dry_run { + plan.install.source_samples(engine, now) + } else { + Vec::new() + }; + let mut data = json!({ + "dry_run": dry_run, + "loader": plan.release.to_json(), + "vanilla_install_needed": plan.vanilla_install_needed, + "game_root": plan.install.game_root.display().to_string(), + "plan": plan.summary_json(), + "result": report.map(|report| json!({ + "downloaded": report.downloaded(), + "skipped": report.skipped(), + "failed": report.failed(), + "bytes": report.bytes(), + "version_json": report.version_json.as_ref().map(|path| path.display().to_string()), + "warnings": report.warnings, + })), + "instance": registration.map(|registration| json!({ + "id": registration.profile.id.as_str(), + "name": registration.profile.name, + "version_id": registration.profile.version_id, + "created": registration.created, + "config_path": registration.config_path.display().to_string(), + "profiles_path": ctx.profiles_config().display().to_string(), + })), + }); + if dry_run { + data["files"] = json!(plan.install.files_json()); + data["sources"] = json!(samples); + } + if let Some(section) = &processors { + data["processors"] = processor_json(section); + } + + Report::new( + data, + human_lines( + dry_run, + plan, + report, + registration, + &samples, + name, + processors.as_ref(), + ), + ) +} + +/// processors 段的 JSON 载荷(计划 + 依赖库结果 + 逐步骤结果)。 +fn processor_json(section: &ProcessorSection<'_>) -> serde_json::Value { + let mut node = section.plan.to_json(); + if let Some(object) = node.as_object_mut() { + object.insert("skipped".to_string(), json!(section.skipped)); + object.insert( + "libraries_result".to_string(), + json!(section.libraries.map(|report| json!({ + "downloaded": report.downloaded(), + "skipped": report.skipped(), + "failed": report.failed(), + "bytes": report.bytes(), + }))), + ); + object.insert( + "run".to_string(), + json!(section.run.map(ProcessorRun::to_json)), + ); + } + node +} + +fn human_lines( + dry_run: bool, + plan: &LoaderInstallPlan, + report: Option<&InstallReport>, + registration: Option<&Registration>, + samples: &[serde_json::Value], + name: &str, + processors: Option<&ProcessorSection<'_>>, +) -> Vec { + let release = &plan.release; + let mut human = Vec::new(); + human.push(format!( + "{} {}《{} {}》(继承原版 {},主类 {})", + if dry_run { + "加载器安装计划" + } else { + "已安装加载器" + }, + release.kind.label(), + release.mc_version, + release.loader_version, + release.mc_version, + release.version_json.main_class.as_deref().unwrap_or("?"), + )); + human.push(format!( + " 版本 id {} → {}", + release.version_id, + plan.install.version_json_path.display() + )); + human.push(format!( + " 原版 {}", + if plan.vanilla_install_needed { + "未安装:执行时先装原版" + } else { + "已安装:跳过" + } + )); + let (total_bytes, unknown) = plan.install.total_bytes(); + human.push(format!( + " loader 库 {} 个 / {}{}", + plan.install.file_count(), + human_bytes(total_bytes), + if unknown > 0 { + format!(" + {unknown} 个大小未知") + } else { + String::new() + } + )); + for sample in samples { + let sources: Vec<&str> = sample["candidates"] + .as_array() + .map(|list| { + list.iter() + .filter_map(|candidate| candidate["source"].as_str()) + .collect() + }) + .unwrap_or_default(); + human.push(format!( + " 源选择 {} → {}", + sample["label"].as_str().unwrap_or_default(), + sources.join(" → ") + )); + } + for warning in plan.warnings() { + human.push(format!("告警: {warning}")); + } + if let Some(section) = processors { + human.extend(processor_lines(section)); + } + if let Some(report) = report { + human.push(format!( + "下载 {} 个 / 跳过 {} 个已就绪 / 失败 {} 个(本次 {})", + report.downloaded(), + report.skipped(), + report.failed(), + human_bytes(report.bytes()) + )); + if let Some(path) = &report.version_json { + human.push(format!("loader 版本清单: {}", path.display())); + } + } + match registration { + Some(registration) => human.push(format!( + "已登记实例 {}({})→ 版本 {}({})", + name, + registration.profile.id.as_str(), + registration.profile.version_id, + if registration.created { + "新建" + } else { + "已存在,复用" + } + )), + None => human.push("(--dry-run:未下载任何文件,未登记实例)".to_string()), + } + human +} + +/// processors 段的人读行(计划 / 依赖库 / 逐步骤 / 告警)。 +fn processor_lines(section: &ProcessorSection<'_>) -> Vec { + let plan = section.plan; + let mut lines = Vec::new(); + lines.push(format!( + " installer processors {} 步,需要 {}(installer {})", + plan.step_count(), + plan.java_requirement.describe(), + plan.installer.display() + )); + if section.skipped { + lines.push(" 版本清单已存在:processors 整段跳过(幂等)".to_string()); + } + if let Some(libraries) = section.libraries { + lines.push(format!( + " processor 依赖库 {} 个:下载 {} / 跳过 {} / 失败 {}", + plan.libraries.len(), + libraries.downloaded(), + libraries.skipped(), + libraries.failed() + )); + } + if let Some(run) = section.run { + lines.push(format!( + " Java {}({}){}", + run.java + .major + .map_or_else(|| "未知".to_string(), |major| major.to_string()), + run.java.origin.as_str(), + run.java.binary.display() + )); + let total = run.steps.len(); + for step in &run.steps { + lines.push(format!( + " [{}/{}] {} → {}{}", + step.index, + total, + step.label, + if step.status.as_str() == "skipped" { + "跳过(产物已就绪)".to_string() + } else { + format!("执行 {} ms", step.elapsed_ms) + }, + if step.outputs > 0 { + format!("(产物 {} 个)", step.outputs) + } else { + String::new() + } + )); + } + for warning in &run.warnings { + lines.push(format!(" 告警: {warning}")); + } + } + for warning in &plan.warnings { + lines.push(format!(" 告警: {warning}")); + } + lines +} diff --git a/crates/cli/src/commands/install/vanilla.rs b/crates/cli/src/commands/install/vanilla.rs new file mode 100644 index 0000000..e01d8e2 --- /dev/null +++ b/crates/cli/src/commands/install/vanilla.rs @@ -0,0 +1,160 @@ +//! `ull install `(无加载器):M27 原版安装流程的宿主。 +//! +//! ```text +//! RemoteSource::resolve(版本清单缓存 → 版本 JSON → 继承链) +//! → RemoteSource::asset_index(资源索引,顺便拿字节给下面预取) +//! → InstallPlan::build(纯计算:文件清单 / 总大小 / 角色) +//! → [--dry-run 到此为止] +//! → execute(跳过已就绪 → 引擎并发下载 → 原子写版本 JSON) +//! → register(profiles.json + config/instances/.json) +//! ``` +//! +//! 异步整段包在一次 `block_on` 里:CLI 不依赖 tokio(D18 依赖树纪律), +//! reqwest 的连接池也不会跨运行时而失效。 + +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, InstallErrorKind, InstallPlan, InstallReport, PlanInput, RemoteSource, +}; + +use super::{ + build_engine, ensure_name_free, failure_error, map_install_error, report, CONCURRENCY, +}; +use crate::cli::InstallArgs; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +/// 执行 `install`(原版)。 +/// +/// # Errors +/// +/// 用法错误、版本不存在、网络失败、校验不符、写盘失败、实例清单损坏。 +pub(super) fn run(ctx: &Ctx, args: &InstallArgs) -> Result { + let version = args.version.trim().to_string(); + if version.is_empty() { + return Err(CliError::usage( + "install 需要版本 id:ull install ", + )); + } + let name = args + .name + .clone() + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| version.clone()); + ensure_name_free(ctx, &name, &version)?; + + let engine = build_engine(ctx); + let now = now_ms(); + let (plan, report) = plan_and_install( + ctx, + &engine, + &version, + args.dry_run, + args.progress, + args.json_progress, + )?; + + if let Some(report) = &report { + if !report.is_complete() { + return Err(failure_error(report, ctx)); + } + } + + let registration = if args.dry_run { + None + } else { + Some(super::register_instance(ctx, &name, &version)?) + }; + + Ok(report::build( + ctx, + &engine, + now, + args.dry_run, + &plan, + report.as_ref(), + registration.as_ref(), + &name, + &version, + )) +} + +/// 规划 + 执行原版安装(`--dry-run` 只规划),供加载器安装当"前置步骤"复用。 +/// +/// # Errors +/// +/// 版本不存在、网络失败、清单装不全、写盘失败。 +pub(super) fn plan_and_install( + ctx: &Ctx, + engine: &DownloadEngine, + version: &str, + dry_run: bool, + progress: bool, + json_progress: bool, +) -> Result<(InstallPlan, Option), CliError> { + let game = ctx.game(); + let meta_cache = ctx.layout().meta_cache(); + let now = now_ms(); + + block_on(async { + let source = RemoteSource::new(engine, &game, &meta_cache, now); + let resolved = source.resolve(version).await?; + let fetched = match resolved + .asset_index + .as_ref() + .filter(|index| index.url.is_some()) + { + Some(reference) => { + let (index, bytes) = source.asset_index(reference).await?; + Some((index, game.asset_index(&reference.id), bytes)) + } + None => None, + }; + let mut plan = InstallPlan::build(PlanInput { + layout: &game, + version_id: version, + resolved: &resolved, + asset_index: fetched.as_ref().map(|(index, _, _)| index), + environment: &Environment::host(), + })?; + if let Some((_, dest, bytes)) = fetched { + plan.prefetch(dest, bytes); + } + if dry_run { + return Ok::(Attempt { plan, report: None }); + } + // 计划里没有客户端 jar(旧版清单缺 downloads.client)→ 拒绝"装出个空壳"。 + if !plan.is_installable() { + let reasons = if plan.warnings.is_empty() { + "清单缺少客户端 jar 的下载信息".to_string() + } else { + plan.warnings.join(";") + }; + return Err(InstallError::new( + InstallErrorKind::Metadata, + format!("版本 {version} 装不全:{reasons}"), + ) + .with_hint( + "旧版(pre-1.6)清单兜底待补;可先用官方启动器装好该版本再用 `ull launch`", + )); + } + let sink = report::progress_sink(progress, json_progress, plan.file_count()); + let report = execute(engine, &plan, CONCURRENCY, sink).await?; + Ok(Attempt { + plan, + report: Some(report), + }) + }) + .map(|attempt| (attempt.plan, attempt.report)) + .map_err(|error: InstallError| map_install_error(&error, ctx)) +} + +/// 一次安装尝试的产物(`--dry-run` 只有计划)。 +struct Attempt { + plan: InstallPlan, + report: Option, +} diff --git a/crates/cli/src/commands/instances.rs b/crates/cli/src/commands/instances.rs new file mode 100644 index 0000000..a5021e0 --- /dev/null +++ b/crates/cli/src/commands/instances.rs @@ -0,0 +1,90 @@ +//! `instances list`:实例与已安装版本的总览。 +//! +//! 安装/登记在 [`super::install`];这里只读(`profiles.json` + `versions/` 的存在性)。 +//! 读 `profiles.json` 的实现委托给 `core::game::install::instance`,避免 CLI 自带一份 +//! 解析逻辑(同一份文件两处解析迟早会漂移)。 + +use std::path::PathBuf; + +use serde_json::{json, Value}; +use unlucky_core::game::install::instance::read_profiles as read_profiles_from_layout; +use unlucky_core::game::ProfileList; + +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +/// `ull instances list`。 +/// +/// # Errors +/// +/// `profiles.json` 读不了或解析失败时返回用户错误。 +pub fn list(ctx: &Ctx) -> Result { + let profiles = read_profiles(ctx)?; + let game = ctx.game(); + let installed = game.installed_versions().unwrap_or_default(); + + let mut rows: Vec = Vec::new(); + let mut body: Vec = Vec::new(); + for profile in &profiles.profiles { + let present = game.is_version_installed(&profile.version_id); + body.push(format!( + " {} [{}] {} {}", + profile.name, + profile.version_id, + if present { "已安装" } else { "载荷缺失" }, + profile.id.as_str() + )); + rows.push(json!({ + "id": profile.id.as_str(), + "name": profile.name, + "version_id": profile.version_id, + "version_present": present, + "game_dir": option_path(profile.game_dir.as_ref()), + "java_path": option_path(profile.java_path.as_ref()), + "last_played_at": profile.last_played_at, + })); + } + + let data = json!({ + "data_root": ctx.data.launcher_home.display().to_string(), + "game_root": ctx.data.game_home.display().to_string(), + "instances": rows, + "installed_versions": installed, + }); + + let mut human = vec![format!( + "实例 {} 个(数据根 {})", + profiles.profiles.len(), + ctx.data.launcher_home.display() + )]; + if body.is_empty() { + human.push(" (空)用 `ull install ` 登记第一个实例".to_string()); + } + human.extend(body); + if installed.is_empty() { + human.push("已安装版本: (无)".to_string()); + } else { + human.push(format!("已安装版本: {}", installed.join(", "))); + } + Ok(Report::new(data, human)) +} + +/// 读实例清单(文件不存在 = 空列表,不算错误)。 +/// +/// # Errors +/// +/// 读盘失败或 JSON 非法时返回用户错误。 +pub fn read_profiles(ctx: &Ctx) -> Result { + let path = ctx.profiles_config(); + read_profiles_from_layout(&ctx.layout()).map_err(|error| { + CliError::user( + "profiles_parse", + format!("读实例清单 {} 失败:{error}", path.display()), + ) + }) +} + +fn option_path(path: Option<&PathBuf>) -> Option { + path.map(|path| path.display().to_string()) +} diff --git a/crates/cli/src/commands/java.rs b/crates/cli/src/commands/java.rs new file mode 100644 index 0000000..6e35269 --- /dev/null +++ b/crates/cli/src/commands/java.rs @@ -0,0 +1,365 @@ +//! Java 运行时:`ull java list` / `install` / `vendors`(D13 v1 + D23 v2)。 +//! +//! 四件事:建下载引擎、把 core 的结果排成人读与 `--json` 两种形态、把错误翻成稳定码、 +//! 从设置里取 JDK 的落点与发行版优先级。探测/下载/解压/映射/回退链全在 `core::game::java`。 +//! +//! D23 之后的三个用户可见变化: +//! +//! 1. **落点可选**:`java.installRoot` 设了就装到那里;否则 Standard 数据模式装到 +//! `%APPDATA%\Java\<发行方>\<版本>\`(mac/Linux 见 `game::java::location`), +//! Portable 仍在数据根 `java-managed/`(v1 布局不变); +//! 2. **发行版可选**:`--vendor temurin|oracle|microsoft|zulu` 钉死一家;不给就按 +//! `java.vendorPriority` → 地区默认序跑回退链,降级原因进 `--json` 的 `chain` 块; +//! 3. **`java vendors`** 一眼看清四家的可用性、镜像与当前顺序。 + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde_json::{json, Value}; +use unlucky_core::download::blocking::block_on; +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, RegionProfile, +}; +use unlucky_core::game::java::{ + self, location, vendor, InstallRequest, JavaOrigin, JavaRuntime, JdkVendor, LocationMode, + DEFAULT_MAJOR, +}; +use unlucky_core::launch::request::JavaSelection; +use unlucky_core::platform::paths::ProcessEnv; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +pub use super::java_launch::{resolve_for_launch, LaunchJava}; +pub use super::java_vendors::vendors; + +/// 可自动安装的最老大版本(再老的 Temurin 没有 GA 产物)。 +const MIN_INSTALLABLE_MAJOR: u32 = 8; + +/// 装配用的 Java 选择(`--java` > `JAVA_HOME` > PATH 上的 `java`)。 +/// +/// `snapshot::prepare` 调它拿 [`JavaSelection`];真正的来源优先级在 +/// [`resolve_for_launch`] 里求值(那一步还会看托管目录与版本要求)。 +#[must_use] +pub fn selection(explicit: Option<&Path>) -> JavaSelection { + let (binary, origin) = match explicit { + Some(path) => (path.to_path_buf(), JavaOrigin::Instance), + None => match env_java() { + Some(binary) => (binary, JavaOrigin::JavaHome), + None => (PathBuf::from(java::java_binary_name()), JavaOrigin::Path), + }, + }; + let runtime = java::probe(&binary, origin); + JavaSelection::new(binary, runtime.major.unwrap_or(DEFAULT_MAJOR), runtime.bits) +} + +/// 托管 JDK 可能存在的全部安装根(Portable 数据根 + Standard/Custom 落点,D23 后不止一个)。 +#[must_use] +pub fn managed_roots(ctx: &Ctx) -> Vec { + let settings = JavaSettings::load(ctx); + location::scan_roots( + ctx.data.mode, + &ctx.layout(), + settings.install_root.as_deref(), + ctx.platform, + &ProcessEnv, + ) +} + +/// `ull java list`:探测托管目录 / `JAVA_HOME` / `PATH` / 实例覆盖,并显示各 JDK 的落点。 +/// +/// # Errors +/// +/// 实例清单读不了时返回用户错误(其余探测失败只体现为缺项)。 +pub fn list(ctx: &Ctx) -> Result { + let settings = JavaSettings::load(ctx); + let roots = managed_roots(ctx); + // 托管 → JAVA_HOME → PATH → 标准安装位置(Program Files 系 / 官方启动器 runtime / 注册表), + // 同一路径只列一次且保留优先级高者(`discover::dedupe` 负责)。 + let mut candidates = java::candidates_in_roots(&roots); + let profiles = super::instances::read_profiles(ctx)?; + for profile in &profiles.profiles { + if let Some(java_path) = &profile.java_path { + candidates.push(java::probe(java_path, JavaOrigin::Instance)); + } + } + let candidates = java::discover::dedupe(candidates); + let candidates: Vec = candidates + .into_iter() + .filter(|candidate| candidate.major.is_some()) + .collect(); + + let mode = settings.location_mode(ctx); + let installed_root = settings.location(ctx).map(|root| root.root).ok(); + let data = json!({ + // 兼容 v1 的 `managed_root`(首个扫描根),同时给出多根清单(D23 之后不止一个根)。 + "managed_root": roots.first().map(|root| root.display().to_string()), + "managed_roots": roots.iter().map(|root| root.display().to_string()).collect::>(), + "install": { + "mode": mode.as_str(), + "root": installed_root.as_ref().map(|root| root.display().to_string()), + "override": settings.install_root.as_ref().map(|root| root.display().to_string()), + }, + "candidates": candidates.iter().map(candidate_json).collect::>(), + }); + let mut human = vec![format!("Java {} 个", candidates.len())]; + if candidates.is_empty() { + human.push( + "(没找到可用 Java)用 `ull java install 21` 装一个,或设 JAVA_HOME / `ull launch --java `" + .to_string(), + ); + } + human.push(format!( + "落点模式:{}", + describe_mode(mode, settings.install_root.as_deref()) + )); + for root in &roots { + human.push(format!(" 扫描根:{}", root.display())); + } + for candidate in &candidates { + human.push(format!( + " {} {} [{}]", + candidate + .version + .clone() + .unwrap_or_else(|| "版本未知".to_string()), + candidate.binary.display(), + source_label(candidate) + )); + } + Ok(Report::new(data, human)) +} + +/// `ull java install [--vendor X]`:回退链查版本 → 下载 → 解压 → `java -version` 验证。 +/// +/// 幂等:同一个归档装过就直接复用(不联网、不解压)。下载走 `core::download` 引擎, +/// 国内环境按 D7 的地区策略优先院校/国内镜像。 +/// +/// # Errors +/// +/// 大版本不合法、平台无产物、所有发行版都失败(网络/校验/解压/跑不起来)时返回用户错误。 +pub fn install(ctx: &Ctx, major: u32, vendor: Option) -> Result { + if !(MIN_INSTALLABLE_MAJOR..=99).contains(&major) { + return Err(CliError::usage(format!( + "大版本号看起来不对:{major}(可用 8 / 11 / 17 / 21 / 25 这类 GA 大版本)" + ))); + } + let settings = JavaSettings::load(ctx); + let engine = build_engine(ctx); + let install_root = settings.location(ctx)?; + let request = match vendor { + Some(vendor) => { + InstallRequest::with_vendor(major, vendor).with_priority(settings.priority.clone()) + } + None => InstallRequest::new(major).with_priority(settings.priority.clone()), + }; + let installed = + block_on(java::install(&engine, &install_root, request)).map_err(map_java_error)?; + + let data = json!({ + "major": installed.major, + "vendor": installed.vendor.id(), + "release": installed.release.release_name, + "version": installed.release.version, + "semver": installed.release.semver, + "archive": installed.release.archive.name, + "sha256": installed.release.archive.sha256, + "size": installed.release.archive.size, + "root": installed.root.display().to_string(), + "install_mode": installed.mode.as_str(), + "binary": installed.binary().display().to_string(), + "reused": installed.reused(), + "source": installed.downloaded, + "bytes": installed.bytes, + "files": installed.files, + "extracted": installed.extracted, + "chain": installed.chain.to_json(), + "runtime": candidate_json(&installed.runtime), + }); + + let mut human = Vec::new(); + if installed.reused() { + human.push(format!( + "Java {}({})已就绪({}),未走网络", + installed.major, + installed.vendor.id(), + installed.root.display() + )); + } else { + human.push(format!( + "已安装 Java {}({},来源 {},{} 字节 / {} 个文件)", + installed.major, + installed.release.summary(), + installed.downloaded.as_deref().unwrap_or("?"), + installed.bytes, + installed.files + )); + } + human.push(format!("落点:{}", installed.location_line())); + human.push(format!("可执行文件:{}", installed.binary().display())); + if let Some(version) = &installed.runtime.version { + human.push(format!("探测:{version}")); + } + for note in installed.chain.degradations() { + human.push(format!("降级:{note}")); + } + human.push("`ull launch` 会自动优先用它".to_string()); + Ok(Report::new(data, human)) +} + +/// 设置里与 JDK 有关的两项(`java.installRoot` / `java.vendorPriority`)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct JavaSettings { + pub(crate) install_root: Option, + pub(crate) priority: Vec, +} + +impl JavaSettings { + /// 读 `<数据根>/.ULL/config/launcher.json`(坏文件退化成默认值,不阻断命令)。 + pub(crate) fn load(ctx: &Ctx) -> Self { + let store = SettingStore::load(ctx.launcher_config(), launcher_schema()); + let install_root = store + .doc() + .effective_str("java.installRoot") + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(PathBuf::from); + let priority = store + .effective("java.vendorPriority") + .and_then(Value::as_array) + .map(|items| { + let texts: Vec = items + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect(); + vendor::parse_priority(&texts) + }) + .unwrap_or_default(); + Self { + install_root, + priority, + } + } + + /// 落点模式(只描述,不建目录)。 + fn location_mode(&self, ctx: &Ctx) -> LocationMode { + if self.install_root.is_some() { + LocationMode::Custom + } else if ctx.data.mode == unlucky_core::platform::data_dir::DataMode::Standard { + LocationMode::Standard + } else { + LocationMode::Portable + } + } + + /// 真正要装到哪(解析失败 → 用户错误,带可照着做的提示)。 + fn location(&self, ctx: &Ctx) -> Result { + java::JavaInstallRoot::resolve( + ctx.data.mode, + &ctx.layout(), + self.install_root.as_deref(), + ctx.platform, + &ProcessEnv, + ) + .map_err(map_java_error) + } +} + +/// 落点模式 → 人读一行(含「没设覆盖键时默认落在哪」的解释)。 +fn describe_mode(mode: LocationMode, override_root: Option<&Path>) -> String { + match mode { + LocationMode::Custom => format!( + "custom(java.installRoot = {})", + override_root.unwrap_or(Path::new("?")).display() + ), + LocationMode::Standard => { + "standard(%APPDATA%\\Java\\<发行方>\\<版本> 等系统标准目录)".to_string() + } + LocationMode::Portable => "portable(<数据根>/.ULL/data/java-managed)".to_string(), + } +} + +/// 一个运行时的 `--json` 形状。 +fn candidate_json(runtime: &JavaRuntime) -> Value { + json!({ + "binary": runtime.binary.display().to_string(), + "source": runtime.origin.as_str(), + "source_category": runtime.origin.category(), + "version": runtime.version, + "major": runtime.major, + "bits": runtime.bits, + "vendor": runtime.vendor, + "install_root": install_root_of(runtime), + }) +} + +/// 托管 JDK 的实际目录(`bin/java` 的上两级);非托管来源为 `None`。 +fn install_root_of(runtime: &JavaRuntime) -> Option { + if runtime.origin != JavaOrigin::Managed { + return None; + } + runtime + .binary + .parent() + .and_then(Path::parent) + .map(|dir| dir.display().to_string()) +} + +/// 建下载引擎:内容寻址缓存落 `.ULL/cache/`,地区判定走 24h 缓存 + 环境信号。 +fn build_engine(ctx: &Ctx) -> DownloadEngine { + let layout = ctx.layout(); + let cache_root = 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)), + ) +} + +/// JDK 下载/解压错误 → CLI 错误(保留稳定码与提示)。 +fn map_java_error(error: java::JavaError) -> CliError { + let cli = CliError::user(error.code(), error.message().to_string()); + match error.hint() { + Some(hint) => cli.with_hint(hint.to_string()), + None => cli, + } +} + +/// `JAVA_HOME/bin/java`(存在才返回)。 +fn env_java() -> Option { + let home = std::env::var_os("JAVA_HOME")?; + let binary = PathBuf::from(home) + .join("bin") + .join(java::java_binary_name()); + binary.is_file().then_some(binary) +} + +/// 运行时 → 展示标签(托管项带上 vendor)。 +fn source_label(runtime: &JavaRuntime) -> String { + match &runtime.vendor { + Some(vendor) => format!("{} · {vendor}", runtime.origin.as_str()), + None => runtime.origin.as_str().to_string(), + } +} + +/// 当前地区档案(`java vendors` 展示「国内/国外」口径;也给 GUI 复用)。 +#[must_use] +pub fn region_profile(ctx: &Ctx) -> RegionProfile { + build_engine(ctx).region().clone() +} diff --git a/crates/cli/src/commands/java_launch.rs b/crates/cli/src/commands/java_launch.rs new file mode 100644 index 0000000..16f374d --- /dev/null +++ b/crates/cli/src/commands/java_launch.rs @@ -0,0 +1,129 @@ +//! 启动期的 Java 解析(`ull launch` 用):实例覆盖 > 托管匹配 > 系统。 +//! +//! 从 `commands/java.rs` 拆出来(那边还要放 list/install/vendors,超 400 行硬上限)。 +//! 这里的职责只有两条:①按 D13 的优先链挑一个 Java;②把结果整理成 `--json` 块与告警行。 +//! 真正「谁优先」的判定在 `core::game::java::probe` / `select`(纯函数),本文件只做装配。 + +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; +use unlucky_core::game::java::{self, JavaMismatch, JavaOrigin, JavaRequirement, JavaRuntime}; + +use crate::context::Ctx; + +/// 启动期的 Java 解析结果。 +#[derive(Debug, Clone)] +pub struct LaunchJava { + /// 喂给 `PlanInput.java` 的显式覆盖;`None` = 让 `snapshot::prepare` 按系统兜底。 + pub override_path: Option, + /// 实际选中的运行时。 + pub runtime: Option, + /// 目标版本对 Java 的要求。 + pub requirement: JavaRequirement, + /// 不符说明(只提示,不阻断)。 + pub mismatch: Option, +} + +impl LaunchJava { + /// `--json` 的 `java` 块(app 层 GUI 弹窗 P1 后直接吃这份数据)。 + #[must_use] + pub fn to_json(&self) -> Value { + let runtime = self.runtime.as_ref(); + json!({ + "binary": runtime.map(|runtime| runtime.binary.display().to_string()), + "major": runtime.and_then(|runtime| runtime.major), + "bits": runtime.map(|runtime| runtime.bits), + "source": runtime.map(|runtime| runtime.origin.as_str()), + "vendor": runtime.and_then(|runtime| runtime.vendor.clone()), + "required": self.requirement.describe(), + "required_major": self.requirement.minimum, + "requirement_source": self.requirement.source.as_str(), + "mismatch": self.mismatch.map(|mismatch| json!({ + "code": mismatch.code(), + "message": mismatch.message(), + })), + }) + } + + /// 告警行(dry-run 与真启动都输出)。 + /// + /// 两类:①用户指定的 Java 探测不出来(路径写错了,静默退回默认版本会很难查); + /// ②Java 与版本要求不符(D13:提示,不阻断)。 + #[must_use] + pub fn warnings(&self) -> Vec { + let mut warnings = Vec::new(); + if let Some(runtime) = &self.runtime { + if runtime.major.is_none() { + warnings.push(format!( + "探测不到 {} 的 `java -version` 输出(路径可能不对)", + runtime.binary.display() + )); + } + } + if let Some(mismatch) = self.mismatch { + warnings.push(mismatch.message()); + } + warnings + } + + /// 人读一行(Java 来源 + 要求)。 + #[must_use] + pub fn summary(&self) -> String { + let source = self + .runtime + .as_ref() + .map_or("未探测到", |runtime| runtime.origin.as_str()); + let version = self + .runtime + .as_ref() + .and_then(|runtime| runtime.major) + .map_or_else(|| "未知".to_string(), |major| major.to_string()); + format!( + "Java {version}({source})· 该版本要求 {}", + self.requirement.describe() + ) + } +} + +/// 解析启动用的 Java:实例覆盖 > 托管匹配 > 系统。 +/// +/// 覆盖优先级的求值顺序写在 `core::game::java::probe`(纯函数,可注入候选); +/// 这里只负责「实例覆盖单独成路」与「把结果钉成路径」。 +#[must_use] +pub fn resolve_for_launch(ctx: &Ctx, version_id: &str, explicit: Option<&Path>) -> LaunchJava { + let requirement = java::requirement_for_version(&ctx.game(), version_id); + let (runtime, mismatch) = match explicit { + Some(path) => { + let runtime = java::probe(path, JavaOrigin::Instance); + let mismatch = runtime + .major + .and_then(|major| java::check(major, &requirement)); + (Some(runtime), mismatch) + } + None => { + // 先看托管 + 环境变量;都不满足要求才去扫盘(别为了一次启动白跑 N 次 java -version)。 + // 托管区可能不止一个根(Portable 与 Standard/Custom 并存,D23)。 + let roots = super::java::managed_roots(ctx); + let mut candidates = java::discover_managed_roots(&roots); + candidates.extend(java::system_candidates()); + let mut resolution = java::resolve(&candidates, &requirement); + if resolution.mismatch.is_some() { + candidates.extend(java::standard_candidates()); + resolution = java::resolve(&java::discover::dedupe(candidates), &requirement); + } + (resolution.runtime, resolution.mismatch) + } + }; + // 托管 JDK 要钉死路径(用户 PATH 上的 java 可能正是我们要避开的那一个); + // 系统来源交回 `snapshot::prepare` 的 `selection()` 兜底,避免重复实现一遍环境判断。 + let override_path = match &runtime { + Some(runtime) if runtime.origin == JavaOrigin::Managed => Some(runtime.binary.clone()), + _ => explicit.map(Path::to_path_buf), + }; + LaunchJava { + override_path, + runtime, + requirement, + mismatch, + } +} diff --git a/crates/cli/src/commands/java_vendors.rs b/crates/cli/src/commands/java_vendors.rs new file mode 100644 index 0000000..96cfb22 --- /dev/null +++ b/crates/cli/src/commands/java_vendors.rs @@ -0,0 +1,141 @@ +//! `ull java vendors`:四家发行版的可用性 / 镜像 / 当前优先级顺序(D23)。 +//! +//! 从 `commands/java.rs` 拆出来(那边 list/install 已经占满一屏)。这里只读不写: +//! 不联网、不改配置,把 `mirrors.json` 的表与设置里的优先级排成一张人读 + `--json` 的对照表。 +//! +//! 存在的意义是**可观测**:用户问「国内为什么默认走 Oracle 而不是微软」时,命令要能直接答 +//! (顺序 + 每家的镜像与实测吞吐),而不是让人翻文档。 + +use serde_json::{json, Value}; +use unlucky_core::download::MirrorTable; +use unlucky_core::game::java::{vendor, JdkVendor}; + +use super::java::{region_profile, JavaSettings}; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +/// `ull java vendors`。 +/// +/// # Errors +/// +/// 设置文件坏掉时退化为默认值;命令本身不产生用户错误。 +pub fn vendors(ctx: &Ctx) -> Result { + let settings = JavaSettings::load(ctx); + let domestic = region_profile(ctx).is_domestic(); + let order = vendor::order(&settings.priority, domestic); + let table = MirrorTable::builtin(); + let rows: Vec = order + .iter() + .enumerate() + .map(|(index, item)| vendor_json(&table, *item, index)) + .collect(); + let priority_source = if settings.priority.is_empty() { + "region_default" + } else { + "java.vendorPriority" + }; + let data = json!({ + "region": if domestic { "domestic" } else { "global" }, + "priority_source": priority_source, + "order": vendor::ids(&order), + "vendors": rows, + }); + let mut human = vec![format!( + "发行版顺序({},来源 {}):{}", + if domestic { "国内" } else { "国外" }, + if settings.priority.is_empty() { + "地区默认序" + } else { + "java.vendorPriority" + }, + vendor::ids(&order).join(" → ") + )]; + for row in &rows { + human.push(format!( + " {} {} {} 支持 Java {}", + row["rank"].as_u64().unwrap_or(0), + row["vendor"].as_str().unwrap_or("?"), + row["display"].as_str().unwrap_or(""), + join_numbers(&row["majors"], "/") + )); + for source in row["sources"].as_array().into_iter().flatten() { + if let Some(text) = source.as_str() { + human.push(format!(" 源:{text}")); + } + } + } + Ok(Report::new(data, human)) +} + +/// 一家的 `--json` 形状(源清单按家取,缺表时为空数组而不是报错)。 +fn vendor_json(table: &MirrorTable, item: JdkVendor, index: usize) -> Value { + let sources: Vec = match item { + JdkVendor::Temurin => table + .adoptium + .iter() + .filter(|mirror| mirror.enabled) + .map(|mirror| { + format!( + "{}{}", + mirror.name, + if mirror.latest_only { + "(只留最新补丁)" + } else { + "" + } + ) + }) + .chain(std::iter::once( + "GitHub 官方(国内套 gh-proxy)".to_string(), + )) + .collect(), + JdkVendor::Oracle => table + .oracle_mirrors() + .map(|mirror| match mirror.speed.trim() { + "" => format!("{} {}", mirror.name, mirror.base()), + speed => format!("{} {} {}", mirror.name, mirror.base(), speed), + }) + .collect(), + JdkVendor::Microsoft => table + .microsoft_openjdk() + .map(|microsoft| { + let speed = if microsoft.measured_speed.trim().is_empty() { + "实测约 400 KB/s".to_string() + } else { + microsoft.measured_speed.clone() + }; + vec![format!( + "aka.ms → VS CDN({speed});无国内镜像、无 GitHub 资产" + )] + }) + .unwrap_or_default(), + JdkVendor::Zulu => table + .zulu() + .map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)]) + .unwrap_or_default(), + }; + json!({ + "rank": index + 1, + "vendor": item.id(), + "display": item.display(), + "majors": item.hint_majors(), + "supported": (8..=99u32).filter(|major| item.supports(*major)).collect::>(), + "sources": sources, + }) +} + +/// JSON 数字数组 → `21/17/11` 这种分隔串。 +fn join_numbers(value: &Value, separator: &str) -> String { + value + .as_array() + .map(|items| { + items + .iter() + .filter_map(Value::as_u64) + .map(|number| number.to_string()) + .collect::>() + .join(separator) + }) + .unwrap_or_default() +} diff --git a/crates/cli/src/commands/launch.rs b/crates/cli/src/commands/launch.rs new file mode 100644 index 0000000..03d6f0d --- /dev/null +++ b/crates/cli/src/commands/launch.rs @@ -0,0 +1,377 @@ +//! `ull launch [--dry-run]`(D18:`--dry-run` 只打印组装后的命令行)。 +//! +//! D20 的隔离档位在**这里**落成 `--gameDir`:本命令按覆盖链 +//! (临时参数 > 实例 `isolation` > 全局 `instances.mode`)派生出运行目录, +//! 交给 [`snapshot::prepare`] 装进 `LaunchDirs.game_dir`(占位符 `${game_directory}` +//! 与子进程 CWD 都取这个值),并在启动时做一次切换检测(只探测、不迁移)。 + +use std::path::PathBuf; + +use serde_json::json; +use unlucky_core::game::install::instance::{ + read_instance_config, write_instance_config, InstanceConfig, Isolation, +}; +use unlucky_core::game::isolation::{derive_game_dir, observe_mode, IsolationMode}; +use unlucky_core::game::ProfileList; +use unlucky_core::launch::time::{format_rfc3339_millis, now}; +use unlucky_core::launch::{LaunchError, Launcher, LauncherConfig}; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +use crate::cli::LaunchArgs; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +use super::identity::{self, Identity}; +use super::snapshot::{self, PlanInput}; + +/// 执行 `launch`。 +/// +/// # Errors +/// +/// 实例/版本不存在、版本 JSON 不可用、命令行组装失败、进程起不来时返回用户错误。 +pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result { + let profiles = super::instances::read_profiles(ctx)?; + let game = ctx.game(); + let profile = profiles + .profiles + .iter() + .find(|profile| profile.id.as_str() == args.instance || profile.name == args.instance); + + let (input, instance_found) = match profile { + Some(profile) => ( + PlanInput { + version_id: profile.version_id.clone(), + instance_id: profile.id.as_str().to_string(), + instance_name: profile.name.clone(), + game_dir: profile.game_dir.clone(), + java: args.java.clone().or_else(|| profile.java_path.clone()), + username: args.username.clone(), + max_memory: args.max_memory, + ..PlanInput::default() + }, + true, + ), + None => { + // 也接受「直接给版本 id」:没有实例档案时按版本跑一次(脚本化常见)。 + if !game.is_version_installed(&args.instance) { + return Err(CliError::user( + "not_found", + format!( + "既没有实例 `{}`,也没有版本 `{}`", + args.instance, args.instance + ), + ) + .with_hint(instance_hint(&profiles))); + } + ( + PlanInput { + version_id: args.instance.clone(), + instance_name: args.instance.clone(), + java: args.java.clone(), + username: args.username.clone(), + max_memory: args.max_memory, + ..PlanInput::default() + }, + false, + ) + } + }; + + // 实例存在性先确认,再解析身份:微软轨解析身份会联网刷新令牌(有副作用), + // 不该在一个注定 not_found 的启动上白跑一遍、更不该顺手改写凭据。 + let identity = identity::resolve( + ctx, + args.account.as_deref(), + args.username.as_deref(), + args.dry_run, + )?; + let input = PlanInput { + auth: Some(identity.placeholders.clone()), + ..input + }; + let isolation = resolve_isolation(ctx, profile, &input, args.dry_run)?; + let input = PlanInput { + // 派生结果就是 `--gameDir` 的取值(覆盖链已求值完)。 + game_dir: Some(isolation.game_dir.clone()), + ..input + }; + + // D13:实例覆盖 > 托管匹配 > 系统。托管命中时把路径钉死交给 `snapshot::prepare`。 + let java = super::java::resolve_for_launch(ctx, &input.version_id, input.java.as_deref()); + let input = PlanInput { + java: java.override_path.clone(), + ..input + }; + + let plan = snapshot::prepare(ctx, &input)?; + let config = LauncherConfig::new(ctx.sessions_root()) + .with_launcher(super::meta::LAUNCHER_NAME, env!("CARGO_PKG_VERSION")); + let launcher = Launcher::new(config); + let mut human = Vec::new(); + human.push(format!( + "隔离档位 {}(来源 {})· 运行目录 {}", + isolation.isolation.as_str(), + isolation.source.as_str(), + isolation.game_dir.display() + )); + if let Some(warning) = &isolation.warning { + human.push(format!("提示: {warning}")); + } + human.push(identity_line(&identity)); + for note in &identity.notes { + human.push(format!("提示: {note}")); + } + human.push(java.summary()); + for warning in java.warnings() { + human.push(format!("告警: {warning}")); + } + + if args.dry_run { + let dry = launcher + .dry_run(&plan.request) + .map_err(|error| map_launch_error(&error, ctx))?; + let mut lines = vec![dry.line().to_string()]; + lines.push(format!( + "(--dry-run:未启动进程 · classpath {} 项 · 编码 {})", + dry.classpath_entries, dry.encoding + )); + if let Some(natives) = &dry.natives { + lines.push(format!("natives:{}", natives.summary())); + } + let mut warnings = dry.warnings.clone(); + warnings.extend(java.warnings()); + // 人读行里 Java 告警已经在上面打过一次,这里只补 `dry_run` 自己新发现的。 + for warning in &dry.warnings { + lines.push(format!("告警: {warning}")); + } + human.extend(lines); + let natives = dry.natives.as_ref().map(|natives| { + json!({ + "dir": natives.dir.display().to_string(), + "files": natives.files, + "skipped": natives.skipped, + "ignored": natives.ignored, + "jars": natives.jars, + "custom": natives.custom, + "up_to_date": natives.up_to_date, + "era": natives.era.as_str(), + }) + }); + let data = json!({ + "dry_run": true, + "instance_found": instance_found, + "version_id": plan.version_id, + "isolation": isolation.isolation.as_str(), + "isolation_source": isolation.source.as_str(), + "game_dir": isolation.game_dir.display().to_string(), + "mode_change_warning": isolation.warning, + "account": account_json(&identity), + "java": java.to_json(), + "program": dry.program.display().to_string(), + "argv": dry.argv, + "display": dry.display, + "classpath_entries": dry.classpath_entries, + "encoding": dry.encoding, + "warnings": warnings, + "natives": natives, + "adapter_warnings": plan.warnings, + }); + return Ok(Report::new(data, human)); + } + + let handle = launcher + .launch(&plan.request) + .map_err(|error| map_launch_error(&error, ctx))?; + let data = json!({ + "dry_run": false, + "instance_found": instance_found, + "isolation": isolation.isolation.as_str(), + "isolation_source": isolation.source.as_str(), + "game_dir": isolation.game_dir.display().to_string(), + "mode_change_warning": isolation.warning, + "account": account_json(&identity), + "java": java.to_json(), + "warnings": java.warnings(), + "session_id": handle.session_id, + "log_path": handle.log_path.display().to_string(), + "exit_code": handle.exit.code, + "exit_type": handle.exit.exit_type.as_str(), + "uptime_ms": handle.uptime_ms, + "summary": handle.exit.summary(), + "artifacts": handle.artifacts.iter().map(|artifact| artifact.path.display().to_string()).collect::>(), + "adapter_warnings": plan.warnings, + }); + human.push(format!( + "会话 {} 结束:{}", + handle.session_id, + handle.exit.summary() + )); + human.push(format!("事件流: {}", handle.log_path.display())); + Ok(Report::new(data, human)) +} + +/// 隔离派生的结果(运行目录 + 生效档位 + 提示)。 +struct IsolationPlan { + /// 生效运行目录(喂 `--gameDir`)。 + game_dir: PathBuf, + /// 生效档位。 + isolation: Isolation, + /// 档位来自哪一层(`temporary` / `instance` / `global`)。 + source: unlucky_core::game::IsolationSource, + /// 切换检测提示(两侧都有内容时;`None` = 不必提示)。 + warning: Option, +} + +/// 按覆盖链派生运行目录,并在启动时做一次切换检测。 +/// +/// 覆盖链:临时参数(`ull launch` 暂无该 flag,留 `None`) > 实例 `isolation` > 全局 `instances.mode`。 +/// 切换检测只探测 `mods/saves/config` 的条目数并写进实例文件的 `lastModeChange`, +/// **不迁移任何数据**(D20 定案);提示只展示一次(`warned` 落盘)。 +/// +/// 遗留实例(`profiles.json` 有档案、`config/instances/.json` 还没有——M27 之前的形态) +/// 会在**首次真启动**时补建基线文件:否则这批「最可能撞上存档消失」的用户永远拿不到提示。 +/// `--dry-run` 仍然一个字节都不写。 +fn resolve_isolation( + ctx: &Ctx, + profile: Option<&unlucky_core::game::InstanceProfile>, + input: &PlanInput, + dry_run: bool, +) -> Result { + let global_mode = global_mode(ctx); + let mut config = match profile { + Some(profile) => { + read_instance_config(&ctx.layout(), profile.id.as_str()).map_err(|error| { + CliError::user( + "instance_config", + format!("读实例设置 {} 失败:{error}", profile.id.as_str()), + ) + })? + } + None => None, + }; + // 补建基线:有档案没有设置文件 → 先按最小配置在内存里建一份(落盘看下面的 dirty)。 + let bootstrap = config.is_none() && profile.is_some(); + if bootstrap { + config = Some(InstanceConfig::new( + input.instance_id.clone(), + input.version_id.clone(), + )); + } + // 读兼容:M27 之前的档案只在 profiles.json 里存 `game_dir`;这种「显式指定目录」的历史语义 + // 等价于 `custom`,不要静默退回全局共享。补建基线时把这条映射**写进文件**, + // 让实例文件成为唯一真相(profiles.json 的 `game_dir` 是遗留字段)。 + if let Some(config) = config.as_mut() { + if config.isolation.is_none() { + if let Some(dir) = profile + .and_then(|profile| profile.game_dir.clone()) + .filter(|dir| !dir.as_os_str().is_empty()) + { + config.isolation = Some(Isolation::Custom); + config.game_dir = Some(dir); + } + } + } + + let derivation = derive_game_dir( + &ctx.game(), + &input.version_id, + global_mode, + config.as_ref(), + None, + ) + .map_err(|error| CliError::user("isolation", error.to_string()))?; + + // 切换检测:生效档位与记录里的 `to` 不同 → 记一次(探测双侧,只记事实)。 + let at = format_rfc3339_millis(now()); + let mut dirty = false; + let warning = match config.as_mut() { + Some(config) => { + let changed = observe_mode(config, &ctx.game(), &at, derivation.isolation) + .map_err(|error| CliError::user("isolation_probe", error.to_string()))?; + let warning = config.take_mode_change_warning(); + dirty = bootstrap || changed || warning.is_some(); + warning.map(|warning| warning.message()) + } + None => None, + }; + // `--dry-run` 不落盘(否则「只看一眼」也会吃掉一次性提示、改写实例状态)。 + if !dry_run && dirty { + if let Some(config) = config.as_ref() { + write_instance_config(&ctx.layout(), config).map_err(|error| { + CliError::user("instance_config", format!("写实例设置失败:{error}")) + })?; + } + } + + Ok(IsolationPlan { + game_dir: derivation.game_dir, + isolation: derivation.isolation, + source: derivation.source, + warning, + }) +} + +/// 全局默认隔离模式(`launcher.json` 的 `instances.mode`,缺键/坏值一律按 D20 默认 `isolated`)。 +fn global_mode(ctx: &Ctx) -> IsolationMode { + let store = SettingStore::load(ctx.launcher_config(), launcher_schema()); + store + .doc() + .effective_str("instances.mode") + .and_then(IsolationMode::parse) + .unwrap_or_default() +} + +fn map_launch_error(error: &LaunchError, ctx: &Ctx) -> CliError { + let hint = format!("版本本体目录:{}", ctx.game().versions_dir().display()); + CliError::user("launch", error.to_string()).with_hint(hint) +} + +fn instance_hint(profiles: &ProfileList) -> String { + if profiles.profiles.is_empty() { + "用 `ull instances list` 查看实例,或 `ull install ` 登记一个".to_string() + } else { + let names: Vec<&str> = profiles + .profiles + .iter() + .map(|profile| profile.name.as_str()) + .collect(); + format!("可用实例:{}", names.join("、")) + } +} + +/// 人读一行:本次用谁的身份启动。 +fn identity_line(identity: &Identity) -> String { + match &identity.account { + Some(meta) => format!( + "账户 {}({})· UUID {}", + meta.display_name(), + meta.kind.label(), + identity.placeholders.uuid + ), + None => format!( + "账户 离线占位 {}(未登记账户)", + identity.placeholders.player_name + ), + } +} + +/// `--json` 里的身份块(**只有可公开字段**,token 永远不出现)。 +fn account_json(identity: &Identity) -> serde_json::Value { + match &identity.account { + Some(meta) => json!({ + "name": meta.display_name(), + "kind": meta.kind.as_str(), + "profile_id": identity.placeholders.uuid, + "account_id": meta.id.as_str(), + "registered": true, + }), + None => json!({ + "name": identity.placeholders.player_name, + "kind": "offline-placeholder", + "profile_id": identity.placeholders.uuid, + "registered": false, + }), + } +} diff --git a/crates/cli/src/commands/meta.rs b/crates/cli/src/commands/meta.rs new file mode 100644 index 0000000..d49425c --- /dev/null +++ b/crates/cli/src/commands/meta.rs @@ -0,0 +1,57 @@ +//! 元命令:`--version` / 无子命令 / `help`。 + +use serde_json::json; + +use crate::cli::help; +use crate::os; +use crate::output::Report; + +/// 启动器名(`${launcher_name}` 与 `--version` 输出共用)。 +pub const LAUNCHER_NAME: &str = "UnluckyLauncher"; + +/// 版本 + 系统摘要(`ull --version`,D19 §5-2 要求附带 os 摘要)。 +/// +/// 探测走 `core::platform::os_info::current()`(进程内缓存),未达基线时附带升级指引。 +#[must_use] +pub fn version() -> Report { + let info = os::detect(); + let data = json!({ + "name": "ull", + "package": env!("CARGO_PKG_NAME"), + "version": env!("CARGO_PKG_VERSION"), + "os": os::to_json(&info), + }); + let mut human = vec![ + format!( + "ull {}({})", + env!("CARGO_PKG_VERSION"), + env!("CARGO_PKG_NAME") + ), + format!("os: {}", os::summary_line(&info)), + ]; + human.extend(os::notes(&info)); + Report::new(data, human) +} + +/// 无子命令:版本摘要 + 帮助(用户原话「无子命令时 --version + --help」)。 +#[must_use] +pub fn welcome() -> Report { + let version_report = version(); + let mut human = version_report.human; + human.push(String::new()); + human.extend(help::general().lines().map(str::to_string)); + let mut data = version_report.data; + data["help"] = json!(help::general()); + Report::new(data, human) +} + +/// 帮助(`ull help <命令>` 给该命令,未知主题退回总帮助)。 +#[must_use] +pub fn help(topic: Option<&str>) -> Report { + let text = topic.and_then(help::topic).unwrap_or_else(help::general); + let data = json!({ + "topic": topic, + "text": text, + }); + Report::new(data, text.lines().map(str::to_string).collect()) +} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs new file mode 100644 index 0000000..e6e385e --- /dev/null +++ b/crates/cli/src/commands/mod.rs @@ -0,0 +1,155 @@ +//! 命令分发:命令模型 → 具体实现,并统一挂上环境提示。 + +pub mod accounts; +pub mod agent; +pub mod config; +pub mod identity; +pub mod install; +pub mod instances; +pub mod java; +pub mod java_launch; +pub mod java_vendors; +pub mod launch; +pub mod meta; +pub mod sessions; +pub mod snapshot; + +use crate::cli::{Command, Globals, Invocation}; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::{Outcome, Report}; + +/// 执行一条已解析的命令。 +/// +/// # Errors +/// +/// 用法/数据根/目标不存在等用户错误,或序列化失配等内部错误。 +pub fn run(invocation: &Invocation) -> Result { + match &invocation.command { + // 这三条不需要数据根(`--version` 在不达基线的机器上也要能跑,用于诊断)。 + Command::Welcome => Ok(Outcome::new(meta::welcome())), + Command::Version => Ok(Outcome::new(meta::version())), + Command::Help(topic) => Ok(Outcome::new(meta::help(topic.as_deref()))), + Command::InstancesList => with_ctx(&invocation.globals, instances::list), + Command::Install(args) => with_ctx(&invocation.globals, |ctx| install::run(ctx, args)), + Command::Launch(args) => with_ctx(&invocation.globals, |ctx| launch::run(ctx, args)), + Command::SessionsList { limit } => { + with_ctx(&invocation.globals, |ctx| sessions::list(ctx, *limit)) + } + Command::Logs(args) => with_ctx(&invocation.globals, |ctx| sessions::logs(ctx, args)), + Command::Diag(args) => with_ctx(&invocation.globals, |ctx| sessions::diag(ctx, args)), + Command::ConfigGet(args) => with_ctx(&invocation.globals, |ctx| { + config::get(ctx, args.key.as_deref(), args.show_secret) + }), + Command::ConfigSet(args) => with_ctx(&invocation.globals, |ctx| { + config::set(ctx, &args.key, &args.value) + }), + Command::JavaList => with_ctx(&invocation.globals, java::list), + Command::JavaInstall { major, vendor } => with_ctx(&invocation.globals, |ctx| { + java::install(ctx, *major, *vendor) + }), + Command::JavaVendors => with_ctx(&invocation.globals, java::vendors), + Command::AccountsList => with_ctx(&invocation.globals, accounts::list), + Command::AccountsAdd(args) => with_ctx(&invocation.globals, |ctx| accounts::add(ctx, args)), + Command::AccountsRemove { name } => { + with_ctx(&invocation.globals, |ctx| accounts::remove(ctx, name)) + } + // `AgentServe` 由 `runner::run` 提前分流(stdio 形态下 stdout 就是协议流, + // 不能走「先返回 Report 再打印」的路径);这里只兜住「有人直接调 commands::run」。 + Command::AgentServe(_) => Err(CliError::usage( + "agent serve 必须在进程入口直接运行(runner 会提前分流)", + )), + Command::AgentConfig { port } => { + with_ctx(&invocation.globals, |ctx| agent::config_report(ctx, *port)) + } + } +} + +/// `core::auth` 的错误 → CLI 错误(保留稳定机读码;文案已是人话,这里只补「下一步」)。 +/// +/// 集中一处的原因:账户相关命令(`accounts *`)与 `launch` 的身份解析要为**同一批** +/// 错误给出一致的码与提示——两处各写一份必然会漂。 +pub(crate) fn map_auth_error(error: unlucky_core::auth::AuthError) -> CliError { + use unlucky_core::auth::body; + use unlucky_core::auth::AuthError; + let hint = match &error { + AuthError::Canceled => Some("登录被取消,可以重跑一次".to_string()), + AuthError::Timeout { .. } => { + Some("重新执行 `ull accounts add msa` 拿新的用户码".to_string()) + } + AuthError::CredentialExpired => Some("重新登录:ull accounts add msa".to_string()), + AuthError::Unsupported(_) => { + Some("微软登录需要设置 ULL_MSA_CLIENT_ID(Azure 公共客户端 id)".to_string()) + } + AuthError::Conflict { .. } => Some("用 `ull accounts list` 查看已有账户".to_string()), + AuthError::NotFound { .. } => Some("用 `ull accounts list` 查看账户名".to_string()), + AuthError::Denied { code, .. } if code == "no_profile" || code == "no_license" => { + Some("去 minecraft.net 检查该账号的 Java 版拥有权与档案".to_string()) + } + AuthError::Xbox { .. } => { + Some("去 xbox.com / 家庭组设置里解决该账号的 Xbox 限制".to_string()) + } + // M38 的两类分流落到 hint 行:拦截页(HTML/纯文本/空体)先查网络, + // JSON 拒绝按步骤查许可与区域。`code()` 仍是 "denied"(M31 兼容), + // 细分结论由 `detail_code()` 带在 hint 里,排障脚本照样能机读分流。 + AuthError::Response(detail) if detail.is_interception() => Some(format!( + "检查网络或代理(关掉/换掉代理后再试,必要时用镜像方案);诊断码 {}", + detail.detail_code() + )), + AuthError::Response(detail) => Some(format!( + "{};诊断码 {}", + body::advice(&detail.step, detail.kind), + detail.detail_code() + )), + _ => None, + }; + let cli = CliError::user(error.code(), error.message()); + match hint { + Some(hint) => cli.with_hint(hint), + None => cli, + } +} + +/// 需要数据根的命令:解析上下文 → D19 支持级别闸门 → 执行 → 挂环境提示。 +fn with_ctx(globals: &Globals, action: F) -> Result +where + F: FnOnce(&Ctx) -> Result, +{ + let ctx = Ctx::resolve(globals)?; + ctx.ensure_supported()?; + let report = action(&ctx)?; + Ok(Outcome::with_warnings(report, ctx.notes())) +} + +#[cfg(test)] +mod tests { + use super::map_auth_error; + use unlucky_core::auth::{AuthError, BodyKind, ResponseDetail}; + + fn response(step: &str, kind: BodyKind) -> AuthError { + AuthError::Response(Box::new(ResponseDetail { + step: step.to_string(), + what: "Minecraft 登录被拒".to_string(), + status: 403, + content_type: None, + kind, + server_message: None, + digest: String::new(), + })) + } + + #[test] + fn response_hints_split_network_from_license_and_carry_detail_code() { + let blocked = map_auth_error(response("login_with_xbox", BodyKind::Html)); + assert_eq!(blocked.code(), "denied", "code() 仍是 M31 的 denied"); + let hint = blocked.hint().expect("拦截页要有 hint").to_string(); + assert!(hint.contains("代理"), "{hint}"); + assert!(hint.contains("login_with_xbox_non_json"), "{hint}"); + + let denied = map_auth_error(response("login_with_xbox", BodyKind::Json)); + let hint = denied.hint().expect("JSON 拒绝要有 hint").to_string(); + assert!(hint.contains("许可"), "{hint}"); + assert!(!hint.contains("代理"), "{hint}"); + assert!(hint.contains("login_with_xbox_403"), "{hint}"); + } +} diff --git a/crates/cli/src/commands/sessions.rs b/crates/cli/src/commands/sessions.rs new file mode 100644 index 0000000..a125914 --- /dev/null +++ b/crates/cli/src/commands/sessions.rs @@ -0,0 +1,292 @@ +//! `sessions list` / `logs` / `diag`:读 M18 的会话索引与 JSONL 事件流。 +//! +//! 数据源形状见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§5: +//! - `/game/index.jsonl`:一会话一行摘要(同 id 取最后一行,倒序); +//! - `/game/.jsonl`:事件流(`kind` + `payload`)。 +//! +//! `diag` 把「退出信号 + stdout/stderr 文本」喂给 M15 的内嵌规则集,输出结构化结论。 + +use std::path::Path; + +use serde_json::{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::cli::{DiagArgs, LogsArgs}; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +/// `ull sessions list [--limit N]`。 +/// +/// # Errors +/// +/// 索引文件读不了时返回用户错误(不存在 = 空列表)。 +pub fn list(ctx: &Ctx, limit: usize) -> Result { + let index = ctx.session_index(); + let entries = session_index::read_all(&index) + .map_err(|error| CliError::io("读会话索引", &index, &error))?; + let shown: Vec<&SessionIndexEntry> = entries.iter().take(limit).collect(); + + let rows: Vec = shown + .iter() + .map(|entry| { + json!({ + "session_id": entry.session_id, + "instance_id": entry.instance_id, + "instance_name": entry.instance_name, + "game_version": entry.game_version, + "started_at": entry.started_at, + "ended_at": entry.ended_at, + "duration_ms": entry.duration_ms, + "exit_code": entry.exit_code, + "exit_type": entry.exit_type, + "silent_failure": entry.silent_failure, + "window_detected": entry.window_detected, + "crash_found": entry.crash_found, + "diagnosis_top": entry.diagnosis_top, + }) + }) + .collect(); + + let mut human = vec![format!( + "会话 {} 条(索引 {})", + shown.len(), + index.display() + )]; + if shown.is_empty() { + human.push(" (空)先用 `ull launch <实例>` 跑一局".to_string()); + } + for entry in &shown { + human.push(format!( + " {} {} {} {} {}", + entry.session_id, + entry.instance_name, + entry.game_version, + entry.started_at, + exit_label(entry), + )); + } + + Ok(Report::new(json!({ "sessions": rows }), human)) +} + +/// `ull logs [--tail N]`:打印事件流末尾 N 条。 +/// +/// # Errors +/// +/// 会话不存在/前缀有歧义、事件流读不了时返回用户错误。 +pub fn logs(ctx: &Ctx, args: &LogsArgs) -> Result { + let entry = find_entry(ctx, &args.session_id)?; + let path = event_path(ctx, &entry); + let events = read_events(&path)?; + let start = events.len().saturating_sub(args.tail.max(1)); + let shown = &events[start..]; + + // 脱敏一次、两条输出共用:`--json` 是机器消费路径,不该比人类路径拿到更多原文。 + let shown: Vec = shown.iter().map(redact_value).collect(); + let mut human = vec![format!( + "会话 {} · {} · 事件 {} 条(显示末尾 {})", + entry.session_id, + entry.instance_name, + events.len(), + shown.len() + )]; + human.extend(shown.iter().map(render_event)); + + Ok(Report::new( + json!({ + "session_id": entry.session_id, + "path": path.display().to_string(), + "total": events.len(), + "events": shown, + }), + human, + )) +} + +/// `ull diag [--tail N]`:规则引擎诊断。 +/// +/// # Errors +/// +/// 会话不存在、事件流读不了、结论序列化失败时返回错误。 +pub fn diag(ctx: &Ctx, args: &DiagArgs) -> Result { + let entry = find_entry(ctx, &args.session_id)?; + let path = event_path(ctx, &entry); + let events = read_events(&path)?; + + let mut stdout_lines: Vec<&str> = Vec::new(); + let mut stderr_lines: Vec<&str> = Vec::new(); + let mut error_lines: u32 = 0; + for event in &events { + let Some(line) = event.pointer("/payload/line").and_then(Value::as_str) else { + continue; + }; + match event.get("kind").and_then(Value::as_str) { + Some("stdout") => stdout_lines.push(line), + Some("stderr") => { + stderr_lines.push(line); + if event.pointer("/payload/level").and_then(Value::as_str) == Some("error") { + error_lines += 1; + } + } + _ => {} + } + } + let stdout_text = tail_join(&stdout_lines, args.tail); + let stderr_text = tail_join(&stderr_lines, args.tail); + + let exit = ExitSignal::classify( + entry.exit_code, + Some(entry.duration_ms), + error_lines, + &stderr_text, + ); + let mut input = DiagnosisInput::new().with_exit(exit); + if !stdout_text.is_empty() { + input.push_text(SignalOrigin::Stdout, stdout_text); + } + if !stderr_text.is_empty() { + input.push_text(SignalOrigin::Stderr, stderr_text); + } + + let loader = RuleLoader::embedded(); + let diagnoser = Diagnoser::new(loader.rule_set().rules().to_vec()); + let report = diagnoser.analyze(&input); + let record = serde_json::to_value(report.to_record()) + .map_err(|error| CliError::internal("diagnosis_serialize", error.to_string()))?; + + let mut human = vec![format!( + "会话 {} · {} · {} · {}", + entry.session_id, + entry.instance_name, + entry.game_version, + exit_label(&entry) + )]; + match report.primary() { + Some(finding) => { + human.push(format!("主因: {}", finding.message)); + if let Some(detail) = &finding.detail { + human.push(format!("说明: {detail}")); + } + for evidence in finding.evidence.iter().take(3) { + human.push(format!("证据: {}", evidence.render())); + } + } + None => human.push("规则引擎没有得出确定结论(needs_more_info)".to_string()), + } + human.push(format!( + "规则评估 {} 条 · 结论 {} 条 · 次生 {} 条", + report.rules_evaluated, + report.findings.len(), + report.secondary.len() + )); + let human: Vec = human + .iter() + .map(|line| unlucky_agent::redact::redact_text(line)) + .collect(); + + let payload = redact_value(&json!({ + "session_id": entry.session_id, + "instance_id": entry.instance_id, + "game_version": entry.game_version, + "exit": { + "code": entry.exit_code, + "exit_type": entry.exit_type, + "silent_failure": entry.silent_failure, + "duration_ms": entry.duration_ms, + }, + "diagnosis": record, + })); + Ok(Report::new(payload, human)) +} + +fn find_entry(ctx: &Ctx, key: &str) -> Result { + let index = ctx.session_index(); + let entries = session_index::read_all(&index) + .map_err(|error| CliError::io("读会话索引", &index, &error))?; + if let Some(entry) = entries.iter().find(|entry| entry.session_id == key) { + return Ok(entry.clone()); + } + let matches: Vec<&SessionIndexEntry> = entries + .iter() + .filter(|entry| entry.session_id.starts_with(key)) + .collect(); + match matches.as_slice() { + [only] => Ok((*only).clone()), + [] => Err( + CliError::user("session_not_found", format!("找不到会话 `{key}`")) + .with_hint(format!("索引文件:{}", index.display())), + ), + _ => Err(CliError::user( + "session_ambiguous", + format!("`{key}` 匹配到 {} 个会话,请给更长的前缀", matches.len()), + )), + } +} + +fn event_path(ctx: &Ctx, entry: &SessionIndexEntry) -> std::path::PathBuf { + ctx.sessions_root().join("game").join(&entry.log_path) +} + +fn read_events(path: &Path) -> Result, CliError> { + let text = std::fs::read_to_string(path) + .map_err(|error| CliError::io("读会话事件流", path, &error))?; + Ok(text + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect()) +} + +fn tail_join(lines: &[&str], tail: usize) -> String { + let start = lines.len().saturating_sub(tail.max(1)); + let mut text = lines[start..].join("\n"); + if !text.is_empty() { + text.push('\n'); + } + text +} + +fn render_event(event: &Value) -> String { + let ts = event.get("ts").and_then(Value::as_str).unwrap_or(""); + let kind = event.get("kind").and_then(Value::as_str).unwrap_or("?"); + let detail = event + .pointer("/payload/line") + .and_then(Value::as_str) + .or_else(|| event.pointer("/payload/summary").and_then(Value::as_str)) + .unwrap_or_default(); + format!("[{ts}] {kind} {detail}") +} + +/// 递归脱敏任意 JSON(所有字符串过 `redact_text`:主目录用户名段 + Bearer 令牌)。 +fn redact_value(value: &Value) -> Value { + match value { + Value::String(text) => Value::String(unlucky_agent::redact::redact_text(text)), + Value::Array(items) => Value::Array(items.iter().map(redact_value).collect()), + Value::Object(map) => Value::Object( + map.iter() + .map(|(key, child)| (key.clone(), redact_value(child))) + .collect(), + ), + other => other.clone(), + } +} + +fn exit_label(entry: &SessionIndexEntry) -> String { + let code = entry + .exit_code + .map_or_else(|| "无退出码".to_string(), |code| format!("退出码 {code}")); + let exit_type = entry + .exit_type + .clone() + .unwrap_or_else(|| "unknown".to_string()); + let silent = if entry.silent_failure { + " · 静默失败" + } else { + "" + }; + format!("{code}/{exit_type}{silent}") +} diff --git a/crates/cli/src/commands/snapshot.rs b/crates/cli/src/commands/snapshot.rs new file mode 100644 index 0000000..b37fd9a --- /dev/null +++ b/crates/cli/src/commands/snapshot.rs @@ -0,0 +1,205 @@ +//! 实例 → [`LaunchRequest`] 的装配:launch 命令与 `--dry-run` 共用这一段。 +//! +//! 全部走 core 的既有公共面(`game::resolve` / `arguments.expand` / `GameLayout` / +//! `launch::adapter::build_request`),CLI 不复制任何继承/参数展开逻辑。 + +use std::path::PathBuf; + +use crate::context::Ctx; +use crate::error::CliError; +use unlucky_core::game::arguments::Environment; +use unlucky_core::game::{self, ProfileId, VersionJson}; +use unlucky_core::launch::adapter::{ + self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput, +}; +use unlucky_core::launch::request::{AuthPlaceholders, LaunchRequest, MemoryOptions}; + +/// 装配输入(来自命令行 + 实例档案)。 +#[derive(Debug, Clone, Default)] +pub struct PlanInput { + /// 版本 id(实例绑定的 `versions//`)。 + pub version_id: String, + /// 实例 id(写进会话索引;无档案时为空)。 + pub instance_id: String, + /// 实例显示名。 + pub instance_name: String, + /// 已派生的运行目录(D20 隔离覆盖链在 `super::launch` 求值完,这里只消费)。 + pub game_dir: Option, + /// Java 可执行文件覆盖。 + pub java: Option, + /// 离线账户名(无账户时生效;有账户时身份由 `auth` 决定)。 + pub username: Option, + /// 已解析好的账户占位(`None` = 退回 `username` 的离线占位)。 + pub auth: Option, + /// 最大堆 MB。 + pub max_memory: Option, +} + +/// 装配结果。 +#[derive(Debug, Clone)] +pub struct Plan { + /// 实际使用的版本 id。 + pub version_id: String, + /// 装配好的启动请求。 + pub request: LaunchRequest, + /// 非致命告警(缺主类/缺资源索引等)。 + pub warnings: Vec, +} + +/// 装配一个 [`LaunchRequest`]。 +/// +/// # Errors +/// +/// 版本 JSON 缺失/非法、继承链断裂(缺父版本/成环)时返回用户错误。 +pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result { + let game = ctx.game(); + let version_id = input.version_id.clone(); + let manifest_path = game.version_json(&version_id); + let manifest = read_version_json(&manifest_path)?; + + let source = |id: &str| -> Option { + let path = game.version_json(id); + read_version_json(&path).ok() + }; + let resolved = game::resolve(&source, &manifest).map_err(|error| { + CliError::user("inherit", format!("解析继承链失败:{error}")) + .with_hint(format!("检查 {}", manifest_path.display())) + })?; + + let environment = Environment::host(); + let expanded = resolved + .arguments + .as_ref() + .map(|arguments| arguments.expand(&environment)) + .unwrap_or_default(); + + let mut libraries: Vec = 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() { + game.version_library(&version_id, &coordinate) + } else { + game.library(&coordinate) + }; + libraries.push(LibraryInput::new(path, library.name.clone())); + } + + // natives 分类器构件(≤1.18.2 才有):收集口径归 core,CLI 只搬运。 + let natives = unlucky_core::launch::natives::collect( + &game, + &version_id, + resolved.libraries.as_deref().unwrap_or_default(), + &environment, + ); + + let jar_id = resolved.jar.clone().unwrap_or_else(|| version_id.clone()); + let primary_jar = game.version_jar(&jar_id); + // 运行目录:调用方(`ull launch`)已按隔离覆盖链派生;`None` 兜底到游戏根 + // (库/其它消费者直接构造 `PlanInput` 时保持旧行为)。 + let game_dir = input + .game_dir + .clone() + .unwrap_or_else(|| ctx.data.game_home.clone()); + let assets_root = 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(&input.instance_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, + game_dir, + assets_index: assets_index.as_deref(), + natives_dir: game.natives_dir(&version_id), + library_dir: game.libraries_dir(), + assets_root: assets_root.clone(), + game_assets: assets_root, + }; + + let options = AdapterOptions { + java: super::java::selection(input.java.as_deref()), + memory: MemoryOptions { + min_mb: None, + max_mb: input.max_memory, + }, + auth: input.auth.clone().unwrap_or_else(|| AuthPlaceholders { + player_name: input + .username + .clone() + .unwrap_or_else(|| "Player".to_string()), + uuid: ProfileId::generate().as_str().replace('-', ""), + user_type: "legacy".to_string(), + user_properties: "{}".to_string(), + ..AuthPlaceholders::default() + }), + launcher_name: super::meta::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 warnings = output.warnings.iter().map(describe_warning).collect(); + let mut request = output.request; + // 会话索引要用实例身份(adapter 默认拿版本 id/名)。 + if !input.instance_id.is_empty() { + request.instance.id = input.instance_id.clone(); + } + request.instance.name = input.instance_name.clone(); + + Ok(Plan { + version_id, + request, + warnings, + }) +} + +fn read_version_json(path: &std::path::Path) -> Result { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(CliError::user( + "version_missing", + format!("版本 JSON 不存在:{}", path.display()), + ) + .with_hint("先准备好版本本体文件(下载装配属 P1 后续 mission)")); + } + Err(error) => return Err(CliError::io("读版本 JSON", path, &error)), + }; + VersionJson::from_json_str(&text) + .map_err(|error| CliError::user("version_parse", format!("{}:{error}", path.display()))) +} + +/// 适配层告警 → 人读一行(`AdapterWarning` 没有 Display,这里集中翻译)。 +fn describe_warning(warning: &AdapterWarning) -> String { + match warning { + AdapterWarning::MissingLibrary { name, path } => { + format!("缺少库文件 {name}({})", path.display()) + } + AdapterWarning::MissingGameArguments => { + "版本 JSON 既没有 arguments.game 也没有 minecraftArguments".to_string() + } + AdapterWarning::MissingAssetsIndex => { + "版本 JSON 没有资源索引 id(${assets_index_name} 为空)".to_string() + } + AdapterWarning::MissingMainClass => "版本 JSON 没有 mainClass".to_string(), + AdapterWarning::MissingNativeJar { name, path } => { + format!("缺少 natives 构件 {name}({})", path.display()) + } + } +} diff --git a/crates/cli/src/context.rs b/crates/cli/src/context.rs new file mode 100644 index 0000000..1ae4b90 --- /dev/null +++ b/crates/cli/src/context.rs @@ -0,0 +1,238 @@ +//! 数据根解析与目录视图。 +//! +//! 三模式(D6 / `04-data-and-config.md` §1)全部交给 `core::platform::data_dir`: +//! - `--data-dir ` → custom 模式(`/.ULL` + `/.minecraft`); +//! - 否则先按便携目录的 `launcher.json` 读 `core.data_mode` / `core.data_custom_root` / +//! `core.launcher_home` / `core.game_home`,再交 [`Resolver`] 解析(含 `redirect.json` 跟随)。 +//! +//! CLI 不自己拼路径:目录形状的真相在 `core::platform::layout`。 +//! +//! `ull config get/set` 的落点也从这里出:[`Ctx::setting_path_for`] 让决定数据根的 +//! `core.*` 键落在**便携目录那份** `launcher.json`(与解析同源),其余键落解析后的数据根。 + +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, CONFIG_DIR, LAUNCHER_CONFIG_FILE, LAUNCHER_HOME_DIR, +}; +use unlucky_core::platform::paths::Platform; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +use crate::cli::Globals; +use crate::error::CliError; +use crate::os; + +/// 决定数据根本身的配置键(`request_from_portable_config` 读的就是它们)。 +/// +/// `ull config get/set` 对这几个键必须读写**同一份文件**(便携目录那份),否则会出现 +/// 「`config get core.data_mode` 说 portable、实际按 standard 跑」这种两套真相 +/// (finding 20261030-worker-java2-improve-ull-config-get-set-launcher-json)。 +pub const DATA_ROOT_KEYS: [&str; 4] = [ + "core.data_mode", + "core.data_custom_root", + "core.launcher_home", + "core.game_home", +]; + +/// 该键是否决定数据根。 +#[must_use] +pub fn is_data_root_key(key: &str) -> bool { + DATA_ROOT_KEYS.contains(&key) +} + +/// 便携目录那份 `launcher.json` 的路径(`/.ULL/config/launcher.json`)。 +#[must_use] +pub fn portable_config_path(exe_dir: &Path) -> PathBuf { + exe_dir + .join(LAUNCHER_HOME_DIR) + .join(CONFIG_DIR) + .join(LAUNCHER_CONFIG_FILE) +} + +/// 一次命令执行的上下文:数据根 + 平台 + 系统摘要。 +#[derive(Debug, Clone)] +pub struct Ctx { + /// 解析后的数据目录(launcher home / game home)。 + pub data: ResolvedData, + /// 宿主平台族。 + pub platform: Platform, + /// 系统信息与支持等级(D19 探测 / D21 Win10+ 单基线)。 + pub os: unlucky_core::platform::os_info::OsInfo, + /// exe 目录(便携目录那份 `launcher.json` 的根;拿不到时为 `None`)。 + exe_dir: Option, + /// `--data-dir` 是否由命令行强制指定(此时数据根不看 `core.*` 键)。 + data_dir_forced: bool, +} + +impl Ctx { + /// 解析数据根。 + /// + /// # Errors + /// + /// `--data-dir` 非绝对路径、exe 目录拿不到、`redirect.json` 损坏等返回用户错误。 + pub fn resolve(globals: &Globals) -> Result { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf)); + Self::resolve_in(globals, exe_dir) + } + + /// 指定 exe 目录的解析入口(单测用它把便携目录钉在临时目录里)。 + /// + /// # Errors + /// + /// 同 [`Self::resolve`]。 + pub fn resolve_in(globals: &Globals, exe_dir: Option) -> Result { + let platform = Platform::current(); + let data_dir_forced = globals.data_dir.is_some(); + let request = match &globals.data_dir { + Some(dir) => { + if !dir.is_absolute() { + return Err(CliError::usage(format!( + "--data-dir 需要绝对路径,收到 `{}`", + dir.display() + ))); + } + DataDirRequest { + mode: DataMode::Custom, + custom_root: Some(dir.clone()), + ..DataDirRequest::default() + } + } + None => request_from_portable_config(exe_dir.clone()), + }; + let resolver = Resolver::new(platform, exe_dir.clone()); + let data = resolver + .resolve_default(&request) + .map_err(|error| CliError::user("data_dir", error.to_string()))?; + Ok(Self { + data, + platform, + os: os::detect(), + exe_dir, + data_dir_forced, + }) + } + + /// 启动器数据布局(`.ULL/` 四分区)。 + #[must_use] + pub fn layout(&self) -> LauncherLayout { + self.data.layout() + } + + /// 游戏目录布局(`.minecraft/`)。 + #[must_use] + pub fn game(&self) -> GameLayout { + GameLayout::new(self.data.game_home.clone()) + } + + /// `launcher.json` 路径。 + #[must_use] + pub fn launcher_config(&self) -> PathBuf { + self.layout().launcher_config() + } + + /// 便携目录那份 `launcher.json`(数据根解析的唯一输入;拿不到 exe 目录时为 `None`)。 + #[must_use] + pub fn portable_launcher_config(&self) -> Option { + self.exe_dir.as_deref().map(portable_config_path) + } + + /// `--data-dir` 是否由命令行强制指定(此时 `core.*` 数据根键本轮不参与解析)。 + #[must_use] + pub const fn data_dir_forced(&self) -> bool { + self.data_dir_forced + } + + /// 某个配置键的读写落点。 + /// + /// 数据根键([`DATA_ROOT_KEYS`])走便携目录那份——和 [`Self::resolve`] 读的是同一份文件, + /// 这样 `config get core.data_mode` 展示的就是真正生效的值,`config set` 也真的会影响 + /// 下次解析;其余键走解析后的数据根(`/.ULL/config/launcher.json`)。 + #[must_use] + pub fn setting_path_for(&self, key: &str) -> PathBuf { + if is_data_root_key(key) { + if let Some(path) = self.portable_launcher_config() { + return path; + } + } + self.launcher_config() + } + + /// `profiles.json` 路径。 + #[must_use] + pub fn profiles_config(&self) -> PathBuf { + self.layout().profiles_config() + } + + /// 会话根(`/agent/sessions`,launch 的事件流与索引都在这下面)。 + #[must_use] + pub fn sessions_root(&self) -> PathBuf { + self.layout().agent_sessions() + } + + /// 账户数据目录(`data/auth/`:`accounts.json` + `credentials.dat`)。 + #[must_use] + pub fn auth_data(&self) -> PathBuf { + self.layout().auth_data() + } + + /// 会话索引文件(`/game/index.jsonl`)。 + #[must_use] + pub fn session_index(&self) -> PathBuf { + unlucky_core::launch::session_index::index_path(&self.sessions_root()) + } + + /// D19/D21:低于基线(Win10+ / macOS 12+ / Linux 达标)时拒绝执行。 + /// + /// # Errors + /// + /// 系统不支持时返回 `unsupported_os` 用户错误(文案与核心自检同一份)。 + pub fn ensure_supported(&self) -> Result<(), CliError> { + let check = os::check(); + if check.is_usable() { + return Ok(()); + } + Err(CliError::user("unsupported_os", check.message) + .with_hint(format!("最低要求:{}", os::BASELINE))) + } + + /// 环境级提示(低于基线时的升级指引;一等基线时为空)。 + #[must_use] + pub fn notes(&self) -> Vec { + os::notes(&self.os) + } +} + +/// 便携目录的 `launcher.json` → 三模式请求。 +/// +/// 只读 `effective`(文件值 → 默认值),坏文件也永不 panic(`SettingStore` 的加载降级)。 +/// 路径由 [`portable_config_path`] 决定,`config get/set` 用的是同一个函数(同源)。 +fn request_from_portable_config(exe_dir: Option) -> DataDirRequest { + let mut request = DataDirRequest::default(); + let Some(exe_dir) = exe_dir else { + return request; + }; + 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("core.data_mode").as_deref().and_then(DataMode::parse) { + request.mode = mode; + } + if let Some(root) = text("core.data_custom_root") { + request.custom_root = Some(PathBuf::from(root)); + } + if let Some(home) = text("core.launcher_home") { + request.launcher_home = Some(PathBuf::from(home)); + } + if let Some(home) = text("core.game_home") { + request.game_home = Some(PathBuf::from(home)); + } + request +} diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs new file mode 100644 index 0000000..ca2f138 --- /dev/null +++ b/crates/cli/src/error.rs @@ -0,0 +1,157 @@ +//! 错误形状与退出码。 +//! +//! 约定(10-cli-design.md §3):**0 成功 / 1 用户错误 / 2 内部错误**。 +//! 用户错误 = 用法错、目标不存在、环境不支持、依赖模块未接线; +//! 内部错误 = 不该发生的事(序列化失配等),脚本见到 2 应该当 bug 报。 + +use std::fmt; + +use serde_json::{json, Value}; + +/// 退出码:成功。 +pub const EXIT_OK: i32 = 0; +/// 退出码:用户错误。 +pub const EXIT_USER: i32 = 1; +/// 退出码:内部错误。 +pub const EXIT_INTERNAL: i32 = 2; + +/// 错误大类(决定退出码)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorKind { + /// 用户侧问题:改参数/改环境可解决。 + User, + /// 内部问题:不该发生,应报 bug。 + Internal, +} + +impl ErrorKind { + /// 稳定名(`--json` 输出用)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::User => "user", + Self::Internal => "internal", + } + } + + /// 对应的进程退出码。 + #[must_use] + pub const fn exit_code(self) -> i32 { + match self { + Self::User => EXIT_USER, + Self::Internal => EXIT_INTERNAL, + } + } +} + +/// 一条 CLI 错误。 +/// +/// `code` 是给脚本的稳定机读标识(`usage` / `not_found` / `unsupported_os` …), +/// `message` 是给人看的中文说明,`hint` 是「下一步该做什么」。 +#[derive(Debug, Clone)] +pub struct CliError { + kind: ErrorKind, + code: &'static str, + message: String, + hint: Option, +} + +impl CliError { + /// 用法错误(参数不对)。 + #[must_use] + pub fn usage(message: impl Into) -> Self { + Self { + kind: ErrorKind::User, + code: "usage", + message: message.into(), + hint: None, + } + } + + /// 用户错误,带稳定 code。 + #[must_use] + pub fn user(code: &'static str, message: impl Into) -> Self { + Self { + kind: ErrorKind::User, + code, + message: message.into(), + hint: None, + } + } + + /// 内部错误,带稳定 code。 + #[must_use] + pub fn internal(code: &'static str, message: impl Into) -> Self { + Self { + kind: ErrorKind::Internal, + code, + message: message.into(), + hint: None, + } + } + + /// IO 失败(读配置/读写会话文件):按用户错误处理,路径进消息。 + #[must_use] + pub fn io(op: &str, path: &std::path::Path, error: &std::io::Error) -> Self { + Self::user("io", format!("{op}失败:{}({error})", path.display())) + } + + /// 补一句提示。 + #[must_use] + pub fn with_hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } + + /// 错误大类。 + #[must_use] + pub const fn kind(&self) -> ErrorKind { + self.kind + } + + /// 稳定错误码。 + #[must_use] + pub const fn code(&self) -> &'static str { + self.code + } + + /// 人类可读说明。 + #[must_use] + pub fn message(&self) -> &str { + &self.message + } + + /// 下一步提示。 + #[must_use] + pub fn hint(&self) -> Option<&str> { + self.hint.as_deref() + } + + /// 进程退出码。 + #[must_use] + pub const fn exit_code(&self) -> i32 { + self.kind.exit_code() + } + + /// `--json` 里的错误对象。 + #[must_use] + pub fn to_json(&self) -> Value { + let mut error = json!({ + "code": self.code, + "kind": self.kind.as_str(), + "message": self.message, + }); + if let Some(hint) = &self.hint { + error["hint"] = Value::String(hint.clone()); + } + json!({ "ok": false, "error": error }) + } +} + +impl fmt::Display for CliError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}", self.message) + } +} + +impl std::error::Error for CliError {} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs new file mode 100644 index 0000000..cdd8dcf --- /dev/null +++ b/crates/cli/src/lib.rs @@ -0,0 +1,27 @@ +//! Unlucky-Launcher 命令行(`unlucky_cli`):`ull` 二进制的可测试内核。 +//! +//! 设计定稿:`DevDocs/PlanDoc/10-cli-design.md`(D18)—— core 是零 UI 纯库, +//! CLI 只是与 Slint GUI 平级的第二个消费者,因此**本 crate 不依赖任何 UI 框架**。 +//! +//! 分层: +//! - [`cli`]:手写参数解析(不引 clap)+ 命令模型 + 帮助文案; +//! - [`commands`]:每个子命令一个函数,输入 [`context::Ctx`],输出 [`output::Report`]; +//! - [`context`]:数据根解析(三模式 / `--data-dir` 覆盖)与目录视图; +//! - [`os`]:系统摘要与 D19/D21 支持级别(事实来自 core::platform::os_info,这里只做呈现); +//! - [`output`]:人类可读 + `--json` 双格式输出; +//! - [`error`]:错误形状与退出码(0 成功 / 1 用户错误 / 2 内部错误)。 +//! +//! 可测试性:入口是 [`run`],参数是 `&[String]`,输出写进两个 `&mut dyn Write`, +//! 返回值就是进程退出码——集成测试因此不需要真的起子进程也能覆盖三条通道。 + +#![forbid(unsafe_code)] + +pub mod cli; +pub mod commands; +pub mod context; +pub mod error; +pub mod os; +pub mod output; +pub mod runner; + +pub use runner::run; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs new file mode 100644 index 0000000..28afe48 --- /dev/null +++ b/crates/cli/src/main.rs @@ -0,0 +1,9 @@ +//! `ull` 二进制入口:把真实 stdio 交给 [`unlucky_cli::run`],退出码照传。 + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let mut stdout = std::io::stdout(); + let mut stderr = std::io::stderr(); + let code = unlucky_cli::run(&args, &mut stdout, &mut stderr); + std::process::exit(code); +} diff --git a/crates/cli/src/os.rs b/crates/cli/src/os.rs new file mode 100644 index 0000000..0957b67 --- /dev/null +++ b/crates/cli/src/os.rs @@ -0,0 +1,78 @@ +//! 系统摘要与启动自检的 **CLI 呈现层**(D19 探测 + D21 Win10+ 单基线)。 +//! +//! 事实采集与等级判定全在 `unlucky_core::platform::os_info`(M20): +//! - [`os_info::detect`] 真探一次(会起子进程,别在热路径反复调用); +//! - [`os_info::current`] 进程内缓存(CLI 每次执行只真探一次); +//! - [`os_info::check`] 启动自检(等级 + 双语文案 + 系统信息)。 +//! +//! 本模块只做 CLI 关心的事:一行摘要、`--json` 对象、拒绝运行时的文案。 +//! 支持级别只有两档(D21 用户拍板「直接 win10 起步」):[`SupportLevel::One`] 与 +//! [`SupportLevel::Unsupported`]——Win8.1/8/7 不承诺 GUI 也不承诺 CLI, +//! 识别出旧版本正是为了明确报「不支持」。 + +use serde_json::{json, Value}; +use unlucky_core::platform::os_info::{self, OsInfo, SupportLevel}; + +/// 取进程内缓存的系统信息(一次进程只真探一次)。 +#[must_use] +pub fn detect() -> OsInfo { + os_info::current().clone() +} + +/// 启动自检(等级 + 双语文案 + 系统信息),供拒绝运行时直接打印。 +#[must_use] +pub fn check() -> os_info::SupportCheck { + os_info::check() +} + +/// 一行摘要(`ull --version` 附带)。 +#[must_use] +pub fn summary_line(info: &OsInfo) -> String { + let version = if info.version_string.is_empty() { + "版本未知" + } else { + info.version_string.as_str() + }; + format!( + "{} · {}({})· {}", + info.product, + version, + info.arch, + support_label(info.support_level) + ) +} + +/// `--json` 里的系统对象。 +#[must_use] +pub fn to_json(info: &OsInfo) -> Value { + json!({ + "family": info.family.manifest_name(), + "product": info.product, + "version": if info.version_string.is_empty() { None } else { Some(&info.version_string) }, + "major_minor": info.major_minor.map(|(major, minor)| format!("{major}.{minor}")), + "build": info.build, + "arch": info.arch, + "session": info.session.as_str(), + "support": info.support_level.as_str(), + "baseline": BASELINE, + }) +} + +/// 支持基线的一句话(`--json` 与帮助文案共用)。 +pub const BASELINE: &str = "Windows 10+ / macOS 12+ / Linux(内核 ≥ 3.2,glibc ≥ 2.17)"; + +/// 环境级提示:仅在低于基线时非空(一等基线不打岔)。 +#[must_use] +pub fn notes(info: &OsInfo) -> Vec { + match info.support_level { + SupportLevel::One => Vec::new(), + SupportLevel::Unsupported => info.describe().zh.lines().map(str::to_string).collect(), + } +} + +fn support_label(level: SupportLevel) -> &'static str { + match level { + SupportLevel::One => "满足最低要求", + SupportLevel::Unsupported => "不支持(低于最低要求)", + } +} diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs new file mode 100644 index 0000000..28aee0f --- /dev/null +++ b/crates/cli/src/output.rs @@ -0,0 +1,136 @@ +//! 双格式输出:人类可读行 + `--json` 机器可读载荷。 +//! +//! 一个命令只产出 [`Report`](`data` 给脚本,`human` 给人);怎么打印由 [`print_report`] +//! 一处决定,命令里不出现 `println!`——这样集成测试能直接断言输出形状, +//! 也能保证 `--json` 覆盖到每个命令(10-cli-design.md §3 全局参数)。 + +use std::io::Write; + +use serde_json::{json, Value}; + +use crate::error::CliError; + +/// 一个命令的结果。 +#[derive(Debug, Clone)] +pub struct Report { + /// `--json` 模式下的 `data` 载荷。 + pub data: Value, + /// 人类可读输出(一行一条)。 + pub human: Vec, +} + +impl Report { + /// 构造。 + #[must_use] + pub fn new(data: Value, human: Vec) -> Self { + Self { data, human } + } +} + +/// 命令结果 + 环境提示(D19/D21 低于基线时的升级指引之类,不混进 `data`)。 +#[derive(Debug, Clone)] +pub struct Outcome { + /// 命令结果。 + pub report: Report, + /// 环境级提示(人类模式进 stderr,JSON 模式进信封的 `warnings`)。 + pub warnings: Vec, +} + +impl Outcome { + /// 无提示的结果。 + #[must_use] + pub fn new(report: Report) -> Self { + Self { + report, + warnings: Vec::new(), + } + } + + /// 带上环境提示。 + #[must_use] + pub fn with_warnings(report: Report, warnings: Vec) -> Self { + Self { report, warnings } + } +} + +/// 打印成功结果。 +/// +/// # Errors +/// +/// 写 stdout/stderr 失败时返回 IO 错误(管道被关掉等)。 +pub fn print_report( + outcome: &Outcome, + json: bool, + quiet: bool, + out: &mut dyn Write, + err: &mut dyn Write, +) -> std::io::Result<()> { + if json { + // 信封固定三个键,脚本按 `ok` 分流后直接吃 `data`。 + let envelope = json!({ + "ok": true, + "data": outcome.report.data, + "warnings": outcome.warnings, + }); + writeln!(out, "{}", serde_json::to_string_pretty(&envelope)?)?; + return Ok(()); + } + if !quiet { + for line in &outcome.report.human { + writeln!(out, "{line}")?; + } + } + for warning in &outcome.warnings { + writeln!(err, "提示: {warning}")?; + } + Ok(()) +} + +/// 打印错误(人类模式进 stderr,JSON 模式进 stdout 的错误信封)。 +/// +/// # Errors +/// +/// 写 stdout/stderr 失败时返回 IO 错误。 +pub fn print_error( + error: &CliError, + json: bool, + out: &mut dyn Write, + err: &mut dyn Write, +) -> std::io::Result<()> { + if json { + writeln!(out, "{}", serde_json::to_string_pretty(&error.to_json())?)?; + return Ok(()); + } + writeln!(err, "错误: {}", error.message())?; + if let Some(hint) = error.hint() { + writeln!(err, "提示: {hint}")?; + } + Ok(()) +} + +/// 长任务的实时进度行(**只进 stderr**,不参与 [`Report`])。 +/// +/// 命令的正经输出仍然全部走 [`Report`](stdout、可断言的形状);下载/安装这类分钟级任务 +/// 需要无缓冲的实时反馈,才单独开这条通道。行内不含 ANSI,`--no-color` 无需处理。 +/// +/// `--json` / `--quiet` 的抑制由**调用方**决定(`InstallArgs::progress` 已按全局选项算好), +/// 这样这条通道不需要知道全局选项长什么样。 +/// +/// 写不出去(管道早闭)就静默放弃:进度永远不该让命令失败。 +pub fn progress_line(text: &str) { + let mut stderr = std::io::stderr(); + let _ = writeln!(stderr, "{text}"); + let _ = stderr.flush(); +} + +#[cfg(test)] +mod tests { + use super::progress_line; + + #[test] + fn progress_line_never_panics() { + // 只验证"写不出去也不抛"(真实 stderr 在测试里通常可写)。 + progress_line("测试进度 1/2"); + progress_line(""); + } +} diff --git a/crates/cli/src/runner.rs b/crates/cli/src/runner.rs new file mode 100644 index 0000000..fe1c5e3 --- /dev/null +++ b/crates/cli/src/runner.rs @@ -0,0 +1,54 @@ +//! 进程入口的可测试形态:解析 → 分发 → 打印 → 退出码。 +//! +//! `main` 只做两件事:把真实 stdio 传进来、把返回码交给进程。 + +use std::io::Write; + +use crate::cli; +use crate::commands; +use crate::error::EXIT_OK; +use crate::output; + +/// 跑一条命令行,返回进程退出码(0 / 1 / 2)。 +/// +/// `args` 不含 argv[0]。`--json` 与 `--quiet` 在解析失败时也要生效, +/// 因此这里先扫一遍原始参数,而不是依赖解析结果。 +pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { + let json = args.iter().any(|arg| arg == "--json"); + let quiet = args.iter().any(|arg| arg == "--quiet" || arg == "-q"); + + let invocation = match cli::parse(args) { + Ok(invocation) => invocation, + Err(error) => { + let _ = output::print_error(&error, json, out, err); + return error.exit_code(); + } + }; + + // `ull agent serve` 提前分流:stdio 形态下 stdout 是 JSON-RPC 流, + // 任何一行额外输出(包括返回后的 Report)都会让客户端解析失败。 + if let cli::Command::AgentServe(serve_args) = &invocation.command { + return match crate::commands::agent::run_serve_with(&invocation.globals, serve_args, err) { + Ok(code) => code, + Err(error) => { + let _ = output::print_error(&error, json, out, err); + error.exit_code() + } + }; + } + + match commands::run(&invocation) { + Ok(outcome) => { + if let Err(write_err) = output::print_report(&outcome, json, quiet, out, err) { + // 输出写不出去(管道早闭)不再折腾,直接按内部错误退出。 + let _ = writeln!(err, "错误: 写输出失败:{write_err}"); + return crate::error::EXIT_INTERNAL; + } + EXIT_OK + } + Err(error) => { + let _ = output::print_error(&error, json, out, err); + error.exit_code() + } + } +} diff --git a/crates/cli/tests/accounts_test.rs b/crates/cli/tests/accounts_test.rs new file mode 100644 index 0000000..023b485 --- /dev/null +++ b/crates/cli/tests/accounts_test.rs @@ -0,0 +1,293 @@ +//! `ull accounts` 与 `ull launch --account` 的端到端断言。 +//! +//! 职责:证明「账户能建/能删/能选」,并且**凭据红线守住了**—— +//! stdout(含 `--json`)、`launch --dry-run` 的 argv 与身份块里都不出现 token 明文。 + +#[path = "support/mod.rs"] +mod support; + +use std::path::{Path, PathBuf}; + +use support::{parse_json, run_cli, TempDir}; +use unlucky_core::auth::offline::offline_uuid; + +const VERSION: &str = "1.20.1"; + +/// 建一个带「版本本体 + 一个实例」的数据根。 +fn data_root(tag: &str) -> TempDir { + let dir = TempDir::new(tag); + support::write_version(dir.path(), VERSION); + support::write_profiles(dir.path(), &[("生存服", VERSION)]); + dir +} + +/// 覆盖版本 JSON:把 `${auth_access_token}` / `${auth_uuid}` 塞进命令行, +/// 这样「脱敏」才是被真的跑到,而不是「压根没有 token 所以没有明文」。 +fn write_version_with_auth(root: &Path) { + let dir = root.join(".minecraft").join("versions").join(VERSION); + std::fs::create_dir_all(&dir).expect("建版本目录"); + let version = serde_json::json!({ + "id": VERSION, + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "assets": "5", + "assetIndex": { "id": "5" }, + "arguments": { + "game": [ + "--username", "${auth_player_name}", + "--uuid", "${auth_uuid}", + "--accessToken", "${auth_access_token}", + "--userType", "${auth_user_type}", + "--version", "${version_name}" + ], + "jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"] + }, + "libraries": [], + "downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } } + }); + std::fs::write( + dir.join(format!("{VERSION}.json")), + serde_json::to_vec_pretty(&version).expect("序列化版本 JSON"), + ) + .expect("写版本 JSON"); +} + +fn auth_dir(root: &Path) -> PathBuf { + root.join(".ULL").join("data").join("auth") +} + +/// 拼一条 `--json --data-dir ...` 命令。 +fn cli(dir: &TempDir, tail: &[&str]) -> Vec { + let mut args = vec!["--json".to_string(), "--data-dir".to_string(), dir.arg()]; + args.extend(tail.iter().map(|item| (*item).to_string())); + args +} + +/// 拼一条 `launch 生存服 --dry-run ...` 命令。 +fn launch(dir: &TempDir, extra: &[&str]) -> Vec { + let mut tail = vec!["launch", "生存服", "--dry-run"]; + tail.extend_from_slice(extra); + cli(dir, &tail) +} + +/// 跑一条命令并断言退出码。 +fn run(args: &[String], expected: i32) -> (String, String) { + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let (code, out, err) = run_cli(&refs); + assert_eq!(code, expected, "args={refs:?}\nstdout={out}\nstderr={err}"); + (out, err) +} + +#[test] +fn accounts_list_is_empty_at_first() { + let dir = data_root("accounts-empty"); + let (out, _) = run(&cli(&dir, &["accounts", "list"]), 0); + let envelope = parse_json(&out); + assert_eq!(envelope["data"]["count"], 0); + assert_eq!( + envelope["data"]["accounts"].as_array().map(Vec::len), + Some(0) + ); + assert!(!auth_dir(dir.path()).join("accounts.json").exists()); +} + +#[test] +fn add_offline_writes_both_files_and_reports_hmcl_uuid() { + let dir = data_root("accounts-add"); + let (out, _) = run(&cli(&dir, &["accounts", "add", "offline", "小明"]), 0); + let envelope = parse_json(&out); + let account = &envelope["data"]["account"]; + assert_eq!(account["name"], "小明"); + assert_eq!(account["kind"], "offline"); + assert_eq!(account["profile_id"], offline_uuid("小明")); + + let accounts_file = auth_dir(dir.path()).join("accounts.json"); + let credentials_file = auth_dir(dir.path()).join("credentials.dat"); + assert!(accounts_file.is_file(), "元数据文件必须落盘"); + assert!( + credentials_file.is_file(), + "凭据文件必须落盘(哪怕离线轨是空的)" + ); + + // list 里能看到,且输出里没有任何凭据字段。 + let (list_out, _) = run(&cli(&dir, &["accounts", "list"]), 0); + let list = parse_json(&list_out); + assert_eq!(list["data"]["count"], 1); + assert_eq!(list["data"]["accounts"][0]["name"], "小明"); + for forbidden in ["access_token", "refresh_token", "client_token", "password"] { + assert!( + !list_out.contains(forbidden), + "accounts list 不该出现 {forbidden}" + ); + } +} + +#[test] +fn duplicate_offline_account_is_rejected() { + let dir = data_root("accounts-dup"); + run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0); + let (out, _) = run(&cli(&dir, &["accounts", "add", "offline", "steve"]), 1); + assert_eq!(parse_json(&out)["error"]["code"], "conflict"); +} + +#[test] +fn add_offline_requires_a_valid_name() { + let dir = data_root("accounts-badname"); + let (out, _) = run(&cli(&dir, &["accounts", "add", "offline"]), 1); + assert_eq!(parse_json(&out)["error"]["code"], "usage"); + + let (out, _) = run( + &cli(&dir, &["accounts", "add", "offline", "0123456789abcdefg"]), + 1, + ); + assert_eq!(parse_json(&out)["error"]["code"], "malformed"); +} + +#[test] +fn remove_deletes_account_and_reports_missing() { + let dir = data_root("accounts-remove"); + run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0); + let (out, _) = run(&cli(&dir, &["accounts", "remove", "Steve"]), 0); + assert_eq!(parse_json(&out)["data"]["removed"]["name"], "Steve"); + + let (out, _) = run(&cli(&dir, &["accounts", "list"]), 0); + assert_eq!(parse_json(&out)["data"]["count"], 0); + + let (out, _) = run(&cli(&dir, &["accounts", "remove", "Steve"]), 1); + assert_eq!(parse_json(&out)["error"]["code"], "not_found"); +} + +#[test] +fn add_msa_without_client_id_is_explained() { + // 没配 ULL_MSA_CLIENT_ID 时不该去联网,而是明确告知要配什么。 + let saved = std::env::var("ULL_MSA_CLIENT_ID").ok(); + std::env::remove_var("ULL_MSA_CLIENT_ID"); + let dir = data_root("accounts-msa"); + let (out, _) = run(&cli(&dir, &["accounts", "add", "msa"]), 1); + if let Some(value) = saved { + std::env::set_var("ULL_MSA_CLIENT_ID", value); + } + let envelope = parse_json(&out); + assert_eq!(envelope["error"]["code"], "unsupported"); + assert!(envelope["error"]["hint"] + .as_str() + .unwrap_or_default() + .contains("ULL_MSA_CLIENT_ID")); +} + +#[test] +fn launch_dry_run_uses_the_only_account() { + let dir = data_root("launch-account"); + write_version_with_auth(dir.path()); + run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0); + let (out, _) = run(&launch(&dir, &[]), 0); + let data = &parse_json(&out)["data"]; + let account = &data["account"]; + assert_eq!(account["name"], "Steve"); + assert_eq!(account["kind"], "offline"); + assert_eq!(account["profile_id"], offline_uuid("Steve")); + assert_eq!(account["registered"], true); + + // argv 里玩家名与 UUID 都对上了,token 位置是脱敏占位。 + let argv: Vec = data["argv"] + .as_array() + .expect("argv") + .iter() + .map(|item| item.as_str().unwrap_or_default().to_string()) + .collect(); + let username_at = argv + .iter() + .position(|arg| arg == "--username") + .expect("有 --username"); + assert_eq!(argv[username_at + 1], "Steve"); + // launch 的脱敏器把 uuid 也当敏感值处理(M18 定案:dry-run 输出是「脱敏 uuid」), + // 真实 uuid 在下面的身份块里以可公开元数据的形式给出。 + let uuid_at = argv + .iter() + .position(|arg| arg == "--uuid") + .expect("有 --uuid"); + assert_eq!(argv[uuid_at + 1], ""); + // 真凭据位置:离线占位 token 是随机 32 hex,输出里必须只剩脱敏占位。 + let token_at = argv + .iter() + .position(|arg| arg == "--accessToken") + .expect("有 --accessToken"); + assert_eq!(argv[token_at + 1], ""); + assert!(out.contains("")); + // 命令行与展示行里不该有任何 32 位十六进制串(= 没漏出 accessToken)。 + let display = data["display"].as_str().unwrap_or_default(); + assert!(!has_hex32(&argv.join(" ")), "argv 漏了 token:{argv:?}"); + assert!(!has_hex32(display), "display 漏了 token:{display}"); + assert!( + !out.contains("access_token"), + "输出不该出现 access_token 字段" + ); +} + +/// 输出里是否有 32 位十六进制串(token 形状)。 +fn has_hex32(text: &str) -> bool { + text.split(|ch: char| !ch.is_ascii_hexdigit()) + .any(|run| run.len() == 32) +} + +#[test] +fn multiple_accounts_require_explicit_choice() { + let dir = data_root("launch-multi"); + run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0); + run(&cli(&dir, &["accounts", "add", "offline", "Alex"]), 0); + let (out, _) = run(&launch(&dir, &[]), 1); + let envelope = parse_json(&out); + assert_eq!(envelope["error"]["code"], "account_ambiguous"); + assert!( + envelope["error"]["hint"] + .as_str() + .unwrap_or_default() + .contains("Steve"), + "hint 要列候选" + ); + + // 指定名字就能选出来(大小写不敏感)。 + let (out, _) = run(&launch(&dir, &["--account", "alex"]), 0); + let data = &parse_json(&out)["data"]; + assert_eq!(data["account"]["name"], "Alex"); + assert_eq!(data["account"]["profile_id"], offline_uuid("Alex")); +} + +#[test] +fn unknown_account_and_username_conflict_are_usage_errors() { + let dir = data_root("launch-unknown"); + run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0); + let (out, _) = run(&launch(&dir, &["--account", "Nobody"]), 1); + assert_eq!(parse_json(&out)["error"]["code"], "not_found"); + + let (out, _) = run(&launch(&dir, &["--username", "Alex"]), 1); + assert_eq!(parse_json(&out)["error"]["code"], "usage"); +} + +#[test] +fn launch_without_accounts_keeps_the_offline_placeholder() { + let dir = data_root("launch-placeholder"); + let (out, _) = run(&launch(&dir, &["--username", "Node"]), 0); + let data = &parse_json(&out)["data"]; + assert_eq!(data["account"]["registered"], false); + assert_eq!(data["account"]["kind"], "offline-placeholder"); + assert_eq!(data["account"]["name"], "Node"); + let argv: Vec<&str> = data["argv"] + .as_array() + .expect("argv") + .iter() + .map(|item| item.as_str().unwrap_or_default()) + .collect(); + let at = argv + .iter() + .position(|arg| *arg == "--username") + .expect("--username"); + assert_eq!(argv[at + 1], "Node"); +} + +#[test] +fn accounts_help_topic_exists() { + let (out, _) = run(&["help".to_string(), "accounts".to_string()], 0); + assert!(out.contains("credentials.dat"), "{out}"); + assert!(out.contains("accounts add offline"), "{out}"); +} diff --git a/crates/cli/tests/agent_mcp_stdio_test.rs b/crates/cli/tests/agent_mcp_stdio_test.rs new file mode 100644 index 0000000..2600454 --- /dev/null +++ b/crates/cli/tests/agent_mcp_stdio_test.rs @@ -0,0 +1,185 @@ +//! 真机验收:起 `ull agent serve --stdio`,用裸 JSON-RPC 走一遍握手 → 列工具 → 调用。 +//! +//! 这是 M39 任务 5 的自动化形态:**不引任何 MCP 客户端库**,直接按协议喂行给子进程。 +//! stdio 形态是 llama.cpp 内置 MCP client 的入口,它的 stdout 必须是纯 JSON-RPC—— +//! 本测试顺带守住「stdout 里没有多余输出」这条最容易破的约束。 + +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use serde_json::{json, Value}; + +fn data_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ull-agent-stdio-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建临时数据根"); + dir +} + +struct Session { + child: std::process::Child, + stdin: std::process::ChildStdin, + stdout: std::io::Lines>, +} + +impl Session { + fn start(dir: &Path) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_ull")) + .args([ + "agent", + "serve", + "--stdio", + "--allow-write", + "--data-dir", + &dir.display().to_string(), + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("应能起 ull agent serve --stdio"); + let stdin = child.stdin.take().expect("stdin"); + let stdout = BufReader::new(child.stdout.take().expect("stdout")).lines(); + Self { + child, + stdin, + stdout, + } + } + + fn send(&mut self, message: &Value) { + writeln!(self.stdin, "{message}").expect("写请求"); + self.stdin.flush().expect("刷新 stdin"); + } + + fn next_response(&mut self) -> Value { + let line = self + .stdout + .next() + .expect("stdio 应有一行响应(不应挂断)") + .expect("读响应行失败"); + serde_json::from_str(&line).unwrap_or_else(|_| panic!("stdout 不是 JSON-RPC:{line}")) + } + + fn rpc(&mut self, id: u32, method: &str, params: Value) -> Value { + self.send(&json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })); + self.next_response() + } +} + +impl Drop for Session { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[test] +fn stdio_handshake_lists_tools_and_calls_them() { + let dir = data_dir("handshake"); + let mut session = Session::start(&dir); + + // 1. initialize + let init = session.rpc( + 1, + "initialize", + json!({ + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { "name": "ull-test", "version": "0.1" } + }), + ); + assert_eq!( + init["result"]["serverInfo"]["name"], + json!("unlucky-launcher"), + "{init}" + ); + session.send(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })); + + // 2. tools/list —— 写工具在(--allow-write),十个工具全在 + let list = session.rpc(2, "tools/list", json!({})); + let names: Vec = list["result"]["tools"] + .as_array() + .expect("tools 数组") + .iter() + .filter_map(|tool| tool["name"].as_str().map(str::to_string)) + .collect(); + assert_eq!(names.len(), 10, "{names:?}"); + for expected in [ + "get_config", + "set_config", + "list_instances", + "list_accounts", + "diagnose_crash", + ] { + assert!(names.iter().any(|name| name == expected), "缺 {expected}"); + } + + // 3. 只读工具真返回(真实 core 端口:空数据根 → 空实例/空账户) + let instances = session.rpc( + 3, + "tools/call", + json!({ "name": "list_instances", "arguments": {} }), + ); + assert_ne!(instances["result"]["isError"], json!(true), "{instances}"); + let text = instances["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default(); + assert!(text.contains("\"total\":0"), "空数据根应 0 个实例:{text}"); + + // 4. 白名单普通键写入真落盘 + let written = session.rpc( + 4, + "tools/call", + json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }), + ); + assert_ne!(written["result"]["isError"], json!(true), "{written}"); + let launcher = dir.join(".ULL").join("config").join("launcher.json"); + let content = std::fs::read_to_string(&launcher).expect("应写出 launcher.json"); + assert!(content.contains("\"concurrency\""), "{content}"); + + // 5. 敏感键被拒(外部客户端没有弹窗),文案引导到设置页 / YOLO + let denied = session.rpc( + 5, + "tools/call", + json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }), + ); + assert_eq!(denied["result"]["isError"], json!(true), "{denied}"); + let text = denied["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default(); + assert!(text.contains("YOLO"), "{text}"); + + // 6. 审计落盘:每一条都在,且会话标签能区分「外部 MCP」 + let audit_dir = dir.join(".ULL").join("data").join("agent").join("audit"); + let mut records: Vec = Vec::new(); + for entry in std::fs::read_dir(&audit_dir) + .expect("审计目录应存在") + .flatten() + { + let text = std::fs::read_to_string(entry.path()).unwrap_or_default(); + for line in text.lines() { + if let Ok(value) = serde_json::from_str::(line) { + records.push(value); + } + } + } + assert!( + records + .iter() + .any(|record| record["tool"] == json!("set_config") && record["outcome"] == json!("ok")), + "普通键写入应有 ok 审计:{records:?}" + ); + assert!( + records + .iter() + .any(|record| record["outcome"] == json!("denied")), + "敏感键拒绝应有 denied 审计:{records:?}" + ); + assert!(records + .iter() + .all(|record| record["agent_session_id"] == json!("mcp-stdio"))); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/cli/tests/config_secret_test.rs b/crates/cli/tests/config_secret_test.rs new file mode 100644 index 0000000..ceae4c0 --- /dev/null +++ b/crates/cli/tests/config_secret_test.rs @@ -0,0 +1,179 @@ +//! 敏感键脱敏(P1-1 回归):单键 get、无键全量 dump、set 的旧值回显三条路径 +//! 都不能把凭据明文交给用户或脚本。 + +#[path = "support/mod.rs"] +mod support; + +use support::{parse_json, run_cli, TempDir}; + +/// 一眼可 grep 的假凭据。 +const SECRET: &str = "sk-live-THIS-MUST-NOT-LEAK"; +/// 代理 URL 里的口令(`download.proxy_url` 是 schema 的 High 档,但键名不含敏感词)。 +const PROXY: &str = "socks5://user:pw@127.0.0.1:1080"; + +/// 建一个已经写入敏感键的数据根。 +fn data_dir_with_secrets() -> TempDir { + let dir = TempDir::new("secret"); + let (code, _, stderr) = run_cli(&[ + "config", + "set", + "ai.api_key", + SECRET, + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "{stderr}"); + let (code, _, stderr) = run_cli(&[ + "config", + "set", + "download.proxy_url", + PROXY, + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "{stderr}"); + // 一个普通键:用来验证掩码没有把整份配置打成红条(默认值不落盘,必须先写一次)。 + let (code, _, stderr) = run_cli(&[ + "config", + "set", + "ui.language", + "en_us", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "{stderr}"); + dir +} + +#[test] +fn full_dump_redacts_sensitive_keys_by_default() { + let dir = data_dir_with_secrets(); + let (code, stdout, stderr) = run_cli(&["config", "get", "--json", "--data-dir", &dir.arg()]); + assert_eq!(code, 0, "{stderr}"); + assert!(!stdout.contains(SECRET), "全量 dump 泄出 API key:{stdout}"); + assert!( + !stdout.contains("user:pw@"), + "全量 dump 泄出代理口令:{stdout}" + ); + + let value = parse_json(&stdout); + assert_eq!(value["data"]["config"]["ai"]["api_key"], ""); + assert_eq!( + value["data"]["config"]["download"]["proxy_url"], + "" + ); + assert_eq!(value["data"]["redacted"], true); + // 非敏感键照常可读(掩码不能把整份配置打成红条)。 + assert_eq!(value["data"]["config"]["ui"]["language"], "en_us"); +} + +#[test] +fn full_dump_human_output_is_redacted_too() { + let dir = data_dir_with_secrets(); + let (code, stdout, _) = run_cli(&["config", "get", "--data-dir", &dir.arg()]); + assert_eq!(code, 0); + assert!(!stdout.contains(SECRET), "{stdout}"); + assert!(!stdout.contains("user:pw@"), "{stdout}"); + assert!(stdout.contains(""), "{stdout}"); + assert!(stdout.contains("--show-secret"), "{stdout}"); +} + +#[test] +fn single_key_get_masks_by_default() { + let dir = data_dir_with_secrets(); + let (code, stdout, _) = run_cli(&[ + "config", + "get", + "ai.api_key", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0); + assert!(!stdout.contains(SECRET), "{stdout}"); + let value = parse_json(&stdout); + assert_eq!(value["data"]["value"], ""); + assert_eq!(value["data"]["sensitive"], true); + assert_eq!(value["data"]["redacted"], true); +} + +#[test] +fn show_secret_reveals_plaintext() { + let dir = data_dir_with_secrets(); + // 全量 dump + --show-secret:明文,且 redacted=false。 + let (code, stdout, _) = run_cli(&[ + "config", + "get", + "--show-secret", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0); + let value = parse_json(&stdout); + assert_eq!(value["data"]["config"]["ai"]["api_key"], SECRET); + assert_eq!(value["data"]["config"]["download"]["proxy_url"], PROXY); + assert_eq!(value["data"]["redacted"], false); + + // 单键 + --show-secret:人类输出就是明文。 + let (code, stdout, _) = run_cli(&[ + "config", + "get", + "ai.api_key", + "--show-secret", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0); + assert!(stdout.contains(SECRET), "{stdout}"); +} + +#[test] +fn set_masks_old_value_but_echoes_new() { + let dir = data_dir_with_secrets(); + + let (code, stdout, stderr) = run_cli(&[ + "config", + "set", + "ai.api_key", + "sk-new-value", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "{stderr}"); + assert!(!stdout.contains(SECRET), "set 回显了旧密钥:{stdout}"); + let value = parse_json(&stdout); + assert_eq!(value["data"]["old"], ""); + assert_eq!(value["data"]["new"], "sk-new-value"); + + // 人类模式同样不回显旧值明文(新值照常,用户刚敲的)。 + let (code, stdout, _) = run_cli(&[ + "config", + "set", + "ai.api_key", + "sk-third", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0); + assert!( + !stdout.contains("sk-new-value"), + "人类输出回显旧值:{stdout}" + ); + assert!(stdout.contains(""), "{stdout}"); + assert!(stdout.contains("sk-third"), "{stdout}"); + + // 非敏感键仍然正常回显原值。 + let (code, stdout, _) = run_cli(&[ + "config", + "set", + "ui.language", + "zh_cn", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0); + assert_eq!(parse_json(&stdout)["data"]["old"], "en_us"); +} diff --git a/crates/cli/tests/exit_code_test.rs b/crates/cli/tests/exit_code_test.rs new file mode 100644 index 0000000..8e732b1 --- /dev/null +++ b/crates/cli/tests/exit_code_test.rs @@ -0,0 +1,109 @@ +//! 退出码与输出通道(0 成功 / 1 用户错误 / 2 内部错误)。 + +#[path = "support/mod.rs"] +mod support; + +use support::{run_cli, write_version, TempDir}; +use unlucky_cli::error::{CliError, ErrorKind, EXIT_INTERNAL, EXIT_OK, EXIT_USER}; + +#[test] +fn exit_code_constants_match_kinds() { + assert_eq!(EXIT_OK, 0); + assert_eq!(EXIT_USER, 1); + assert_eq!(EXIT_INTERNAL, 2); + assert_eq!(ErrorKind::User.exit_code(), 1); + assert_eq!(ErrorKind::Internal.exit_code(), 2); + assert_eq!(CliError::usage("x").exit_code(), 1); + assert_eq!(CliError::user("io", "x").exit_code(), 1); + assert_eq!(CliError::internal("bug", "x").exit_code(), 2); +} + +#[test] +fn internal_errors_serialize_as_internal() { + let value = CliError::internal("serialize", "序列化失配").to_json(); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["kind"], "internal"); + assert_eq!(value["error"]["code"], "serialize"); + assert!(value["error"].get("hint").is_none()); +} + +#[test] +fn success_exits_zero() { + let dir = TempDir::new("code-ok"); + let (code, _, _) = run_cli(&["instances", "list", "--data-dir", &dir.arg()]); + assert_eq!(code, 0); +} + +#[test] +fn user_error_exits_one() { + let dir = TempDir::new("code-user"); + let cases: Vec> = vec![ + vec!["frobnicate".to_string()], + vec!["install".to_string()], + vec![ + "launch".to_string(), + "nope".to_string(), + "--data-dir".to_string(), + dir.arg(), + ], + vec![ + "logs".to_string(), + "deadbeef".to_string(), + "--data-dir".to_string(), + dir.arg(), + ], + // `java install` 已实装(会真联网下载)——用户错误只取参数校验这一层: + // 大版本号离谱时在联网之前就拒,测试因此保持零网络(M27 踩坑记录 G 节同款教训)。 + vec![ + "java".to_string(), + "install".to_string(), + "4".to_string(), + "--data-dir".to_string(), + dir.arg(), + ], + vec![ + "--data-dir".to_string(), + "relative/path".to_string(), + "instances".to_string(), + "list".to_string(), + ], + ]; + for args in cases { + let (code, _, stderr) = run_cli(&args.iter().map(String::as_str).collect::>()); + assert_eq!(code, 1, "args={args:?} stderr={stderr}"); + assert!(stderr.contains("错误:"), "args={args:?} stderr={stderr}"); + } +} + +#[test] +fn human_and_json_go_to_different_streams() { + let dir = TempDir::new("code-streams"); + write_version(dir.path(), "1.20.1"); + + // 人类模式:结果进 stdout。 + let (code, stdout, stderr) = run_cli(&["instances", "list", "--data-dir", &dir.arg()]); + assert_eq!(code, 0); + assert!(stdout.contains("实例"), "{stdout}"); + assert!(stderr.is_empty(), "{stderr}"); + + // 错误进 stderr,不污染 stdout。 + let (code, stdout, stderr) = run_cli(&["launch", "nope", "--data-dir", &dir.arg()]); + assert_eq!(code, 1); + assert!(stdout.is_empty(), "{stdout}"); + assert!(stderr.contains("错误:"), "{stderr}"); +} + +#[test] +fn quiet_suppresses_human_output_only() { + let dir = TempDir::new("code-quiet"); + let (code, stdout, stderr) = + run_cli(&["instances", "list", "--quiet", "--data-dir", &dir.arg()]); + assert_eq!(code, 0); + assert!(stdout.is_empty(), "{stdout}"); + assert!(stderr.is_empty(), "{stderr}"); + + // 错误不受 --quiet 影响(脚本仍要能看到原因)。 + let (code, _, stderr) = run_cli(&["launch", "nope", "--quiet", "--data-dir", &dir.arg()]); + assert_eq!(code, 1); + assert!(stderr.contains("错误:"), "{stderr}"); +} diff --git a/crates/cli/tests/isolation_launch_test.rs b/crates/cli/tests/isolation_launch_test.rs new file mode 100644 index 0000000..2b8d4a2 --- /dev/null +++ b/crates/cli/tests/isolation_launch_test.rs @@ -0,0 +1,345 @@ +//! D20 隔离档位的 CLI 端到端:`ull launch` 按覆盖链把派生结果填进 `--gameDir`。 +//! +//! 四条路径(全局 isolated / 全局 shared / 实例 override / custom)+ 旧档案读兼容 + +//! `--dry-run` 不落盘。断言走 `--json` 输出的 `isolation` / `isolation_source` / `game_dir`, +//! 也就是真正喂给 `${game_directory}` 与子进程 CWD 的那个值。 + +#[path = "support/mod.rs"] +mod support; + +use std::path::{Path, PathBuf}; + +use support::{parse_json, run_cli, TempDir}; + +/// 实例 id(与 `profiles.json` 里写的必须一致)。 +const INSTANCE_ID: &str = "11111111-2222-4333-8444-000000000001"; +/// 版本 id。 +const VERSION: &str = "1.20.1"; + +/// 建数据根:版本本体 + `profiles.json`(一个实例「生存服」)。 +fn data_root(tag: &str) -> TempDir { + let dir = TempDir::new(tag); + write_version_with_game_dir(dir.path()); + write_profiles(dir.path(), None); + dir +} + +/// 写一份「带 `--gameDir ${game_directory}`」的最小版本 JSON + 空 client jar。 +/// +/// 比 `support::write_version` 多这一个参数——`--gameDir` 就是隔离档位的真实落点 +/// (正式版清单 1.13+ 都有这条),没有它就无法从命令行验证派生结果。 +fn write_version_with_game_dir(root: &Path) { + let dir = root.join(".minecraft").join("versions").join(VERSION); + std::fs::create_dir_all(&dir).expect("建版本目录"); + let version = serde_json::json!({ + "id": VERSION, + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "assets": "5", + "assetIndex": { "id": "5" }, + "arguments": { + "game": [ + "--username", "${auth_player_name}", + "--version", "${version_name}", + "--gameDir", "${game_directory}" + ], + "jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"] + }, + "libraries": [], + "downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } } + }); + std::fs::write( + dir.join(format!("{VERSION}.json")), + serde_json::to_vec_pretty(&version).expect("序列化版本 JSON"), + ) + .expect("写版本 JSON"); + std::fs::write(dir.join(format!("{VERSION}.jar")), b"junk").expect("写 client jar"); +} + +/// 写 `config/profiles.json`(可选 `game_dir` 遗留字段)。 +fn write_profiles(root: &Path, legacy_game_dir: Option<&Path>) { + let profiles_dir = root.join(".ULL").join("config"); + std::fs::create_dir_all(&profiles_dir).expect("建配置目录"); + let mut profile = serde_json::json!({ + "id": INSTANCE_ID, + "name": "生存服", + "version_id": VERSION, + }); + if let Some(game_dir) = legacy_game_dir { + profile["game_dir"] = serde_json::json!(game_dir.display().to_string()); + } + let document = serde_json::json!({ + "schema_version": 1, + "profiles": [profile], + }); + std::fs::write( + profiles_dir.join("profiles.json"), + serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"), + ) + .expect("写 profiles.json"); +} + +/// 写实例设置文件(`config/instances/.json`)。 +fn write_instance_config(dir: &Path, value: &serde_json::Value) { + let path = dir + .join(".ULL") + .join("config") + .join("instances") + .join(format!("{INSTANCE_ID}.json")); + std::fs::create_dir_all(path.parent().expect("父目录")).expect("建实例配置目录"); + std::fs::write( + &path, + serde_json::to_vec_pretty(value).expect("序列化实例设置"), + ) + .expect("写实例设置"); +} + +/// 跑一次 `launch --dry-run --json`。 +fn dry_run(dir: &TempDir) -> serde_json::Value { + let (code, stdout, stderr) = run_cli(&[ + "launch", + "生存服", + "--dry-run", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "{stderr}"); + parse_json(&stdout)["data"].clone() +} + +/// 数据根下的游戏目录。 +fn game_root(dir: &TempDir) -> PathBuf { + dir.path().join(".minecraft") +} + +#[test] +fn default_mode_is_isolated_and_derives_version_dir() { + let dir = data_root("iso-default"); + let data = dry_run(&dir); + assert_eq!(data["isolation"], "isolated", "D20 默认隔离"); + assert_eq!(data["isolation_source"], "global"); + assert_eq!(data["instance_found"], true, "实例名解析仍然有效"); + assert_eq!( + PathBuf::from(data["game_dir"].as_str().expect("game_dir")), + game_root(&dir).join("versions").join(VERSION) + ); + assert!(data["mode_change_warning"].is_null()); + + // 端到端:派生值必须是命令行里 `--gameDir` 的实参(占位符已替换、家目录已脱敏, + // 所以按路径后缀断言)。 + let argv: Vec<&str> = data["argv"] + .as_array() + .expect("argv") + .iter() + .map(|token| token.as_str().expect("token")) + .collect(); + let index = argv + .iter() + .position(|token| *token == "--gameDir") + .unwrap_or_else(|| panic!("命令行里没有 --gameDir:{argv:?}")); + let passed = PathBuf::from(argv[index + 1]); + assert!( + passed.ends_with(Path::new("versions").join(VERSION)), + "--gameDir 不是派生的隔离目录:{}", + passed.display() + ); +} + +#[test] +fn shared_mode_switches_game_dir_to_game_root() { + let dir = data_root("iso-shared"); + let (code, _, stderr) = run_cli(&[ + "config", + "set", + "instances.mode", + "shared", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "{stderr}"); + + let data = dry_run(&dir); + assert_eq!(data["isolation"], "shared"); + assert_eq!(data["isolation_source"], "global"); + assert_eq!( + PathBuf::from(data["game_dir"].as_str().expect("game_dir")), + game_root(&dir), + "共享档的运行目录就是 .minecraft 根" + ); +} + +#[test] +fn instance_isolation_overrides_global_mode() { + let dir = data_root("iso-instance"); + let (code, _, stderr) = run_cli(&[ + "config", + "set", + "instances.mode", + "shared", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "{stderr}"); + write_instance_config( + dir.path(), + &serde_json::json!({ + "schemaVersion": 1, + "id": INSTANCE_ID, + "versionId": VERSION, + "isolation": "isolated", + }), + ); + + let data = dry_run(&dir); + assert_eq!(data["isolation"], "isolated", "实例档位压过全局"); + assert_eq!(data["isolation_source"], "instance"); + assert_eq!( + PathBuf::from(data["game_dir"].as_str().expect("game_dir")), + game_root(&dir).join("versions").join(VERSION) + ); +} + +#[test] +fn custom_isolation_uses_the_recorded_game_dir() { + let dir = data_root("iso-custom"); + let custom = dir.path().join("mcs").join("farm"); + write_instance_config( + dir.path(), + &serde_json::json!({ + "schemaVersion": 1, + "id": INSTANCE_ID, + "versionId": VERSION, + "isolation": "custom", + "gameDir": custom.display().to_string(), + }), + ); + + let data = dry_run(&dir); + assert_eq!(data["isolation"], "custom"); + assert_eq!( + PathBuf::from(data["game_dir"].as_str().expect("game_dir")), + custom + ); +} + +#[test] +fn legacy_profile_game_dir_reads_as_custom() { + // M27 之前的档案只在 profiles.json 里存 game_dir(没有实例设置文件): + // 显式指定过的目录不能被静默退回全局档。 + let dir = TempDir::new("iso-legacy"); + write_version_with_game_dir(dir.path()); + let legacy = dir.path().join("old-instance-dir"); + write_profiles(dir.path(), Some(&legacy)); + let data = dry_run(&dir); + assert_eq!(data["isolation"], "custom"); + assert_eq!( + PathBuf::from(data["game_dir"].as_str().expect("game_dir")), + legacy + ); +} + +#[test] +fn dry_run_does_not_write_instance_state() { + // 切换检测只在真启动时落盘:`--dry-run` 不该吃掉「切换后首次启动提示一次」。 + let dir = data_root("iso-dry-run"); + let config_path = dir + .path() + .join(".ULL") + .join("config") + .join("instances") + .join(format!("{INSTANCE_ID}.json")); + assert!(!config_path.exists()); + let _ = dry_run(&dir); + assert!( + !config_path.exists(), + "--dry-run 写盘了:{}", + config_path.display() + ); +} + +#[test] +fn unknown_instance_still_reports_available_names() { + let dir = data_root("iso-unknown"); + let (code, _, stderr) = run_cli(&[ + "launch", + "不存在的实例", + "--dry-run", + "--data-dir", + &dir.arg(), + ]); + assert_ne!(code, 0, "未知实例要失败"); + assert!(stderr.contains("生存服"), "提示里要列出可用实例:{stderr}"); +} + +/// 读实例设置文件(不存在返回 `None`)。 +fn read_instance_config(dir: &TempDir) -> Option { + let path = dir + .path() + .join(".ULL") + .join("config") + .join("instances") + .join(format!("{INSTANCE_ID}.json")); + let text = std::fs::read_to_string(path).ok()?; + Some(serde_json::from_str(&text).expect("实例设置应为合法 JSON")) +} + +/// 真启动一次(用一个不存在的 java,保证 spawn 必失败、不会真的跑起游戏), +/// 只为驱动「真启动才写盘」的那段逻辑。 +fn real_launch_that_fails(dir: &TempDir) -> (i32, String, String) { + let bogus_java = dir.path().join("no-such-java.exe"); + run_cli(&[ + "launch", + "生存服", + "--java", + &bogus_java.display().to_string(), + "--data-dir", + &dir.arg(), + ]) +} + +#[test] +fn first_real_launch_bootstraps_instance_file() { + // 回归 P2-3(review r1):profiles.json 有档案但没有实例设置文件(M27 之前的形态), + // 真启动要补建基线文件,否则切换检测对这批遗留实例永远不启动。 + let dir = data_root("iso-bootstrap"); + assert!(read_instance_config(&dir).is_none(), "前置:还没有设置文件"); + + let (code, _, stderr) = real_launch_that_fails(&dir); + assert_ne!(code, 0, "假 java 必然启动失败:{stderr}"); + + let config = read_instance_config(&dir).expect("首次真启动应补建设置文件"); + assert_eq!(config["versionId"], VERSION); + assert_eq!(config["id"], INSTANCE_ID); + // 基线:from == to,且已经标记 warned(不该平白弹一次提示)。 + assert_eq!(config["lastModeChange"]["from"], "isolated"); + assert_eq!(config["lastModeChange"]["to"], "isolated"); + assert_eq!(config["lastModeChange"]["warned"], true); + assert!(config["lastModeChange"]["at"].is_string()); + assert!(config["lastModeChange"]["probe"]["isolatedSide"].is_number()); + assert!(config["lastModeChange"]["probe"]["sharedSide"].is_number()); + // inherit 不落盘。 + assert!(config.get("isolation").is_none(), "{config}"); +} + +#[test] +fn legacy_profile_dir_is_recorded_as_custom_on_bootstrap() { + // 同一批遗留档案里「显式指定过目录」的那部分:补建时把 old profiles.game_dir → custom + // 固化进实例文件,之后实例文件就是唯一真相。 + let dir = TempDir::new("iso-bootstrap-legacy"); + write_version_with_game_dir(dir.path()); + let legacy = dir.path().join("old-instance-dir"); + write_profiles(dir.path(), Some(&legacy)); + + let (code, _, stderr) = real_launch_that_fails(&dir); + assert_ne!(code, 0, "假 java 必然启动失败:{stderr}"); + + let config = read_instance_config(&dir).expect("首次真启动应补建设置文件"); + assert_eq!(config["isolation"], "custom"); + assert_eq!( + PathBuf::from(config["gameDir"].as_str().expect("gameDir")), + legacy + ); + assert_eq!(config["lastModeChange"]["to"], "custom"); + assert_eq!(config["lastModeChange"]["warned"], true, "基线不弹提示"); +} diff --git a/crates/cli/tests/java_test.rs b/crates/cli/tests/java_test.rs new file mode 100644 index 0000000..c1570ae --- /dev/null +++ b/crates/cli/tests/java_test.rs @@ -0,0 +1,197 @@ +//! `ull java list` / `ull java install` / `ull launch` 的 Java 选择(D13)CLI 端。 +//! +//! 不联网:`install` 只测参数校验与稳定错误码;`launch --dry-run` 断言 `java` 块的结构 +//! 与「实例覆盖优先」——真正的下载/解压由 `core::game::java` 的测试与本机验收覆盖。 + +#[path = "support/mod.rs"] +mod support; + +use support::{parse_json, run_cli, write_version, TempDir}; + +/// 一个可组装的版本 id。 +const VERSION: &str = "1.20.1"; + +#[test] +fn java_list_reports_the_managed_root() { + let dir = TempDir::new("java-list"); + let (code, stdout, stderr) = run_cli(&["--json", "--data-dir", &dir.arg(), "java", "list"]); + assert_eq!(code, 0, "stderr: {stderr}"); + let envelope = parse_json(&stdout); + let json = &envelope["data"]; + assert!( + json["managed_root"] + .as_str() + .is_some_and(|root| root.ends_with("java-managed")), + "managed_root 应是 data/java-managed:{json}" + ); + // D23:多根扫描 + 落点模式要说清(`--data-dir` 是 custom 数据模式 → portable 落点)。 + assert!( + json["managed_roots"] + .as_array() + .is_some_and(|roots| !roots.is_empty()), + "{json}" + ); + assert_eq!(json["install"]["mode"], "portable", "{json}"); + assert!( + json["install"]["root"] + .as_str() + .is_some_and(|root| root.ends_with("java-managed")), + "落点根应是数据根里的 java-managed:{json}" + ); + assert!(json["candidates"].is_array(), "{json}"); +} + +#[test] +fn java_vendors_lists_the_four_vendors_with_the_current_order() { + let dir = TempDir::new("java-vendors"); + let (code, stdout, stderr) = run_cli(&["--json", "--data-dir", &dir.arg(), "java", "vendors"]); + assert_eq!(code, 0, "stderr: {stderr}"); + let envelope = parse_json(&stdout); + let json = &envelope["data"]; + let order: Vec<&str> = json["order"] + .as_array() + .expect("order 数组") + .iter() + .filter_map(|item| item.as_str()) + .collect(); + assert_eq!(order.len(), 4, "{json}"); + assert!(order.contains(&"temurin"), "{json}"); + assert!(order.contains(&"oracle"), "{json}"); + assert!(order.contains(&"microsoft"), "{json}"); + assert!(order.contains(&"zulu"), "{json}"); + // 本机时区/语言决定 domestic/global,两种默认序都合法,但来源必须明示。 + let source = json["priority_source"].as_str().unwrap_or("?"); + assert_eq!( + source, "region_default", + "没设 java.vendorPriority 时:{json}" + ); + let vendors = json["vendors"].as_array().expect("vendors 数组"); + assert_eq!(vendors.len(), 4); + let microsoft = vendors + .iter() + .find(|row| row["vendor"] == "microsoft") + .expect("应有 microsoft 行"); + assert_eq!( + microsoft["supported"].as_array().map(Vec::len), + Some(4), + "MS 只发 25/21/17/11:{microsoft}" + ); + assert!( + microsoft["sources"] + .as_array() + .is_some_and(|sources| !sources.is_empty()), + "MS 的源(aka.ms → VS CDN)要列出来:{microsoft}" + ); +} + +#[test] +fn java_install_rejects_an_unknown_vendor_without_network() { + let dir = TempDir::new("java-vendor-bad"); + let (code, stdout, _stderr) = run_cli(&[ + "--json", + "--data-dir", + &dir.arg(), + "java", + "install", + "21", + "--vendor", + "graalvm", + ]); + assert_eq!(code, 1); + let envelope = parse_json(&stdout); + assert_eq!(envelope["error"]["code"], "usage", "{envelope}"); +} + +#[test] +fn java_install_rejects_an_absurd_major_without_network() { + let dir = TempDir::new("java-install"); + let (code, stdout, _stderr) = + run_cli(&["--json", "--data-dir", &dir.arg(), "java", "install", "4"]); + assert_eq!(code, 1); + let envelope = parse_json(&stdout); + assert_eq!(envelope["error"]["code"], "usage", "{envelope}"); +} + +#[test] +fn launch_dry_run_reports_the_java_requirement_of_the_version() { + let dir = TempDir::new("java-launch"); + write_version(dir.path(), VERSION); + let (code, stdout, stderr) = run_cli(&[ + "--json", + "--data-dir", + &dir.arg(), + "launch", + VERSION, + "--dry-run", + ]); + assert_eq!(code, 0, "stderr: {stderr}"); + let envelope = parse_json(&stdout); + let json = &envelope["data"]; + let java = &json["java"]; + // 1.20.1 的清单没写 javaVersion → 静态表兜底 17。 + assert_eq!(java["required_major"], 17, "{json}"); + assert_eq!(java["requirement_source"], "table", "{json}"); + assert_eq!(json["version_id"], VERSION); + // `source` 只可能是探测到的来源之一(本机未必装了 Java)。 + let source = java["source"].as_str().unwrap_or("none"); + assert!( + ["instance", "managed", "JAVA_HOME", "PATH", "none"].contains(&source), + "意外的来源:{source}" + ); +} + +#[test] +fn instance_java_override_wins_over_the_system_java() { + let dir = TempDir::new("java-override"); + write_version(dir.path(), VERSION); + // 故意给一个不存在的路径:只要能进 `source=instance` 就说明覆盖优先级生效 + // (探测失败只影响 major,不该把用户指定的路径换成系统 Java)。 + let fake = dir.path().join("some-jdk").join("bin").join("java"); + let (code, stdout, stderr) = run_cli(&[ + "--json", + "--data-dir", + &dir.arg(), + "launch", + VERSION, + "--dry-run", + "--java", + &fake.display().to_string(), + ]); + assert_eq!(code, 0, "stderr: {stderr}"); + let envelope = parse_json(&stdout); + let json = &envelope["data"]; + assert_eq!(json["java"]["source"], "instance", "{json}"); + assert_eq!( + json["java"]["binary"].as_str(), + Some(fake.display().to_string().as_str()) + ); +} + +#[test] +fn legacy_version_requires_java_8_and_flags_a_newer_runtime() { + let dir = TempDir::new("java-legacy"); + write_version(dir.path(), "1.12.2"); + let (code, stdout, stderr) = run_cli(&[ + "--json", + "--data-dir", + &dir.arg(), + "launch", + "1.12.2", + "--dry-run", + ]); + assert_eq!(code, 0, "stderr: {stderr}"); + let envelope = parse_json(&stdout); + let json = &envelope["data"]; + assert_eq!(json["java"]["required_major"], 8, "{json}"); + assert_eq!(json["java"]["required"], "Java 8", "{json}"); + // 本机若有比 8 新的 Java,必须给出 java_too_new 告警(不能只靠「最低版本」)。 + if let Some(code) = json["java"]["mismatch"]["code"].as_str() { + assert!(code == "java_too_new" || code == "java_too_old", "{json}"); + assert!( + json["warnings"] + .as_array() + .is_some_and(|warnings| !warnings.is_empty()), + "不符时必须进 warnings:{json}" + ); + } +} diff --git a/crates/cli/tests/os_test.rs b/crates/cli/tests/os_test.rs new file mode 100644 index 0000000..086da5f --- /dev/null +++ b/crates/cli/tests/os_test.rs @@ -0,0 +1,115 @@ +//! D19/D21 支持基线:core 的纯判定函数 + CLI 的呈现层(注入样本,不动真机)。 + +use unlucky_cli::os; +use unlucky_core::platform::linux_caps::{GlProbe, SessionKind}; +use unlucky_core::platform::os_info::{ + self, linux_level, macos_level, support_level_for, windows_level, OsFamily, OsInfo, + SupportLevel, +}; +use unlucky_core::platform::vk_probe::VkProbe; + +/// 手工构造一份样本系统信息(字段全是探测事实,等级用 core 的纯函数算)。 +fn sample(family: OsFamily, version: Option<(u32, u32)>, product: &str) -> OsInfo { + let version_string = + version.map_or_else(String::new, |(major, minor)| format!("{major}.{minor}")); + OsInfo { + family, + arch: "x86_64".to_string(), + major_minor: version, + build: None, + product: product.to_string(), + version_string, + session: SessionKind::Unknown, + gl: GlProbe::Unknown, + vk: VkProbe::Unknown, + kernel: None, + glibc: None, + support_level: support_level_for(family, version, None, None), + } +} + +#[test] +fn windows_samples_below_win10_are_unsupported() { + // D21 用户拍板「直接 win10 起步」:Win8.1 / 8 / 7 及更旧一律不支持。 + assert_eq!(windows_level(Some((6, 1))), SupportLevel::Unsupported); // Win7 + assert_eq!(windows_level(Some((6, 2))), SupportLevel::Unsupported); // Win8 + assert_eq!(windows_level(Some((6, 3))), SupportLevel::Unsupported); // Win8.1 + assert_eq!(windows_level(Some((5, 1))), SupportLevel::Unsupported); // XP + assert_eq!(windows_level(Some((10, 0))), SupportLevel::One); // Win10 + + // 探不到版本 → 保守判不支持(不能因为"探不到"就放行)。 + assert_eq!(windows_level(None), SupportLevel::Unsupported); +} + +#[test] +fn macos_and_linux_samples() { + assert_eq!(macos_level(Some(11)), SupportLevel::Unsupported); + assert_eq!(macos_level(Some(12)), SupportLevel::One); + assert_eq!(macos_level(None), SupportLevel::Unsupported); + // Linux:内核/glibc 探得到但过低 → 不支持;取不到(容器/musl/Termux)不阻塞。 + assert_eq!(linux_level(Some((2, 6)), None), SupportLevel::Unsupported); + assert_eq!(linux_level(None, Some((2, 12))), SupportLevel::Unsupported); + assert_eq!(linux_level(Some((6, 8)), Some((2, 39))), SupportLevel::One); + assert_eq!(linux_level(None, None), SupportLevel::One); +} + +#[test] +fn unsupported_sample_renders_upgrade_hint() { + let info = sample(OsFamily::Windows, Some((6, 3)), "Windows 8.1"); + + assert!(!info.support_level.is_usable()); + let line = os::summary_line(&info); + assert!(line.contains("Windows 8.1"), "{line}"); + assert!(line.contains("6.3"), "{line}"); + assert!(line.contains("不支持"), "{line}"); + + let notes = os::notes(&info); + assert_eq!(notes.len(), 1); + assert!(notes[0].contains("Windows 10 或更高版本"), "{notes:?}"); + + // 文案是中英双语(core 同一份措辞),脚本只读 --json。 + let message = info.describe().combined(); + assert!(message.contains("Windows 10 或更高版本"), "{message}"); + assert!(message.contains("Windows 10 or newer"), "{message}"); + + let value = os::to_json(&info); + assert_eq!(value["family"], "windows"); + assert_eq!(value["product"], "Windows 8.1"); + assert_eq!(value["support"], "unsupported"); + assert_eq!(value["major_minor"], "6.3"); +} + +#[test] +fn supported_sample_has_no_notes() { + let info = sample(OsFamily::Windows, Some((10, 0)), "Windows 11"); + assert!(info.support_level.is_usable()); + assert!(os::notes(&info).is_empty()); + assert!(os::summary_line(&info).contains("满足最低要求")); + assert_eq!(os::to_json(&info)["support"], "one"); +} + +#[test] +fn missing_version_is_reported_as_null_not_zero() { + // 探不到版本时 `version` 是 null(脚本按 null 判定"未探测"),而不是空串。 + let info = sample(OsFamily::Windows, None, "Windows"); + let value = os::to_json(&info); + assert!(value["version"].is_null()); + assert!(value["major_minor"].is_null()); + assert_eq!(value["support"], "unsupported"); + assert!(os::summary_line(&info).contains("版本未知")); +} + +#[test] +fn real_detect_matches_core_cache() { + // 真机上 CLI 的摘要必须就是 core 的缓存快照(同一份事实,不做二次加工)。 + let info = os::detect(); + assert_eq!(&info, os_info::current()); + assert!(!info.arch.is_empty()); + assert!(!info.product.is_empty()); + // 自检形态与事实一致。 + let check = os::check(); + assert_eq!(check.level, info.support_level); + assert_eq!(check.os, info); + assert_eq!(check.is_usable(), info.support_level.is_usable()); + assert!(check.message.contains(&info.product), "{}", check.message); +} diff --git a/crates/cli/tests/output_test.rs b/crates/cli/tests/output_test.rs new file mode 100644 index 0000000..a9df87c --- /dev/null +++ b/crates/cli/tests/output_test.rs @@ -0,0 +1,287 @@ +//! `--json` 输出形状与真实 core 轻量路径(只读命令 + dry-run)。 + +#[path = "support/mod.rs"] +mod support; + +use support::{parse_json, run_cli, write_profiles, write_session, write_version, TempDir}; + +#[test] +fn version_json_has_os_summary() { + let (code, stdout, _) = run_cli(&["--version", "--json"]); + assert_eq!(code, 0); + let value = parse_json(&stdout); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["name"], "ull"); + assert_eq!(value["data"]["version"], env!("CARGO_PKG_VERSION")); + // D19/D21:os 对象来自 core::platform::os_info(family + 两档 support)。 + assert!(value["data"]["os"]["family"].is_string()); + let support = value["data"]["os"]["support"].as_str().unwrap_or_default(); + assert!(matches!(support, "one" | "unsupported"), "{support}"); +} + +#[test] +fn welcome_without_subcommand_prints_version_and_help() { + let (code, stdout, _) = run_cli(&[]); + assert_eq!(code, 0); + assert!(stdout.contains("ull "), "{stdout}"); + assert!(stdout.contains("用法:"), "{stdout}"); + + let (code, stdout, _) = run_cli(&["--json"]); + assert_eq!(code, 0); + let value = parse_json(&stdout); + assert!(value["data"]["help"] + .as_str() + .unwrap_or_default() + .contains("用法:")); +} + +#[test] +fn instances_list_json_shape() { + let dir = TempDir::new("instances"); + write_version(dir.path(), "1.20.1"); + write_profiles(dir.path(), &[("生存服", "1.20.1"), ("空实例", "1.19.4")]); + + let (code, stdout, _) = run_cli(&["instances", "list", "--json", "--data-dir", &dir.arg()]); + assert_eq!(code, 0); + let value = parse_json(&stdout); + let instances = value["data"]["instances"] + .as_array() + .expect("instances 数组"); + assert_eq!(instances.len(), 2); + assert_eq!(instances[0]["name"], "生存服"); + assert_eq!(instances[0]["version_present"], true); + assert_eq!(instances[1]["version_present"], false); + let installed = value["data"]["installed_versions"] + .as_array() + .expect("已安装版本"); + assert_eq!(installed.len(), 1); + assert_eq!(installed[0], "1.20.1"); +} + +#[test] +fn install_rejects_unknown_loader_before_touching_network() { + let dir = TempDir::new("install-loader"); + let (code, stdout, _) = run_cli(&[ + "install", + "1.20.1", + "--loader", + "liteloader", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 1, "未知加载器是用户错误"); + let value = parse_json(&stdout); + assert_eq!(value["error"]["code"], "loader_unknown"); + assert!( + !dir.path() + .join(".ULL") + .join("config") + .join("profiles.json") + .exists(), + "拒绝时不该登记任何实例" + ); +} + +#[test] +fn install_reports_name_conflict_without_downloading() { + let dir = TempDir::new("install-conflict"); + write_profiles(dir.path(), &[("生存服", "1.19.4")]); + + // 同名但绑定别的版本 → 冲突,且这一步在联网/下载之前就返回。 + let (code, stdout, _) = run_cli(&[ + "install", + "1.20.1", + "--name", + "生存服", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 1); + let value = parse_json(&stdout); + assert_eq!(value["error"]["code"], "instance_exists"); + assert!( + value["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("1.19.4"), + "错误里应说明冲突版本:{stdout}" + ); +} + +#[test] +fn install_requires_a_version_id() { + let dir = TempDir::new("install-empty"); + let (code, _, stderr) = run_cli(&["install", " ", "--data-dir", &dir.arg()]); + assert_eq!(code, 1); + assert!(stderr.contains("版本 id"), "stderr={stderr}"); +} + +#[test] +fn launch_dry_run_prints_assembled_command() { + let dir = TempDir::new("dryrun"); + write_version(dir.path(), "1.20.1"); + write_profiles(dir.path(), &[("生存服", "1.20.1")]); + + let (code, stdout, _) = run_cli(&[ + "launch", + "生存服", + "--dry-run", + "--username", + "Steve", + "--max-memory", + "2048", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "{stdout}"); + let value = parse_json(&stdout); + let data = &value["data"]; + assert_eq!(data["dry_run"], true); + assert_eq!(data["instance_found"], true); + let argv = data["argv"].as_array().expect("argv 数组"); + let joined = argv + .iter() + .filter_map(|item| item.as_str()) + .collect::>() + .join(" "); + assert!(joined.contains("-Xmx2048"), "{joined}"); + assert!( + joined.contains("net.minecraft.client.main.Main"), + "{joined}" + ); + assert!(joined.contains("--username Steve"), "{joined}"); + // 凭据绝不落到 dry-run 输出里。 + assert!(!joined.contains("accessToken "), "{joined}"); + assert!(data["classpath_entries"].as_u64().unwrap_or(0) >= 1); +} + +#[test] +fn sessions_logs_and_diag_read_the_index() { + let dir = TempDir::new("sessions"); + let sid = write_session(dir.path()); + + let (code, stdout, _) = run_cli(&["sessions", "list", "--json", "--data-dir", &dir.arg()]); + assert_eq!(code, 0); + let value = parse_json(&stdout); + let sessions = value["data"]["sessions"].as_array().expect("sessions 数组"); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0]["session_id"], sid); + assert_eq!(sessions[0]["exit_code"], 1); + + let (code, stdout, _) = run_cli(&[ + "logs", + &sid, + "--tail", + "1", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0); + let value = parse_json(&stdout); + let events = value["data"]["events"].as_array().expect("events 数组"); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["kind"], "stderr"); + + // 前缀匹配唯一即可(脚本里手打全 id 太长)。 + let (code, stdout, _) = run_cli(&["diag", "20261030", "--json", "--data-dir", &dir.arg()]); + assert_eq!(code, 0, "{stdout}"); + let value = parse_json(&stdout); + assert_eq!(value["data"]["session_id"], sid); + let candidates = value["data"]["diagnosis"]["candidates"] + .as_array() + .expect("candidates 数组"); + assert!(!candidates.is_empty(), "OOM 日志应命中规则:{stdout}"); + assert_eq!(value["data"]["diagnosis"]["needs_more_info"], false); +} + +#[test] +fn logs_json_payload_is_redacted_like_human_output() { + // P2-1:`--json` 是机器消费路径,不该比人类输出拿到更多原文(主目录用户名段)。 + let dir = TempDir::new("logs-redact"); + let sid = write_session(dir.path()); + let events = dir + .path() + .join(".ULL") + .join("data") + .join("agent") + .join("sessions") + .join("game") + .join(format!("{sid}.jsonl")); + let extra = serde_json::json!({ + "v": 1, "seq": 9, "ts": "2026-10-30T10:15:07Z", "t_ms": 6000, + "kind": "stdout", "session_id": sid, + "payload": { "line": r"cat C:\Users\Bob\secret.txt failed", "level": "info" } + }); + let mut text = std::fs::read_to_string(&events).expect("读事件流"); + text.push_str(&serde_json::to_string(&extra).expect("序列化事件")); + text.push('\n'); + std::fs::write(&events, text).expect("写事件流"); + + let (code, stdout, stderr) = run_cli(&["logs", &sid, "--json", "--data-dir", &dir.arg()]); + assert_eq!(code, 0, "{stderr}"); + assert!(!stdout.contains("Bob"), "JSON 载荷泄出用户名:{stdout}"); + assert!(stdout.contains("***"), "JSON 载荷未脱敏:{stdout}"); +} + +#[test] +fn config_get_set_round_trip() { + let dir = TempDir::new("config"); + let data_dir = dir.arg(); + + let (code, stdout, _) = run_cli(&[ + "config", + "get", + "ui.language", + "--json", + "--data-dir", + &data_dir, + ]); + assert_eq!(code, 0); + let value = parse_json(&stdout); + assert_eq!(value["data"]["key"], "ui.language"); + assert_eq!(value["data"]["value"], "zh_cn"); + + let (code, stdout, _) = run_cli(&[ + "config", + "set", + "ui.language", + "en_us", + "--json", + "--data-dir", + &data_dir, + ]); + assert_eq!(code, 0, "{stdout}"); + let value = parse_json(&stdout); + assert_eq!(value["data"]["old"], "zh_cn"); + assert_eq!(value["data"]["new"], "en_us"); + + let (_, stdout, _) = run_cli(&[ + "config", + "get", + "ui.language", + "--json", + "--data-dir", + &data_dir, + ]); + assert_eq!(parse_json(&stdout)["data"]["value"], "en_us"); +} + +#[test] +fn error_envelope_shape() { + let dir = TempDir::new("errors"); + let (code, stdout, _) = run_cli(&["launch", "nope", "--json", "--data-dir", &dir.arg()]); + assert_eq!(code, 1); + let value = parse_json(&stdout); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "not_found"); + assert_eq!(value["error"]["kind"], "user"); + assert!(value["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("nope")); + assert!(value["error"]["hint"].is_string()); +} diff --git a/crates/cli/tests/parse_test.rs b/crates/cli/tests/parse_test.rs new file mode 100644 index 0000000..62a17cb --- /dev/null +++ b/crates/cli/tests/parse_test.rs @@ -0,0 +1,338 @@ +//! 子命令解析:手写解析器的语法契约。 + +#[path = "support/mod.rs"] +mod support; + +use unlucky_cli::cli::{parse, AccountsAddKind, Command, Globals}; +use unlucky_cli::error::ErrorKind; + +fn invoke(args: &[&str]) -> unlucky_cli::cli::Invocation { + let owned: Vec = args.iter().map(|arg| arg.to_string()).collect(); + parse(&owned).unwrap_or_else(|error| panic!("解析失败:{error}")) +} + +#[test] +fn no_args_is_welcome() { + let invocation = invoke(&[]); + assert_eq!(invocation.command, Command::Welcome); + assert_eq!(invocation.globals, Globals::default()); +} + +#[test] +fn version_flags() { + assert_eq!(invoke(&["--version"]).command, Command::Version); + assert_eq!(invoke(&["-V"]).command, Command::Version); + assert_eq!(invoke(&["version"]).command, Command::Version); +} + +#[test] +fn help_takes_topic_from_positional() { + assert_eq!(invoke(&["--help"]).command, Command::Help(None)); + assert_eq!( + invoke(&["--help", "launch"]).command, + Command::Help(Some("launch".to_string())) + ); + assert_eq!( + invoke(&["help", "config"]).command, + Command::Help(Some("config".to_string())) + ); +} + +#[test] +fn globals_are_position_independent() { + let invocation = invoke(&["instances", "list", "--json", "--quiet"]); + assert_eq!(invocation.command, Command::InstancesList); + assert!(invocation.globals.json); + assert!(invocation.globals.quiet); + + let before = invoke(&["--json", "instances", "list"]); + assert!(before.globals.json); + assert_eq!(before.command, Command::InstancesList); +} + +#[test] +fn data_dir_accepts_both_forms() { + assert_eq!( + invoke(&["--data-dir", "/tmp/ull", "instances", "list"]) + .globals + .data_dir, + Some(std::path::PathBuf::from("/tmp/ull")) + ); + assert_eq!( + invoke(&["instances", "list", "--data-dir=/tmp/ull"]) + .globals + .data_dir, + Some(std::path::PathBuf::from("/tmp/ull")) + ); +} + +#[test] +fn install_flags() { + let Command::Install(args) = + invoke(&["install", "1.20.1", "--loader", "vanilla", "--name=x"]).command + else { + panic!("应为 install"); + }; + assert_eq!(args.version, "1.20.1"); + assert_eq!(args.loader.as_deref(), Some("vanilla")); + assert_eq!(args.name.as_deref(), Some("x")); + assert!(!args.dry_run); + assert!(args.progress, "人类模式默认打实时进度"); +} + +#[test] +fn install_loader_version_flag() { + let Command::Install(args) = invoke(&[ + "install", + "1.20.1", + "--loader", + "fabric", + "--loader-version", + "0.15.11", + ]) + .command + else { + panic!("应为 install"); + }; + assert_eq!(args.loader.as_deref(), Some("fabric")); + assert_eq!(args.loader_version.as_deref(), Some("0.15.11")); + // 不带该 flag 时为 None(由 core 取官方默认版本)。 + let Command::Install(default) = invoke(&["install", "1.20.1"]).command else { + panic!("应为 install"); + }; + assert!(default.loader_version.is_none()); +} + +#[test] +fn install_dry_run_flag_and_progress_suppression() { + let Command::Install(args) = invoke(&["install", "1.20.1", "--dry-run"]).command else { + panic!("应为 install"); + }; + assert!(args.dry_run); + assert!(args.progress); + assert!(!args.json_progress, "人类模式打文本进度"); + + // `--json`:stderr 换成 JSONL 进度(stdout 仍是单个信封)。 + let Command::Install(args) = invoke(&["--json", "install", "1.20.1"]).command else { + panic!("应为 install"); + }; + assert!(args.progress); + assert!(args.json_progress); + + // `--quiet`:一律不打进度。 + let Command::Install(args) = invoke(&["install", "1.20.1", "-q"]).command else { + panic!("应为 install"); + }; + assert!(!args.progress); + assert!(!args.dry_run); +} + +#[test] +fn launch_flags_and_defaults() { + let Command::Launch(args) = invoke(&["launch", "生存服"]).command else { + panic!("应为 launch"); + }; + assert_eq!(args.instance, "生存服"); + assert!(!args.dry_run); + assert_eq!(args.java, None); + assert_eq!(args.max_memory, None); + + let Command::Launch(args) = invoke(&[ + "launch", + "生存服", + "--dry-run", + "--java", + "/opt/jdk/bin/java", + "--username", + "Steve", + "--max-memory", + "4096", + ]) + .command + else { + panic!("应为 launch"); + }; + assert!(args.dry_run); + assert_eq!(args.username.as_deref(), Some("Steve")); + assert_eq!(args.max_memory, Some(4096)); +} + +#[test] +fn accounts_subcommands() { + assert_eq!(invoke(&["accounts"]).command, Command::AccountsList); + assert_eq!(invoke(&["accounts", "list"]).command, Command::AccountsList); + + let Command::AccountsAdd(args) = invoke(&["accounts", "add", "offline", "Steve"]).command + else { + panic!("应为 accounts add offline"); + }; + assert_eq!(args.kind, AccountsAddKind::Offline); + assert_eq!(args.name.as_deref(), Some("Steve")); + + let Command::AccountsAdd(args) = invoke(&["accounts", "add", "msa"]).command else { + panic!("应为 accounts add msa"); + }; + assert_eq!(args.kind, AccountsAddKind::Microsoft); + assert_eq!(args.name, None); + + let Command::AccountsRemove { name } = invoke(&["accounts", "remove", "Steve"]).command else { + panic!("应为 accounts remove"); + }; + assert_eq!(name, "Steve"); +} + +#[test] +fn launch_account_flag() { + let Command::Launch(args) = invoke(&["launch", "生存服", "--account", "Steve"]).command + else { + panic!("应为 launch"); + }; + assert_eq!(args.account.as_deref(), Some("Steve")); + assert_eq!(args.username, None); +} + +#[test] +fn logs_and_diag_default_tails() { + let Command::Logs(args) = invoke(&["logs", "abc"]).command else { + panic!("应为 logs"); + }; + assert_eq!(args.tail, 200); + + let Command::Diag(args) = invoke(&["diag", "abc", "--tail", "10"]).command else { + panic!("应为 diag"); + }; + assert_eq!(args.tail, 10); +} + +#[test] +fn config_get_set_forms() { + let Command::ConfigGet(args) = invoke(&["config", "get"]).command else { + panic!("应为 config get"); + }; + assert_eq!(args.key, None); + assert!(!args.show_secret); + + let Command::ConfigGet(args) = + invoke(&["config", "get", "ui.language", "--show-secret"]).command + else { + panic!("应为 config get"); + }; + assert_eq!(args.key.as_deref(), Some("ui.language")); + assert!(args.show_secret); + + let Command::ConfigSet(args) = invoke(&["config", "set", "ui.language", "en_us"]).command + else { + panic!("应为 config set"); + }; + assert_eq!(args.key, "ui.language"); + assert_eq!(args.value, "en_us"); +} + +#[test] +fn java_subcommands() { + assert_eq!(invoke(&["java"]).command, Command::JavaList); + assert_eq!(invoke(&["java", "list"]).command, Command::JavaList); + assert_eq!( + invoke(&["java", "install", "21"]).command, + Command::JavaInstall { + major: 21, + vendor: None + } + ); + // D23:`--vendor` 钉死发行版(别名也认),`java vendors` 列出四家。 + assert_eq!( + invoke(&["java", "install", "21", "--vendor", "oracle"]).command, + Command::JavaInstall { + major: 21, + vendor: Some(unlucky_core::game::java::JdkVendor::Oracle), + } + ); + assert_eq!( + invoke(&["java", "install", "17", "--vendor", "adoptium"]).command, + Command::JavaInstall { + major: 17, + vendor: Some(unlucky_core::game::java::JdkVendor::Temurin), + } + ); + assert_eq!(invoke(&["java", "vendors"]).command, Command::JavaVendors); +} + +#[test] +fn usage_errors_are_user_errors() { + let cases: &[&[&str]] = &[ + &["frobnicate"], + &["install"], + &["launch"], + &["launch", "a", "b"], + &["instances", "list", "--nope"], + &["install", "1.20.1", "--dry-run=1"], + &["config", "set", "only-key"], + &["java", "install", "abc"], + &["--data-dir"], + &["sessions", "watch"], + &["accounts", "add"], + &["accounts", "add", "offline"], + &["accounts", "add", "authlib"], + &["accounts", "remove"], + &["accounts", "frobnicate"], + ]; + for args in cases { + let owned: Vec = args.iter().map(|arg| arg.to_string()).collect(); + let error = parse(&owned).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::User, "args={args:?}"); + assert_eq!(error.exit_code(), 1, "args={args:?}"); + } +} + +#[test] +fn value_flags_do_not_swallow_the_next_flag() { + // P2-2:漏了值的写法必须在解析期报用法错误,而不是把 `--dry-run` 当成 Java 路径。 + let cases: &[&[&str]] = &[ + &["launch", "x", "--java", "--dry-run"], + &["launch", "x", "--username", "--json"], + &["install", "1.20.1", "--name", "--loader", "fabric"], + &["install", "1.20.1", "--loader", "--name", "x"], + &["logs", "abc", "--tail", "--json"], + &["--data-dir", "--json", "instances", "list"], + ]; + for args in cases { + let owned: Vec = args.iter().map(|arg| arg.to_string()).collect(); + let error = parse(&owned).expect_err(&format!("args={args:?} 应当报用法错误")); + assert_eq!(error.code(), "usage", "args={args:?}"); + assert!( + error.message().contains("需要一个值"), + "args={args:?} message={}", + error.message() + ); + } + + // 负数值与正常值不受影响(只拒 `--` 开头的 token)。 + let Command::Launch(args) = invoke(&["launch", "x", "--java", "/opt/jdk/bin/java"]).command + else { + panic!("应为 launch"); + }; + assert_eq!( + args.java.as_deref(), + Some(std::path::Path::new("/opt/jdk/bin/java")) + ); +} + +#[test] +fn help_text_covers_every_command() { + for topic in [ + "instances", + "install", + "launch", + "sessions", + "logs", + "diag", + "config", + "java", + ] { + assert!( + unlucky_cli::cli::help::topic(topic).is_some(), + "缺少 {topic} 的帮助" + ); + } + assert!(unlucky_cli::cli::help::topic("nope").is_none()); +} diff --git a/crates/cli/tests/support/mod.rs b/crates/cli/tests/support/mod.rs new file mode 100644 index 0000000..a14e011 --- /dev/null +++ b/crates/cli/tests/support/mod.rs @@ -0,0 +1,159 @@ +//! 集成测试共用夹具:临时数据根、命令执行器、最小版本 JSON / 会话索引。 +//! +//! 每个测试目标都会整份编译本文件但只用其中一部分,因此这里放开 dead_code。 +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// 一次性临时目录(`Drop` 时清理)。 +pub struct TempDir { + path: PathBuf, +} + +impl TempDir { + /// 建一个唯一目录。 + pub fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "ull-cli-test-{tag}-{}-{seq}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("创建临时目录"); + Self { path } + } + + /// 目录路径。 + pub fn path(&self) -> &Path { + &self.path + } + + /// 目录的字符串形式(喂给 `--data-dir`)。 + pub fn arg(&self) -> String { + self.path.display().to_string() + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +/// 跑一次 CLI,返回(退出码, stdout, stderr)。 +pub fn run_cli(args: &[&str]) -> (i32, String, String) { + let owned: Vec = args.iter().map(|arg| arg.to_string()).collect(); + let mut out: Vec = Vec::new(); + let mut err: Vec = Vec::new(); + let code = unlucky_cli::run(&owned, &mut out, &mut err); + ( + code, + String::from_utf8_lossy(&out).into_owned(), + String::from_utf8_lossy(&err).into_owned(), + ) +} + +/// 解析 `--json` 输出(断言它是合法 JSON 并返回)。 +pub fn parse_json(stdout: &str) -> serde_json::Value { + serde_json::from_str(stdout).unwrap_or_else(|error| panic!("输出不是 JSON:{error}\n{stdout}")) +} + +/// 写一份最小可组装的新版版本 JSON + 空 client jar。 +pub fn write_version(data_root: &Path, id: &str) { + let dir = data_root.join(".minecraft").join("versions").join(id); + std::fs::create_dir_all(&dir).expect("创建版本目录"); + let version = serde_json::json!({ + "id": id, + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "assets": "5", + "assetIndex": { "id": "5" }, + "arguments": { + "game": ["--username", "${auth_player_name}", "--version", "${version_name}"], + "jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"] + }, + "libraries": [], + "downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } } + }); + std::fs::write( + dir.join(format!("{id}.json")), + serde_json::to_vec_pretty(&version).expect("序列化版本 JSON"), + ) + .expect("写版本 JSON"); + std::fs::write(dir.join(format!("{id}.jar")), b"junk").expect("写 client jar"); +} + +/// 写 `config/profiles.json`(实例名 → 版本 id)。 +pub fn write_profiles(data_root: &Path, profiles: &[(&str, &str)]) { + let dir = data_root.join(".ULL").join("config"); + std::fs::create_dir_all(&dir).expect("创建配置目录"); + let entries: Vec = profiles + .iter() + .enumerate() + .map(|(index, (name, version))| { + serde_json::json!({ + "id": format!("11111111-2222-4333-8444-{:012}", index + 1), + "name": name, + "version_id": version, + }) + }) + .collect(); + let document = serde_json::json!({ "schema_version": 1, "profiles": entries }); + std::fs::write( + dir.join("profiles.json"), + serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"), + ) + .expect("写 profiles.json"); +} + +/// 写一份「退出码 1 + OOM」的会话索引与事件流,返回会话 id。 +pub fn write_session(data_root: &Path) -> String { + let sid = "20261030T101500-abcdef"; + let dir = data_root + .join(".ULL") + .join("data") + .join("agent") + .join("sessions") + .join("game"); + std::fs::create_dir_all(&dir).expect("创建会话目录"); + let events = [ + serde_json::json!({"v":1,"seq":0,"ts":"2026-10-30T10:15:00Z","t_ms":0,"kind":"session_start","session_id":sid,"payload":{"instance_name":"生存服","game_version":"1.20.1"}}), + serde_json::json!({"v":1,"seq":1,"ts":"2026-10-30T10:15:02Z","t_ms":1500,"kind":"stdout","session_id":sid,"payload":{"line":"[main/INFO]: Loading Minecraft 1.20.1","level":"info"}}), + serde_json::json!({"v":1,"seq":2,"ts":"2026-10-30T10:15:05Z","t_ms":4200,"kind":"stderr","session_id":sid,"payload":{"line":"java.lang.OutOfMemoryError: Java heap space","level":"error"}}), + ]; + let mut text = String::new(); + for event in events { + text.push_str(&serde_json::to_string(&event).expect("序列化事件")); + text.push('\n'); + } + std::fs::write(dir.join(format!("{sid}.jsonl")), text).expect("写事件流"); + let entry = serde_json::json!({ + "v": 1, + "session_id": sid, + "instance_id": "11111111-2222-4333-8444-555555555555", + "instance_name": "生存服", + "game_version": "1.20.1", + "started_at": "2026-10-30T10:15:00Z", + "ended_at": "2026-10-30T10:15:06Z", + "duration_ms": 5000, + "exit_code": 1, + "exit_type": "application_error", + "silent_failure": false, + "window_detected": false, + "event_count": 3, + "log_path": format!("{sid}.jsonl"), + "crash_found": false, + "diagnosis_top": null + }); + std::fs::write( + dir.join("index.jsonl"), + format!("{}\n", serde_json::to_string(&entry).expect("序列化索引")), + ) + .expect("写会话索引"); + sid.to_string() +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..d39441e --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,74 @@ +[package] +name = "unlucky-core" +description = "Unlucky-Launcher 领域库:纯逻辑,零 UI 依赖(D3 架构纪律)" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +publish.workspace = true + +# 零 UI 依赖是硬约束:这里只允许纯逻辑/协议/IO 类依赖(serde/tokio/reqwest 等后续按模块加), +# 任何时候都不得出现 slint / egui / iced / winit / gtk / javafx —— CI 的 core-no-ui job 守门。 +[dependencies] +# M15(core::diagnose)预接线:59 条规则正则、规则 YAML 解析、诊断输出序列化。 +# 真正使用方是 crates/core/src/diagnose/ 下的 collectors/engine/hs_err/loader/rules(M15 落地)。 +regex.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml_ng.workspace = true + +# M13(core::download)预接线:HTTP / 异步运行时 / 流 / 哈希校验。 +reqwest.workspace = true +tokio.workspace = true +futures-util.workspace = true +sha1.workspace = true +sha2.workspace = true +zip.workspace = true +# M31(core::auth):离线 UUID = MD5 v3(与 HMCL / 服务端 nameUUIDFromBytes 对拍)。 +md-5.workspace = true +# M33(core::game::java)预接线:tar.gz 解压(Temurin 的 Linux/macOS 产物)。 +flate2.workspace = true +tar.workspace = true + +# M13 的 8 个下载测试 target(文件由 M13 阶段 B 用真实用例覆盖;脚手架期是占位)。 +# `tests/download/support/mod.rs` 是被各 test include 的公共夹具,不单独注册。 +[[test]] +name = "backoff" +path = "tests/download/backoff_test.rs" + +[[test]] +name = "health" +path = "tests/download/health_test.rs" + +[[test]] +name = "mirrors" +path = "tests/download/mirrors_test.rs" + +[[test]] +name = "region" +path = "tests/download/region_test.rs" + +[[test]] +name = "cache" +path = "tests/download/cache_test.rs" + +[[test]] +name = "chunked" +path = "tests/download/chunked_test.rs" + +[[test]] +name = "resume" +path = "tests/download/resume_test.rs" + +[[test]] +name = "engine_mock" +path = "tests/download/engine_mock_test.rs" + +[[test]] +name = "partfile" +path = "tests/download/partfile_test.rs" + +[lints] +workspace = true diff --git a/crates/core/src/addon/loader/fabric.rs b/crates/core/src/addon/loader/fabric.rs new file mode 100644 index 0000000..ec28c57 --- /dev/null +++ b/crates/core/src/addon/loader/fabric.rs @@ -0,0 +1,247 @@ +//! Fabric 系(Fabric / Quilt):官方 meta → loader 版本 JSON。 +//! +//! 依据 `DevDocs/analyse/05-modloader-modpack.md` §1.4:这两个加载器**没有 installer**, +//! `meta.fabricmc.net` / `meta.quiltmc.org` 直接给 launcher 可用的 profile JSON, +//! 所以安装 = "把 profile 收成我们的版本 JSON + 补库",没有任何子进程。 +//! +//! 本文件是**纯函数**(不进网络):URL 拼装与 JSON 归一化分开写,测试直接喂离线夹具。 +//! +//! # 归一化做了三件容易被上游坑的事 +//! +//! 1. **`jar` 钉成原版 id**:profile 通常不写 `jar`,而 `game::inherit::resolve` 在缺 `jar` +//! 时会把 `jar` 兜底成**自身 id**——那会让启动去找 `versions//.jar` +//! (不存在)。这里显式写 `jar = <原版 id>`,让继承链正确地回到原版 jar。 +//! 2. **`mainClass` 断言**:缺主类就不是一份能启动的清单,宁可在安装期报 metadata, +//! 也不要装出一个起不来的实例(HMCL 同款防御,analyse/05 §1.4)。 +//! 3. **扁平 `sha1`/`size` 提升成 `downloads.artifact`**:Fabric profile 的库把摘要写在 +//! 库对象顶层,M27 的库展开只认 `downloads.artifact`。不提上来的后果是"没有校验值 → +//! 存在即信",重装时无法识别被截断的库文件。 + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::plan::{LoaderRelease, LoaderSupport}; +use super::{LoaderError, LoaderKind}; +use crate::game::library::{Library, LibraryDownloads}; +use crate::game::manifest::{DownloadInfo, VersionJson}; + +/// Fabric 官方 meta 根(BMCLAPI 有对应镜像规则 `bmclapi-fabric-meta`)。 +pub const FABRIC_META: &str = "https://meta.fabricmc.net/v2"; +/// Quilt 官方 meta 根(同上,`bmclapi-quilt-meta`)。 +pub const QUILT_META: &str = "https://meta.quiltmc.org/v3"; + +/// meta 根地址(按加载器)。 +#[must_use] +pub fn meta_base(kind: LoaderKind) -> &'static str { + match kind { + LoaderKind::Quilt => QUILT_META, + _ => FABRIC_META, + } +} + +/// 该原版版本可用的加载器版本列表地址。 +#[must_use] +pub fn loader_list_url(kind: LoaderKind, mc_version: &str) -> String { + format!("{}/versions/loader/{mc_version}", meta_base(kind)) +} + +/// 指定加载器版本的 launcher profile 地址(官方直出可用的版本 JSON)。 +#[must_use] +pub fn profile_url(kind: LoaderKind, mc_version: &str, loader_version: &str) -> String { + format!( + "{}/versions/loader/{mc_version}/{loader_version}/profile/json", + meta_base(kind) + ) +} + +/// 列表里的一个加载器版本。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LoaderEntry { + /// 版本号(`0.15.11`)。 + #[serde(default)] + pub version: String, + /// 是否稳定版(Quilt 的列表不带这个键,缺省按稳定处理)。 + #[serde(default = "default_true")] + pub stable: bool, + /// Maven 坐标(`net.fabricmc:fabric-loader:0.15.11`)。 + #[serde(default)] + pub maven: String, +} + +const fn default_true() -> bool { + true +} + +/// 列表响应的一行(`[{ "loader": {…}, "intermediary": {…}, … }]`)。 +#[derive(Debug, Deserialize)] +struct ListRow { + #[serde(default)] + loader: Option, +} + +/// 解析版本列表(最新的排在前,与官方顺序一致——**不重排**)。 +/// +/// # Errors +/// +/// JSON 非法、或列表形状完全不符(顶层不是数组 / 一条都认不出)时返回 metadata 错误。 +pub fn parse_loader_list(kind: LoaderKind, json: &str) -> Result, LoaderError> { + let rows: Vec = serde_json::from_str(json).map_err(|error| { + LoaderError::metadata(format!("{} 版本列表解析失败:{error}", kind.label())) + })?; + let entries: Vec = rows + .into_iter() + .filter_map(|row| row.loader) + .filter(|entry| !entry.version.is_empty()) + .collect(); + if entries.is_empty() { + return Err(LoaderError::not_found(format!( + "{} 版本列表为空(该原版版本可能还没有对应加载器)", + kind.label() + ))); + } + Ok(entries) +} + +/// 从列表里挑一个默认版本:优先第一个稳定版,否则取第一个(列表最新在前)。 +#[must_use] +pub fn pick_default(entries: &[LoaderEntry]) -> Option<&LoaderEntry> { + entries + .iter() + .find(|entry| entry.stable) + .or_else(|| entries.first()) +} + +/// 把官方 profile JSON 归一化成 `--` 的 loader 版本 JSON。 +/// +/// # Errors +/// +/// profile 不是合法版本 JSON、缺主类时返回 metadata 错误。 +pub fn build_release( + kind: LoaderKind, + mc_version: &str, + loader_version: &str, + profile_json: &str, +) -> Result { + let mut version_json = VersionJson::from_json_str(profile_json).map_err(|error| { + LoaderError::metadata(format!( + "{} {loader_version} 的 profile JSON 解析失败:{error}", + kind.label() + )) + })?; + let mut warnings = Vec::new(); + + let version_id = kind.version_id(mc_version, loader_version); + version_json.id = Some(version_id.clone()); + version_json.name = Some(format!("{mc_version} · {} {loader_version}", kind.label())); + version_json.inherits_from = Some(mc_version.to_string()); + // 启动用原版 jar(见模块文档第 1 条)。 + version_json.jar = Some(mc_version.to_string()); + + if version_json + .main_class + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + return Err(LoaderError::metadata(format!( + "{} {loader_version} 的 profile 没有 mainClass:这份清单装上去也起不来", + kind.label() + )) + .with_hint("换一个加载器版本,或用官方启动器核对")); + } + + let libraries = version_json.libraries.get_or_insert_with(Vec::new); + for library in libraries.iter_mut() { + lift_flat_digest(library); + } + ensure_loader_library(kind, loader_version, libraries, &mut warnings); + + Ok(LoaderRelease { + kind, + mc_version: mc_version.to_string(), + loader_version: loader_version.to_string(), + version_id, + version_json, + support: LoaderSupport::JsonRoute, + installer: None, + install_profile: None, + warnings, + }) +} + +/// 把库对象顶层的 `sha1`/`size` 提升成 `downloads.artifact`(Fabric profile 的写法)。 +fn lift_flat_digest(library: &mut Library) { + if library.downloads.is_some() { + return; + } + let Some(base) = library + .url + .as_deref() + .filter(|url| !url.is_empty()) + .map(str::to_string) + else { + return; + }; + let Ok(relative) = library.relative_path() else { + return; + }; + let sha1 = library + .extra + .get("sha1") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let size = library + .extra + .get("size") + .and_then(serde_json::Value::as_u64); + let path = relative.to_string_lossy().replace('\\', "/"); + let url = if base.ends_with('/') { + format!("{base}{path}") + } else { + format!("{base}/{path}") + }; + library.downloads = Some(LibraryDownloads { + artifact: Some(DownloadInfo { + id: None, + sha1, + size, + url: Some(url), + path: Some(path), + }), + classifiers: BTreeMap::new(), + }); +} + +/// loader 本体库必须在列表里:缺了就补一条(并用告警说明),别装出个"没有 loader"的实例。 +fn ensure_loader_library( + kind: LoaderKind, + loader_version: &str, + libraries: &mut Vec, + warnings: &mut Vec, +) { + let (group, artifact) = match kind { + LoaderKind::Quilt => ("org.quiltmc", "quilt-loader"), + _ => ("net.fabricmc", "fabric-loader"), + }; + let present = libraries.iter().any(|library| { + library + .coordinate() + .is_ok_and(|coordinate| coordinate.group == group && coordinate.artifact == artifact) + }); + if present { + return; + } + let maven_base = match kind { + LoaderKind::Quilt => "https://maven.quiltmc.org/repository/release/", + _ => "https://maven.fabricmc.net/", + }; + libraries.push(Library { + name: format!("{group}:{artifact}:{loader_version}"), + url: Some(maven_base.to_string()), + ..Library::default() + }); + warnings.push(format!( + "{} {loader_version} 的 profile 没带 {group}:{artifact}:已按 maven 坐标补一条(缺库会让游戏起不来)", + kind.label() + )); +} diff --git a/crates/core/src/addon/loader/forge.rs b/crates/core/src/addon/loader/forge.rs new file mode 100644 index 0000000..76c0ee5 --- /dev/null +++ b/crates/core/src/addon/loader/forge.rs @@ -0,0 +1,280 @@ +//! NeoForge / Forge:installer 内的 install profile → loader 版本 JSON。 +//! +//! 依据 `DevDocs/analyse/05-modloader-modpack.md` §1.2/§1.3:Forge 有两代 installer 协议, +//! NeoForge 是 fork(早期与 Forge 完全同构)。判据也在那儿:install profile 有没有 +//! `spec` / processors、有没有独立的 `version.json`。maven 坐标与版本表在 [`super::maven`]。 +//! +//! # 本模块的取舍(为什么只做一部分) +//! +//! 这两家都**不发**launcher 直接可用的 profile,只发 installer jar;能纯 json 化到什么程度 +//! 取决于 installer 声明的处理器: +//! +//! | install profile 形状 | 实测样例 | 本模块结论 | +//! |----------------------|----------|-----------| +//! | `spec: 1` + 有 client 侧 processors | Forge 1.20.1-47.2.0、NeoForge 21.1.80 | [`LoaderSupport::RequiresInstaller`] | +//! | `spec: 0` + 无 processors + 内嵌 `version.json` | Forge 1.12.2-14.23.5.2860 | [`LoaderSupport::JsonRoute`] | +//! | 只有内联 `versionInfo`(+ `install.filePath` 解包) | 1.12.2 之前的老重建版 | 明确 unsupported | +//! +//! 第一类必须跑 `java -cp … installertools …` 才能产出 `client-slim/extra` 等打补丁产物 +//! (processor 的 `data` 变量表里有 `MC_SLIM`/`MC_EXTRA`/`PATCHED`),解析结果因此带上 +//! [`LoaderSupport::RequiresInstaller`] 标记 + install profile 本体,交给 +//! [`super::processors`](M35 执行器)真跑;这里的**判定**仍只管「要不要跑 installer」。 +//! +//! 第二类的本体 jar 在 version.json 里 `url` 是空串,且**官方 maven 只发布 `-universal` +//! classifier**(无 classifier 路径官方 404,只有 BMCLAPI 的 302 别名兜底)—— +//! 补救见 [`fill_missing_urls`],URL 带 classifier、磁盘落点保持官方 installer 的原样。 +//! +//! # 一条容易踩的坑:processor 的库不进启动 classpath +//! +//! install profile 自带的 `libraries` 是**processor 依赖**(installertools/srgutils…), +//! 它们不属于游戏 classpath。把它们并进 loader 版本 JSON 会让启动多加载一堆无关 jar, +//! 因此这里只记数量进告警、绝不合并;真下载它们的是 [`super::processors`]。 + +use std::collections::BTreeMap; + +use serde::Deserialize; + +use super::maven; +use super::plan::{LoaderRelease, LoaderSupport}; +use super::{LoaderError, LoaderKind}; +use crate::game::library::Library; +use crate::game::manifest::VersionJson; + +pub use super::processors::spec::{Datum, Processor}; + +/// 老协议 installer 的 `install` 段(`filePath` 指向内嵌的 universal jar)。 +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyInstall { + /// 内嵌 jar 在 installer 里的相对路径。 + #[serde(default)] + pub file_path: Option, +} + +/// installer 里的 `install_profile.json`(只解析我们需要的键,其余忽略)。 +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallProfile { + /// 协议代号(`1` = processor 时代)。 + #[serde(default)] + pub spec: Option, + /// 组件名(`forge` / `NeoForge`)。 + #[serde(default)] + pub profile: Option, + /// installer 自己的版本串(`1.20.1-forge-47.2.0`)。 + #[serde(default)] + pub version: Option, + /// 目标原版版本。 + #[serde(default)] + pub minecraft: Option, + /// 内嵌版本 JSON 在 installer 里的路径(通常是 `/version.json`)。 + #[serde(default, rename = "json")] + pub version_json_path: Option, + /// universal jar 的 maven 坐标(`net.minecraftforge:forge:…`,spec 0 才有)。 + #[serde(default)] + pub path: Option, + /// processor 列表。 + #[serde(default)] + pub processors: Vec, + /// processor 的变量表(`{MAPPINGS}` / `{MC_SLIM}` 之类占位的数据源)。 + #[serde(default)] + pub data: BTreeMap, + /// processor / universal jar 依赖(**不是**启动 classpath,别合并)。 + #[serde(default)] + pub libraries: Vec, + /// 老协议:内嵌 jar 解包信息。 + #[serde(default)] + pub install: Option, + /// 老协议:内联的版本 JSON 片段。 + #[serde(default, rename = "versionInfo")] + pub version_info: Option, +} + +impl InstallProfile { + /// client 侧 processor 数量。 + #[must_use] + pub fn client_processors(&self) -> usize { + self.processors + .iter() + .filter(|processor| processor.applies_to_client()) + .count() + } + + /// 是否是老协议 installer(内联 `versionInfo` + 解包 universal jar)。 + #[must_use] + pub fn is_legacy_protocol(&self) -> bool { + self.version_json_path.is_none() && (self.install.is_some() || self.version_info.is_some()) + } +} + +/// 用 install profile + 内嵌版本 JSON 组 loader 版本。 +/// +/// `embedded` 是 installer 内 `version.json` 的内容(读不到就是 `None`)。 +/// +/// # Errors +/// +/// 原版版本对不上、installer 里没有 `version.json`、生成的清单缺主类时返回错误。 +pub fn build_release( + kind: LoaderKind, + mc_version: &str, + maven_version: &str, + profile: &InstallProfile, + embedded: Option<&VersionJson>, +) -> Result { + if let Some(declared) = profile.minecraft.as_deref() { + if declared != mc_version { + return Err(LoaderError::metadata(format!( + "installer 声明的原版是 {declared},与目标的 {mc_version} 不符(选错了加载器版本)" + )) + .with_hint("`--loader-version` 要和 `install ` 的原版版本配套")); + } + } + let loader_version = maven::display_loader_version(kind, mc_version, maven_version); + let version_id = kind.version_id(mc_version, &loader_version); + let mut warnings = Vec::new(); + + let Some(embedded) = embedded else { + let reason = if profile.is_legacy_protocol() { + format!( + "{} {} 走老协议 installer(内联 versionInfo + 解包 universal jar),本期不做解包路线", + kind.label(), + loader_version + ) + } else { + format!( + "{} {} 的 installer 里没有可读的 version.json", + kind.label(), + loader_version + ) + }; + return Err(LoaderError::unsupported(reason).with_hint( + "这段协议要动 installer 内的 jar,属后续 mission;可先用官方启动器装好该加载器", + )); + }; + + let mut version_json = embedded.clone(); + let mut support = LoaderSupport::JsonRoute; + let client_processors = profile.client_processors(); + if client_processors > 0 { + support = LoaderSupport::RequiresInstaller { + reason: format!( + "{} {} 的 installer 声明了 {client_processors} 个 client 侧 processor(拆包/映射/打补丁),必须先外调 java 执行", + kind.label(), + loader_version + ), + }; + warnings.push(format!( + "install profile 里另有 {} 个 processor 依赖(installertools 等):它们不进启动 classpath,只在安装期给执行器用", + profile.libraries.len() + )); + } + + version_json.id = Some(version_id.clone()); + version_json.name = Some(format!("{mc_version} · {} {loader_version}", kind.label())); + version_json.inherits_from = Some(mc_version.to_string()); + version_json.jar = Some(mc_version.to_string()); + + if version_json + .main_class + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + return Err(LoaderError::metadata(format!( + "{} {} 的 version.json 没有 mainClass", + kind.label(), + loader_version + ))); + } + + let libraries = version_json.libraries.get_or_insert_with(Vec::new); + let installer_body = profile.path.as_deref(); + for library in libraries.iter_mut() { + fill_missing_urls(library, maven::base(kind), installer_body); + } + + Ok(LoaderRelease { + kind, + mc_version: mc_version.to_string(), + loader_version, + version_id, + version_json, + support, + installer: None, + install_profile: Some(profile.clone()), + warnings, + }) +} + +/// spec 0 的 version.json 里,forge 本体库的 `url` 常是空串(jar 原本内嵌在 installer 里), +/// 于是"补一个 maven 地址"就能直取,省掉解包 installer。 +/// +/// ⚠️ 本体 jar 的**发布 classifier 是 `universal`**:installer 内嵌的条目叫 +/// `maven/…/forge-.jar`(无 classifier),但官方 maven 只托管 +/// `forge--universal.jar`——无 classifier 的那条路径**官方 404** +/// (实测 1.12.2-14.23.5.2860:`-universal.jar` 200 / 4466148 字节,sha1 与 version.json 一致; +/// 无 classifier 路径仅靠 BMCLAPI 的 302 别名兜底)。 +/// 因此这里**只改 URL**:给 installer 声明的本体构件追加 `-universal`; +/// `artifact.path`(磁盘落点)保持官方 installer 的原样,启动 classpath 与官方布局一致。 +fn fill_missing_urls(library: &mut Library, base: &str, installer_body: Option<&str>) { + if library + .url + .as_deref() + .is_none_or(|url| url.trim().is_empty()) + { + library.url = Some(if base.ends_with('/') { + base.to_string() + } else { + format!("{base}/") + }); + } + // 先算"是不是 installer 声明的本体"(要读 library 的坐标),再拿 artifact 的可变借用。 + let is_body = is_installer_body(library, installer_body); + let root = library.url.clone().unwrap_or_else(|| base.to_string()); + let Some(artifact) = library + .downloads + .as_mut() + .and_then(|downloads| downloads.artifact.as_mut()) + else { + return; + }; + if artifact.has_url() { + return; + } + let Some(path) = artifact.path.clone() else { + return; + }; + let remote_path = if is_body { + with_universal_classifier(&path) + } else { + path + }; + artifact.url = Some(if root.ends_with('/') { + format!("{root}{remote_path}") + } else { + format!("{root}/{remote_path}") + }); +} + +/// 这条库是不是 install profile 的 `path` 声明的本体构件(`group:artifact:version`,无 classifier)。 +fn is_installer_body(library: &Library, installer_body: Option<&str>) -> bool { + let Some(body) = installer_body else { + return false; + }; + let mut parts = body.split(':'); + let (Some(group), Some(artifact)) = (parts.next(), parts.next()) else { + return false; + }; + library.coordinate().is_ok_and(|coordinate| { + coordinate.classifier.is_none() + && coordinate.group == group + && coordinate.artifact == artifact + }) +} + +/// `…/forge-1.12.2-14.23.5.2860.jar` → `…/forge-1.12.2-14.23.5.2860-universal.jar`。 +fn with_universal_classifier(path: &str) -> String { + match path.strip_suffix(".jar") { + Some(stem) => format!("{stem}-universal.jar"), + None => path.to_string(), + } +} diff --git a/crates/core/src/addon/loader/maven.rs b/crates/core/src/addon/loader/maven.rs new file mode 100644 index 0000000..898e2f4 --- /dev/null +++ b/crates/core/src/addon/loader/maven.rs @@ -0,0 +1,173 @@ +//! Maven 坐标与版本表(Forge / NeoForge):版本列表地址、版本选择、installer 地址。 +//! +//! 与 [`super::forge`] 的分工:这里只管"maven 上有什么版本、jar 在哪", +//! 那边只管"installer 里的 profile 怎么变成版本 JSON"。 +//! +//! 镜像:两家 maven 都在 D7 镜像表里有 BMCLAPI 规则(`bmclapi-neoforge` / +//! `bmclapi-forge-maven-net`),所以这里一律**写官方地址**,重写交给 `core::download` +//! 的镜像展开(与 M27 的"不拼镜像 URL"纪律一致)。 + +use super::LoaderKind; + +/// NeoForge maven 根(规则 `bmclapi-neoforge` 把 `/releases` 重写到 `/maven`)。 +pub const NEOFORGE_MAVEN: &str = "https://maven.neoforged.net/releases"; +/// Forge maven 根(规则 `bmclapi-forge-maven-net`)。 +pub const FORGE_MAVEN: &str = "https://maven.minecraftforge.net"; +/// NeoForge 唯一走 Forge 布局的原版版本(1.20.1 是"NeoForge 还叫 forge 的时候")。 +pub const NEOFORGE_FORGE_LAYOUT_MC: &str = "1.20.1"; + +/// maven 根地址。 +#[must_use] +pub fn base(kind: LoaderKind) -> &'static str { + match kind { + LoaderKind::NeoForge => NEOFORGE_MAVEN, + _ => FORGE_MAVEN, + } +} + +/// 该原版版本对应的 `maven-metadata.xml` 地址。 +#[must_use] +pub fn metadata_url(kind: LoaderKind, mc_version: &str) -> String { + let (root, path) = match kind { + LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => { + (NEOFORGE_MAVEN, "net/neoforged/forge") + } + LoaderKind::NeoForge => (NEOFORGE_MAVEN, "net/neoforged/neoforge"), + _ => (FORGE_MAVEN, "net/minecraftforge/forge"), + }; + format!("{root}/{path}/maven-metadata.xml") +} + +/// installer jar 地址。 +#[must_use] +pub fn installer_url(kind: LoaderKind, mc_version: &str, maven_version: &str) -> String { + let (root, path, file) = match kind { + LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => ( + NEOFORGE_MAVEN, + "net/neoforged/forge", + format!("forge-{maven_version}-installer.jar"), + ), + LoaderKind::NeoForge => ( + NEOFORGE_MAVEN, + "net/neoforged/neoforge", + format!("neoforge-{maven_version}-installer.jar"), + ), + _ => ( + FORGE_MAVEN, + "net/minecraftforge/forge", + format!("forge-{maven_version}-installer.jar"), + ), + }; + format!("{root}/{path}/{maven_version}/{file}") +} + +/// 扫 maven-metadata.xml 里的 `` 值。 +/// +/// 故意不引 XML 库:maven 元数据是**极简且稳定**的格式(只有一层 `` 列表), +/// 为它拉一个 XML 解析器不划算。解析不出就返回空表,由上层报"没有可用版本"。 +#[must_use] +pub fn parse_metadata_versions(xml: &str) -> Vec { + const OPEN: &str = ""; + const CLOSE: &str = ""; + let mut out = Vec::new(); + let mut rest = xml; + while let Some(start) = rest.find(OPEN) { + let after = &rest[start + OPEN.len()..]; + let Some(end) = after.find(CLOSE) else { + break; + }; + let value = after[..end].trim(); + if !value.is_empty() { + out.push(value.to_string()); + } + rest = &after[end + CLOSE.len()..]; + } + out +} + +/// NeoForge 版本号 → 原版版本号(`21.1.80` → `1.21.1`,analyse/05 §1.3 的反解规则)。 +/// +/// - 主版本 `0` 是快照版(命名不带原版版本,返回 `None`); +/// - 主版本 `≥26` 是 MC 新命名方案(`26.1.2.125` → `26.1`); +/// - 其余是 `1.<主>.<次>`(`20.4.237` → `1.20.4`)。 +#[must_use] +pub fn neoforge_mc_version(version: &str) -> Option { + let core = version.split(['-', '+']).next().unwrap_or(version); + let segments: Vec = core + .split('.') + .map(str::parse::) + .collect::>() + .ok()?; + let (major, minor) = (*segments.first()?, *segments.get(1)?); + match major { + 0 => None, + 26.. => Some(format!("{major}.{minor}")), + _ => Some(format!("1.{major}.{minor}")), + } +} + +/// maven 版本是否属于该原版版本。 +#[must_use] +pub fn matches_mc(kind: LoaderKind, mc_version: &str, version: &str) -> bool { + match kind { + LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => { + version.starts_with(&format!("{mc_version}-")) + } + LoaderKind::NeoForge => neoforge_mc_version(version).as_deref() == Some(mc_version), + _ => version.starts_with(&format!("{mc_version}-")), + } +} + +/// 从 maven 版本表里挑出属于该原版版本的加载器版本(**升序**,末尾最好)。 +#[must_use] +pub fn select_versions(kind: LoaderKind, mc_version: &str, versions: &[String]) -> Vec { + let mut picked: Vec = versions + .iter() + .filter(|version| matches_mc(kind, mc_version, version)) + .cloned() + .collect(); + picked.sort_by(|left, right| { + numeric_key(left) + .cmp(&numeric_key(right)) + .then_with(|| left.cmp(right)) + }); + picked +} + +/// 用户给的版本号归一成 maven 版本号(Forge 的 maven 版本带原版前缀)。 +/// +/// `--loader-version 47.2.0` 与 `--loader-version 1.20.1-47.2.0` 都接受; +/// NeoForge(非 1.20.1 布局)本身就是完整 maven 版本,原样返回。 +#[must_use] +pub fn normalize_maven_version(kind: LoaderKind, mc_version: &str, raw: &str) -> String { + let raw = raw.trim(); + let needs_prefix = match kind { + LoaderKind::NeoForge => mc_version == NEOFORGE_FORGE_LAYOUT_MC, + _ => true, + }; + if needs_prefix && !raw.starts_with(&format!("{mc_version}-")) { + format!("{mc_version}-{raw}") + } else { + raw.to_string() + } +} + +/// 用户视角的加载器版本号(Forge 去掉原版前缀,NeoForge 原样)。 +#[must_use] +pub fn display_loader_version(kind: LoaderKind, mc_version: &str, maven_version: &str) -> String { + match kind { + LoaderKind::NeoForge if mc_version != NEOFORGE_FORGE_LAYOUT_MC => maven_version.to_string(), + _ => maven_version + .strip_prefix(&format!("{mc_version}-")) + .unwrap_or(maven_version) + .to_string(), + } +} + +/// 版本号 → 可比较的数字键(非数字段按 0,保证不 panic)。 +fn numeric_key(version: &str) -> Vec { + version + .split(['-', '.', '+']) + .map(|segment| segment.parse::().unwrap_or(0)) + .collect() +} diff --git a/crates/core/src/addon/loader/meta.rs b/crates/core/src/addon/loader/meta.rs new file mode 100644 index 0000000..29d0367 --- /dev/null +++ b/crates/core/src/addon/loader/meta.rs @@ -0,0 +1,242 @@ +//! 加载器元数据的网络层:版本列表 + 官方 profile/installer → [`LoaderRelease`]。 +//! +//! 分工与 M27 的 [`crate::game::install::RemoteSource`] 一样:**只发请求、只解析**, +//! 不写游戏目录(installer jar 落 `.ULL/cache/meta/` 作离线复用),镜像/退避/health 全在 +//! `core::download` 的引擎里(我们一律递**官方地址**,重写由镜像表负责)。 +//! +//! 两族的取数路径完全不同: +//! +//! ```text +//! Fabric / Quilt : meta API → profile/json(官方直出可用版本 JSON) +//! NeoForge/Forge : maven-metadata.xml → installer jar → 读 install_profile.json + version.json +//! ``` + +use std::fs::File; +use std::path::{Path, PathBuf}; + +use zip::ZipArchive; + +use super::fabric; +use super::forge::{self, InstallProfile}; +use super::maven; +use super::plan::LoaderRelease; +use super::{LoaderError, LoaderKind}; +use crate::download::{DownloadEngine, DownloadRequest, ResourceKind}; +use crate::game::manifest::VersionJson; + +/// installer jar 的缓存文件名前缀(超长/含 `+` 的版本串会被规整成安全文件名)。 +const INSTALLER_TAG: &str = "installer"; + +/// 远端加载器元数据来源。 +pub struct LoaderSource<'a> { + engine: &'a DownloadEngine, + meta_cache: &'a Path, +} + +impl<'a> LoaderSource<'a> { + /// 绑定下载引擎与元数据缓存目录(`.ULL/cache/meta/`)。 + #[must_use] + pub fn new(engine: &'a DownloadEngine, meta_cache: &'a Path) -> Self { + Self { engine, meta_cache } + } + + /// 取该原版版本可用的加载器版本列表(**最新在前**)。 + /// + /// # Errors + /// + /// 网络失败、元数据解析失败、该原版版本没有加载器版本时返回 [`LoaderError`]。 + pub async fn loader_versions( + &self, + kind: LoaderKind, + mc_version: &str, + ) -> Result, LoaderError> { + if kind.is_fabric_family() { + let url = fabric::loader_list_url(kind, mc_version); + let body = self.fetch_text(&url).await?; + let entries = fabric::parse_loader_list(kind, &body)?; + return Ok(entries.into_iter().map(|entry| entry.version).collect()); + } + let url = maven::metadata_url(kind, mc_version); + let body = self.fetch_text(&url).await?; + let all = maven::parse_metadata_versions(&body); + // maven 表是升序的,统一成"最新在前"。 + let mut picked = maven::select_versions(kind, mc_version, &all); + picked.reverse(); + if picked.is_empty() { + return Err(LoaderError::not_found(format!( + "{} 在 {} 上没有可用版本(maven 表 {} 条,全部与该原版版本不匹配)", + kind.label(), + mc_version, + all.len() + ))); + } + Ok(picked) + } + + /// 解析一个加载器版本(`loader_version` 为 `None` 时取官方默认:Fabric 系取首个稳定版, + /// maven 系取该原版版本下最新的一版)。 + /// + /// # Errors + /// + /// 网络失败、版本不存在、installer 缺 profile、协议不受支持时返回 [`LoaderError`]。 + pub async fn resolve( + &self, + kind: LoaderKind, + mc_version: &str, + loader_version: Option<&str>, + ) -> Result { + let requested = loader_version + .map(str::trim) + .filter(|value| !value.is_empty()); + if kind.is_fabric_family() { + let version = match requested { + Some(version) => version.to_string(), + None => { + let url = fabric::loader_list_url(kind, mc_version); + let body = self.fetch_text(&url).await?; + let entries = fabric::parse_loader_list(kind, &body)?; + fabric::pick_default(&entries) + .map(|entry| entry.version.clone()) + .ok_or_else(|| { + LoaderError::not_found(format!("{} 没有可用的加载器版本", kind.label())) + })? + } + }; + let url = fabric::profile_url(kind, mc_version, &version); + let body = self.fetch_text(&url).await?; + return fabric::build_release(kind, mc_version, &version, &body); + } + + let maven_version = match requested { + Some(raw) => maven::normalize_maven_version(kind, mc_version, raw), + None => self + .loader_versions(kind, mc_version) + .await? + .into_iter() + .next() + .ok_or_else(|| { + LoaderError::not_found(format!("{} 没有可用的加载器版本", kind.label())) + })?, + }; + let url = maven::installer_url(kind, mc_version, &maven_version); + let path = self.installer_path(kind, &maven_version); + let jar = self.fetch_installer(&url, &path).await?; + let (profile, embedded) = read_installer(&jar)?; + let mut release = forge::build_release( + kind, + mc_version, + &maven_version, + &profile, + embedded.as_ref(), + )?; + // M35 执行器要的两样东西:installer 本体(跑 processor 与解包)与它声明的 profile。 + release.installer = Some(jar); + Ok(release) + } + + /// installer jar 的本地缓存路径(`+`/非法字符规整成 `_`)。 + fn installer_path(&self, kind: LoaderKind, maven_version: &str) -> PathBuf { + let safe: String = maven_version + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { + ch + } else { + '_' + } + }) + .collect(); + self.meta_cache + .join(format!("{}-{safe}-{INSTALLER_TAG}.jar", kind.as_str())) + } + + /// 取 installer jar:本地缓存里有可读的 zip 就直接用(离线复用),否则下载。 + async fn fetch_installer(&self, url: &str, path: &Path) -> Result { + if path.is_file() && ZipArchive::new(File::open(path).map_err(io_error(path))?).is_ok() { + return Ok(path.to_path_buf()); + } + let request = DownloadRequest::new(ResourceKind::Library, url, path.to_path_buf()); + let outcome = self.engine.download(request).await?; + Ok(outcome.path) + } + + /// 取一小段文本元数据(版本列表 / profile JSON / maven metadata)。 + async fn fetch_text(&self, url: &str) -> Result { + let dest = self.meta_cache.join("loader-meta.bin"); + let bytes = self + .engine + .download_bytes(DownloadRequest::new( + ResourceKind::VersionManifest, + url, + dest, + )) + .await?; + String::from_utf8(bytes) + .map_err(|_| LoaderError::metadata(format!("元数据不是 UTF-8:{url}"))) + } +} + +/// 读 installer jar 里的 `install_profile.json` 与内嵌的版本 JSON。 +/// +/// 内嵌版本 JSON 的路径来自 profile 的 `json` 字段(通常 `/version.json`); +/// 该条目不存在时返回 `None`,由 [`forge::build_release`] 判定为 unsupported。 +/// +/// # Errors +/// +/// 不是合法 zip、缺 `install_profile.json`、profile/版本 JSON 无法解析时返回 metadata 错误。 +pub fn read_installer(path: &Path) -> Result<(InstallProfile, Option), LoaderError> { + let file = File::open(path).map_err(io_error(path))?; + let mut archive = ZipArchive::new(file).map_err(|error| { + LoaderError::metadata(format!("{} 不是合法 zip:{error}", path.display())) + })?; + + let profile_text = read_entry(&mut archive, "install_profile.json")?.ok_or_else(|| { + LoaderError::metadata(format!( + "{} 里没有 install_profile.json(不是 Forge/NeoForge installer?)", + path.display() + )) + })?; + let profile: InstallProfile = serde_json::from_str(&profile_text).map_err(|error| { + LoaderError::metadata(format!("install_profile.json 解析失败:{error}")) + })?; + + let embedded = match profile + .version_json_path + .as_deref() + .map(|entry| entry.trim_start_matches('/')) + .filter(|entry| !entry.is_empty()) + { + Some(entry) => match read_entry(&mut archive, entry)? { + Some(text) => Some(VersionJson::from_json_str(&text).map_err(|error| { + LoaderError::metadata(format!("installer 内 {entry} 解析失败:{error}")) + })?), + None => None, + }, + None => None, + }; + Ok((profile, embedded)) +} + +/// 读一个 zip 条目成字符串;条目不存在返回 `Ok(None)`。 +fn read_entry(archive: &mut ZipArchive, name: &str) -> Result, LoaderError> { + use std::io::Read; + + let mut entry = match archive.by_name(name) { + Ok(entry) => entry, + Err(zip::result::ZipError::FileNotFound) => return Ok(None), + Err(error) => { + return Err(LoaderError::metadata(format!( + "读 installer 条目 {name} 失败:{error}" + ))) + } + }; + let mut text = String::new(); + entry + .read_to_string(&mut text) + .map_err(|error| LoaderError::io(format!("installer 条目 {name} 不是可读文本:{error}")))?; + Ok(Some(text)) +} + +fn io_error(path: &Path) -> impl FnOnce(std::io::Error) -> LoaderError + '_ { + move |error| LoaderError::io(format!("{} 读写失败:{error}", path.display())) +} diff --git a/crates/core/src/addon/loader/mod.rs b/crates/core/src/addon/loader/mod.rs new file mode 100644 index 0000000..d8eee17 --- /dev/null +++ b/crates/core/src/addon/loader/mod.rs @@ -0,0 +1,249 @@ +//! 加载器安装(Fabric / Quilt / NeoForge / Forge):官方元数据 → loader 版本 JSON → 补库清单。 +//! +//! 设计输入:`DevDocs/PlanDoc/08-mod-and-recommend.md` §1(D10 加载器安装)、 +//! `DevDocs/analyse/05-modloader-modpack.md` §1(五加载器安装协议、`patch + priority` 主线)。 +//! +//! # 与 M27 安装层的关系(不复制粘贴,只叠一层) +//! +//! 加载器不是"另一个版本",而是**叠在已装原版之上的一个 `inheritsFrom` 层**: +//! +//! ```text +//! versions/1.20.1/1.20.1.json 原版(M27 负责装) +//! versions/1.20.1-fabric-0.15.11/…json loader 层:inheritsFrom=1.20.1 + 主类 + loader 库 +//! ``` +//! +//! 因此本模块**只做两件事**:①把官方元数据翻译成一份 loader 版本 JSON; +//! ②把它的库列表交给 [`crate::game::install::libraries`] 展开成下载计划。 +//! 落盘、跳过判定、实例登记一律复用 M27([`crate::game::install::execute`] / `register`), +//! 不另起一套下载/写盘逻辑。 +//! +//! # 三条路线(按"能不能不跑 java 就装出来"分类,而不是按加载器名字) +//! +//! | 路线 | 加载器 | 做法 | +//! |------|--------|------| +//! | [`LoaderSupport::JsonRoute`] | Fabric / Quilt | 官方 meta 直接给 launcher 用的 profile JSON | +//! | [`LoaderSupport::JsonRoute`] | Forge spec-0(如 1.12.2 重建版) | installer 里的 `version.json` + 库清单,universal jar 走 maven 直取 | +//! | [`LoaderSupport::RequiresInstaller`] | Forge spec≥1 / NeoForge | installer 声明了 client 侧 processors(拆包/映射),**必须外调 java** | +//! +//! 第三类本期只出**明确 unsupported**:版本 JSON 与库清单仍照常生成(`--dry-run` 可看), +//! 但真装会被拒——半成品实例比明确报错更糟(沿 M27 §7「如实告知」的纪律)。 +//! 老协议 installer(内联 `versionInfo` + universal jar 解包,≈1.12.2 之前的重建版)同样归 unsupported。 + +pub mod fabric; +pub mod forge; +pub mod maven; +pub mod meta; +pub mod plan; +pub mod processors; + +#[cfg(test)] +mod tests; + +pub use meta::LoaderSource; +pub use plan::{LoaderInstallPlan, LoaderPlanInput, LoaderRelease, LoaderSupport}; + +use std::fmt; + +use crate::download::{DownloadError, DownloadErrorKind}; + +/// 支持的加载器种类。 +/// +/// `vanilla` / `none` **不在这里**:那是"不装加载器",由 CLI 走 M27 的原版路径。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum LoaderKind { + /// Fabric(`meta.fabricmc.net`,纯 meta,无 installer)。 + Fabric, + /// Quilt(`meta.quiltmc.org`,与 Fabric 同构)。 + Quilt, + /// NeoForge(`maven.neoforged.net`,installer 内嵌 install profile)。 + NeoForge, + /// Forge(`maven.minecraftforge.net`,两代 installer 协议)。 + Forge, +} + +impl LoaderKind { + /// 解析命令行里的加载器名(大小写不敏感;`neo-forge` 是 Modrinth 的另一种拼法)。 + #[must_use] + pub fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "fabric" => Some(Self::Fabric), + "quilt" => Some(Self::Quilt), + "neoforge" | "neo-forge" => Some(Self::NeoForge), + "forge" => Some(Self::Forge), + _ => None, + } + } + + /// 稳定字符串 id(进版本 id、JSON 输出与日志,**不要改名**)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Fabric => "fabric", + Self::Quilt => "quilt", + Self::NeoForge => "neoforge", + Self::Forge => "forge", + } + } + + /// 人读名称。 + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Fabric => "Fabric", + Self::Quilt => "Quilt", + Self::NeoForge => "NeoForge", + Self::Forge => "Forge", + } + } + + /// 是否属于 Fabric 系(纯 meta、profile JSON 直接可用)。 + #[must_use] + pub const fn is_fabric_family(self) -> bool { + matches!(self, Self::Fabric | Self::Quilt) + } + + /// 全部加载器(CLI 的 `--loader` 枚举提示用)。 + #[must_use] + pub const fn all() -> &'static [Self] { + &[Self::Fabric, Self::Quilt, Self::NeoForge, Self::Forge] + } + + /// loader 版本 id 的词根:`--`(`versions//` 与实例 `versionId` 都用它)。 + #[must_use] + pub fn version_id(self, mc_version: &str, loader_version: &str) -> String { + format!("{mc_version}-{}-{loader_version}", self.as_str()) + } +} + +impl fmt::Display for LoaderKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.label()) + } +} + +/// 加载器安装失败的大类(CLI 据此给稳定错误码 `loader_*`)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoaderErrorKind { + /// 该加载器/该版本走不通我们的路线(需要跑 installer 或属未支持的老协议)。 + Unsupported, + /// 官方元数据里没有这个加载器版本(或没有匹配该原版版本的版本)。 + NotFound, + /// 元数据解析失败(profile JSON / install profile / maven metadata 坏了)。 + Metadata, + /// 网络侧失败(所有候选源都失败 / 超时)。 + Network, + /// 本地 IO(读 installer 缓存、写盘失败)。 + Io, +} + +impl LoaderErrorKind { + /// 稳定字符串 id(进 `--json` 的 `error.code`)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Unsupported => "loader_unsupported", + Self::NotFound => "loader_not_found", + Self::Metadata => "loader_metadata", + Self::Network => "loader_network", + Self::Io => "loader_io", + } + } +} + +/// 加载器安装错误:大类 + 人读说明 + 可选提示。 +#[derive(Debug, Clone)] +pub struct LoaderError { + kind: LoaderErrorKind, + message: String, + hint: Option, +} + +impl LoaderError { + /// 构造。 + #[must_use] + pub fn new(kind: LoaderErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + hint: None, + } + } + + /// 走不通我们的路线(需要外调 java installer / 老协议)。 + #[must_use] + pub fn unsupported(message: impl Into) -> Self { + Self::new(LoaderErrorKind::Unsupported, message) + } + + /// 元数据里查不到。 + #[must_use] + pub fn not_found(message: impl Into) -> Self { + Self::new(LoaderErrorKind::NotFound, message) + } + + /// 元数据坏了/形状不符。 + #[must_use] + pub fn metadata(message: impl Into) -> Self { + Self::new(LoaderErrorKind::Metadata, message) + } + + /// 本地 IO。 + #[must_use] + pub fn io(message: impl Into) -> Self { + Self::new(LoaderErrorKind::Io, message) + } + + /// 补一句提示。 + #[must_use] + pub fn with_hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } + + /// 大类。 + #[must_use] + pub const fn kind(&self) -> LoaderErrorKind { + self.kind + } + + /// 人读说明。 + #[must_use] + pub fn message(&self) -> &str { + &self.message + } + + /// 下一步提示。 + #[must_use] + pub fn hint(&self) -> Option<&str> { + self.hint.as_deref() + } +} + +impl fmt::Display for LoaderError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for LoaderError {} + +impl From for LoaderError { + fn from(error: DownloadError) -> Self { + let kind = match error.kind { + DownloadErrorKind::Io => LoaderErrorKind::Io, + DownloadErrorKind::InvalidRequest => LoaderErrorKind::Metadata, + DownloadErrorKind::Integrity + | DownloadErrorKind::NotFound + | DownloadErrorKind::ServerError + | DownloadErrorKind::Timeout + | DownloadErrorKind::Network + | DownloadErrorKind::RangeUnsupported + | DownloadErrorKind::AllSourcesFailed => LoaderErrorKind::Network, + }; + let mut message = error.message.clone(); + if let Some(source) = &error.source { + message.push_str(&format!("(源 {source})")); + } + Self::new(kind, message) + } +} diff --git a/crates/core/src/addon/loader/plan.rs b/crates/core/src/addon/loader/plan.rs new file mode 100644 index 0000000..dc8aae8 --- /dev/null +++ b/crates/core/src/addon/loader/plan.rs @@ -0,0 +1,235 @@ +//! 加载器安装计划:一份 loader 版本 JSON + 需要补下载的库清单。 +//! +//! 计划层与网络层分开:网络在 [`super::meta`],这里只做**纯计算**——把已经解析好的 +//! loader 版本 JSON 交给 [`crate::game::install::libraries`] 展开成待下载文件,再包成 +//! M27 的 [`InstallPlan`](落盘/跳过判定/原子写版本 JSON 全部沿用同一套实现)。 +//! +//! 为什么不直接调 [`InstallPlan::build`]:那是"装一个完整原版版本"的入口(客户端 jar + +//! 资源索引 + 逐对象),而 loader 层**只有库**——jar 与资源都由 `inheritsFrom` 的原版提供。 +//! 直接组 [`InstallPlan`] 的字段既省掉一堆"没有 jar"的告警,又不复制任何路径规则。 + +use std::path::PathBuf; + +use serde_json::{json, Value}; + +use super::forge::InstallProfile; +use super::LoaderKind; +use crate::game::arguments::Environment; +use crate::game::install::libraries; +use crate::game::install::{InstallPlan, PlanFile}; +use crate::game::layout::GameLayout; +use crate::game::manifest::VersionJson; + +/// 加载器版本的可用路线(判据是"能不能不跑 java 就装出来",不是加载器名字)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoaderSupport { + /// 纯元数据/纯下载路线:不跑任何外部进程。 + JsonRoute, + /// 官方 installer 声明了必须先跑的 processors(拆包/映射),走 M35 执行器外调 java。 + RequiresInstaller { + /// 人读原因(含该版本声明的 processors 数量与作用)。 + reason: String, + }, +} + +impl LoaderSupport { + /// 稳定字符串 id(JSON 输出用)。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::JsonRoute => "json_route", + Self::RequiresInstaller { .. } => "requires_installer", + } + } + + /// 是否可真正安装(两条路线都走到了:processors 走 M35 执行器)。 + #[must_use] + pub const fn is_installable(&self) -> bool { + true + } + + /// 是否要外调 java 跑 installer processors。 + #[must_use] + pub const fn requires_installer(&self) -> bool { + matches!(self, Self::RequiresInstaller { .. }) + } + + /// JSON 视图。 + #[must_use] + pub fn to_json(&self) -> Value { + match self { + Self::JsonRoute => json!({"mode": self.as_str(), "requires_installer": false}), + Self::RequiresInstaller { reason } => { + json!({"mode": self.as_str(), "requires_installer": true, "reason": reason}) + } + } + } +} + +/// 一次解析出来的加载器版本(网络层产物,计划层的输入)。 +#[derive(Debug, Clone)] +pub struct LoaderRelease { + /// 加载器种类。 + pub kind: LoaderKind, + /// 目标原版版本(`inheritsFrom` 的父版本 id)。 + pub mc_version: String, + /// 加载器自身版本号(`0.15.11` / `47.2.0` / `21.1.80`)。 + pub loader_version: String, + /// 生成的 loader 版本 id(`1.20.1-fabric-0.15.11`,即 `versions//` 的目录名)。 + pub version_id: String, + /// loader 版本 JSON(`inheritsFrom` 指向原版)。 + pub version_json: VersionJson, + /// 可走哪条路线。 + pub support: LoaderSupport, + /// installer jar 的磁盘路径(M35 执行器的输入;Fabric 系为 `None`)。 + pub installer: Option, + /// installer 里读出来的 install profile(执行器要 `processors` / `data` / `libraries`)。 + pub install_profile: Option, + /// 解析期告警(不致命:例如 profile 缺 loader 库时我们补了一条)。 + pub warnings: Vec, +} + +impl LoaderRelease { + /// 是否可真正安装。 + #[must_use] + pub const fn is_installable(&self) -> bool { + self.support.is_installable() + } + + /// 是否要外调 java 跑 installer processors。 + #[must_use] + pub const fn requires_installer(&self) -> bool { + self.support.requires_installer() + } + + /// 需要 installer 时的原因(`JsonRoute` 返回 `None`)。 + #[must_use] + pub fn installer_reason(&self) -> Option<&str> { + match &self.support { + LoaderSupport::JsonRoute => None, + LoaderSupport::RequiresInstaller { reason } => Some(reason), + } + } + + /// JSON 视图(`--dry-run` 与诊断共用)。 + #[must_use] + pub fn to_json(&self) -> Value { + json!({ + "kind": self.kind.as_str(), + "label": self.kind.label(), + "mc_version": self.mc_version, + "loader_version": self.loader_version, + "version_id": self.version_id, + "inherits_from": self.version_json.inherits_from, + "jar": self.version_json.jar, + "main_class": self.version_json.main_class, + "libraries": self.version_json.libraries.as_ref().map_or(0, Vec::len), + "support": self.support.to_json(), + "warnings": self.warnings, + }) + } +} + +/// [`LoaderInstallPlan::build`] 的输入。 +pub struct LoaderPlanInput<'a> { + /// 游戏目录布局(`versions/` `libraries/` 的路径规则)。 + pub layout: &'a GameLayout, + /// 已解析的加载器版本。 + pub release: &'a LoaderRelease, + /// 规则求值环境(OS / 架构 / 特性开关)。 + pub environment: &'a Environment, + /// 原版的 `versions//.json` 是否已在盘上。 + pub vanilla_installed: bool, +} + +/// 加载器安装计划:M27 的 [`InstallPlan`] + 加载器自己的上下文。 +/// +/// 执行期直接 `execute(&engine, &plan.install, …)`:那份计划里的 `version_json` 就是 +/// 带 `inheritsFrom` 的 loader 层,`execute` 会在**全部库就绪后**原子写到 +/// `versions//.json`(半成品不写,沿 M27 的纪律)。 +#[derive(Debug, Clone)] +pub struct LoaderInstallPlan { + /// 加载器版本上下文。 + pub release: LoaderRelease, + /// 待执行的下载计划(库 + 版本 JSON 落盘)。 + pub install: InstallPlan, + /// 是否还需要先装原版(原版已装则 `false`,真装时会跳过)。 + pub vanilla_install_needed: bool, +} + +impl LoaderInstallPlan { + /// 组计划。 + /// + /// 缺下载地址的库降级成告警(见 [`Self::summary_json`] 的 `warnings`),不硬失败—— + /// 与 M27「宁可给出可修复的骨架,也不静默假装成功」一致。 + #[must_use] + pub fn build(input: LoaderPlanInput<'_>) -> Self { + let release = input.release; + let (files, library_warnings) = libraries::plan( + input.layout, + &release.version_id, + release + .version_json + .libraries + .as_deref() + .unwrap_or_default(), + input.environment, + ); + + let mut warnings = release.warnings.clone(); + warnings.extend(library_warnings); + if let Some(reason) = release.installer_reason() { + warnings.push(format!("需要外调 java 跑 installer processors:{reason}")); + } + if !input.vanilla_installed { + warnings.push(format!( + "原版 {} 尚未安装:执行时会先装原版再叠加载器", + release.mc_version + )); + } + + let install = InstallPlan { + version_id: release.version_id.clone(), + // jar 来自原版层(生成的 loader JSON 已把 `jar` 钉成原版 id)。 + jar_id: release.mc_version.clone(), + game_root: input.layout.root().to_path_buf(), + version_json_path: input.layout.version_json(&release.version_id), + files, + version_json: release.version_json.clone(), + prefetched: Vec::new(), + warnings, + }; + + Self { + release: release.clone(), + install, + vanilla_install_needed: !input.vanilla_installed, + } + } + + /// 计划里的待下载文件。 + #[must_use] + pub fn files(&self) -> &[PlanFile] { + &self.install.files + } + + /// 告警(含"原版未装""本期不能真装")。 + #[must_use] + pub fn warnings(&self) -> &[String] { + &self.install.warnings + } + + /// 汇总 JSON(`--dry-run` 的主载荷)。 + #[must_use] + pub fn summary_json(&self) -> Value { + let mut summary = self.install.summary_json(); + if let Some(object) = summary.as_object_mut() { + object.insert("loader".to_string(), self.release.to_json()); + object.insert( + "vanilla_install_needed".to_string(), + json!(self.vanilla_install_needed), + ); + } + summary + } +} diff --git a/crates/core/src/addon/loader/processors/download.rs b/crates/core/src/addon/loader/processors/download.rs new file mode 100644 index 0000000..a9e39ba --- /dev/null +++ b/crates/core/src/addon/loader/processors/download.rs @@ -0,0 +1,83 @@ +//! processor 依赖库的下载:把 [`ProcessorPlan::libraries`] 交给 M27 的下载引擎。 +//! +//! 为什么不直接复用 `game::install::execute`:那个入口在**全量就绪后写版本 JSON**, +//! 而 processors 的产物要先于版本清单落盘(版本 JSON 存在 = 这版真装完了)。 +//! 所以这里只借它的**逐文件跳过判定**(`current_bytes`:摘要 → 大小 → 存在即信) +//! 与结果类型,下载本身仍走同一个引擎——镜像、健康度、退避、续传一条不少。 + +use futures_util::stream::{self, StreamExt}; + +use crate::download::DownloadEngine; +use crate::game::install::execute::current_bytes; +use crate::game::install::{ + FileFailure, FileOutcome, FileStatus, InstallReport, PlanFile, ProgressSink, +}; + +/// 并发下载 processor 依赖库(`concurrency` 上限,引擎内部另有信号量)。 +/// +/// 失败**不**中断:与 M27 一致,逐文件结构化返回,由调用方决定是否继续。 +pub async fn download_libraries( + engine: &DownloadEngine, + files: &[PlanFile], + concurrency: usize, + sink: Option, +) -> InstallReport { + let outcomes: Vec = stream::iter(files.iter()) + .map(|file| { + let sink = sink.clone(); + async move { + let outcome = install_one(engine, file).await; + if let Some(sink) = &sink { + sink(&outcome); + } + outcome + } + }) + .buffer_unordered(concurrency.max(1)) + .collect() + .await; + + InstallReport { + files: outcomes, + version_json: None, + warnings: Vec::new(), + } +} + +/// 单文件:先判「已就绪」,否则走引擎下载。 +async fn install_one(engine: &DownloadEngine, file: &PlanFile) -> FileOutcome { + if let Some(bytes) = current_bytes(file) { + return FileOutcome { + role: file.role, + name: file.name.clone(), + dest: file.dest.clone(), + status: FileStatus::Skipped, + bytes, + source: None, + failure: None, + }; + } + match engine.download(file.request()).await { + Ok(outcome) => FileOutcome { + role: file.role, + name: file.name.clone(), + dest: file.dest.clone(), + status: FileStatus::Downloaded, + bytes: outcome.bytes, + source: Some(outcome.source), + failure: None, + }, + Err(error) => FileOutcome { + role: file.role, + name: file.name.clone(), + dest: file.dest.clone(), + status: FileStatus::Failed, + bytes: 0, + source: None, + failure: Some(FileFailure { + kind: error.kind, + message: error.message.clone(), + }), + }, + } +} diff --git a/crates/core/src/addon/loader/processors/error.rs b/crates/core/src/addon/loader/processors/error.rs new file mode 100644 index 0000000..99d53ad --- /dev/null +++ b/crates/core/src/addon/loader/processors/error.rs @@ -0,0 +1,181 @@ +//! processors 执行器的错误分类(CLI 据此给稳定错误码 `processor_*`)。 +//! +//! 与 [`crate::addon::loader::LoaderError`] 分开是刻意的:加载器**解析**阶段的失败 +//! (元数据坏了、版本不存在)与**执行**阶段的失败(java 跑挂了、产物没落盘)对用户 +//! 是完全不同的两件事——前者换版本/换线路,后者多半是 Java 或磁盘问题。 +//! 失败分流也是 mission 的硬要求:`processor_failed` 必须能单独被脚本识别。 + +use std::fmt; + +/// processor 执行失败的大类。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProcessorErrorKind { + /// 规划期:install profile 形状不对(缺 `jar`、坐标不合法、变量缺定义)。 + Plan, + /// 找不到可用的 Java(M33 的解析链一个候选都没有)。 + Java, + /// processor 自己要用的 jar/依赖不在盘上(下载没覆盖到)。 + Library, + /// 外调 java 失败(非零退出),消息里带脱敏后的 stderr 尾部。 + Failed, + /// processor 跑完了但产物没落盘。 + Output, + /// 本地 IO(解包 installer 条目、建目录、复制 MC jar、起进程失败)。 + Io, +} + +impl ProcessorErrorKind { + /// 稳定字符串 id(进 `--json` 的 `error.code`)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Plan => "processor_plan", + Self::Java => "processor_java_missing", + Self::Library => "processor_library_missing", + Self::Failed => "processor_failed", + Self::Output => "processor_output_missing", + Self::Io => "processor_io", + } + } +} + +/// processor 执行错误:大类 + 人读说明 + 可选提示 + 可选 stderr 尾部(已脱敏)。 +#[derive(Debug, Clone)] +pub struct ProcessorError { + kind: ProcessorErrorKind, + message: String, + hint: Option, + stderr_tail: Option, +} + +impl ProcessorError { + /// 构造。 + #[must_use] + pub fn new(kind: ProcessorErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + hint: None, + stderr_tail: None, + } + } + + /// 规划期失败(install profile 形状/变量问题)。 + #[must_use] + pub fn plan(message: impl Into) -> Self { + Self::new(ProcessorErrorKind::Plan, message) + } + + /// 找不到可用的 Java。 + #[must_use] + pub fn java_missing(message: impl Into) -> Self { + Self::new(ProcessorErrorKind::Java, message) + } + + /// processor 依赖不在盘上。 + #[must_use] + pub fn library_missing(message: impl Into) -> Self { + Self::new(ProcessorErrorKind::Library, message) + } + + /// processor 产物没落盘。 + #[must_use] + pub fn output_missing(message: impl Into) -> Self { + Self::new(ProcessorErrorKind::Output, message) + } + + /// 本地 IO 失败。 + #[must_use] + pub fn io(message: impl Into) -> Self { + Self::new(ProcessorErrorKind::Io, message) + } + + /// 外调 java 非零退出:消息 = 说明 + 脱敏后的 stderr 尾部。 + #[must_use] + pub fn failed(label: &str, exit: &str, stderr_tail: &str) -> Self { + let mut message = format!("processor {label} 执行失败(退出码 {exit})"); + if !stderr_tail.trim().is_empty() { + message.push_str(";stderr 尾部:\n"); + message.push_str(stderr_tail.trim_end()); + } + let mut error = Self::new(ProcessorErrorKind::Failed, message); + error.stderr_tail = Some(stderr_tail.trim_end().to_string()); + error + } + + /// 补一句提示。 + #[must_use] + pub fn with_hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } + + /// 大类。 + #[must_use] + pub const fn kind(&self) -> ProcessorErrorKind { + self.kind + } + + /// 人读说明。 + #[must_use] + pub fn message(&self) -> &str { + &self.message + } + + /// 下一步提示。 + #[must_use] + pub fn hint(&self) -> Option<&str> { + self.hint.as_deref() + } + + /// 脱敏后的 stderr 尾部(只有 `processor_failed` 会有)。 + #[must_use] + pub fn stderr_tail(&self) -> Option<&str> { + self.stderr_tail.as_deref() + } +} + +impl fmt::Display for ProcessorError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for ProcessorError {} + +#[cfg(test)] +mod tests { + use super::{ProcessorError, ProcessorErrorKind}; + + #[test] + fn stable_codes_are_distinct_and_prefixed() { + let kinds = [ + ProcessorErrorKind::Plan, + ProcessorErrorKind::Java, + ProcessorErrorKind::Library, + ProcessorErrorKind::Failed, + ProcessorErrorKind::Output, + ProcessorErrorKind::Io, + ]; + for kind in kinds { + assert!(kind.as_str().starts_with("processor_"), "{}", kind.as_str()); + } + assert_eq!(ProcessorErrorKind::Failed.as_str(), "processor_failed"); + } + + #[test] + fn failed_carries_the_stderr_tail_in_message_and_accessor() { + let error = ProcessorError::failed("g:a:1", "1", "boom\nCaused by: x"); + assert_eq!(error.kind(), ProcessorErrorKind::Failed); + assert!(error.message().contains("退出码 1"), "{}", error.message()); + assert!(error.message().contains("Caused by: x")); + assert_eq!(error.stderr_tail(), Some("boom\nCaused by: x")); + } + + #[test] + fn empty_stderr_keeps_the_message_clean() { + let error = ProcessorError::failed("g:a:1", "255", ""); + assert!(!error.message().contains("stderr 尾部")); + assert_eq!(error.stderr_tail(), Some("")); + } +} diff --git a/crates/core/src/addon/loader/processors/exec.rs b/crates/core/src/addon/loader/processors/exec.rs new file mode 100644 index 0000000..0c2029c --- /dev/null +++ b/crates/core/src/addon/loader/processors/exec.rs @@ -0,0 +1,363 @@ +//! 逐个外调 java 执行 processor:`java -cp <依赖…>: <主类> <参数…>`。 +//! +//! 依据 `DevDocs/analyse/05-modloader-modpack.md` §1.2(Forge 新式 installer 的 processors +//! 流水线)与 HMCL `ForgeNewInstallTask.ProcessorTask` 的实现,四条硬规矩: +//! +//! 1. **串行**:后面的 processor 吃前面的产物(拆包 → 映射 → 打补丁),并行必炸; +//! 2. **幂等**:每步先按 `outputs` 的 SHA-1 判定,全部命中就整步跳过(重跑 install 零成本); +//! 3. **主类来自 jar 的 MANIFEST**:官方 install profile 不写主类(少数 fork 给 `mainClass`); +//! 4. **失败要能定位**:非零退出带脱敏后的 stderr 尾部(路径/用户名不能泄漏到日志外)。 +//! +//! # 关于产物 SHA-1 与 zlib 变体(HMCL 踩过的坑) +//! +//! processor 动态生成的 jar 是用**本机 JDK 的 zlib** 压缩的。zlib-ng 之类非标准实现会把 +//! 同样的内容压出不同字节 → SHA-1 与 profile 里的期望值不符。HMCL 的处置是「哈希不符时 +//! 退化成 ZIP 结构校验」。我们的处置更保守:**存在性强制、摘要不符只告警**, +//! 既不误杀真实装,也不假装校验通过。 + +use std::path::Path; +use std::process::Command; +use std::sync::Arc; +use std::time::Instant; + +use serde_json::{json, Value}; + +use super::error::ProcessorError; +use super::jar::read_main_class; +use super::plan::{ProcessorPlan, ProcessorStep}; +use super::prepare::prepare; +use super::text::{host_redactor, non_empty, tail}; +use crate::download::verify; +use crate::download::HashAlgo; +use crate::game::java::{self, JavaRequirement, JavaRuntime}; +use crate::launch::redact::Redactor; + +/// 一步的结局。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StepStatus { + /// 产物已就绪(SHA-1 命中),整步跳过。 + Skipped, + /// 真的起了一次 java。 + Executed, +} + +impl StepStatus { + /// 稳定字符串 id(`--json` 用)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Skipped => "skipped", + Self::Executed => "executed", + } + } +} + +/// 一步的执行结果。 +#[derive(Debug, Clone)] +pub struct StepOutcome { + /// 序号(从 1 起)。 + pub index: usize, + /// jar 的 maven 坐标。 + pub label: String, + /// 结局。 + pub status: StepStatus, + /// 产物个数。 + pub outputs: usize, + /// 耗时(毫秒;跳过时为 0)。 + pub elapsed_ms: u128, + /// stdout 尾巴(已脱敏,可能为空)。 + pub stdout_tail: Option, + /// stderr 尾巴(已脱敏,可能为空)。 + pub stderr_tail: Option, +} + +impl StepOutcome { + /// JSON 视图。 + #[must_use] + pub fn to_json(&self) -> Value { + json!({ + "index": self.index, + "jar": self.label, + "status": self.status.as_str(), + "outputs": self.outputs, + "elapsed_ms": self.elapsed_ms, + "stderr_tail": self.stderr_tail, + }) + } +} + +/// 一次 processors 执行的汇总。 +#[derive(Debug, Clone)] +pub struct ProcessorRun { + /// 实际用的 Java(含来源与主版本)。 + pub java: JavaRuntime, + /// 逐步骤结果(顺序即执行顺序)。 + pub steps: Vec, + /// 不致命的告警(摘要不符、没有 outputs 的步骤等)。 + pub warnings: Vec, +} + +impl ProcessorRun { + /// 真正跑过的步数。 + #[must_use] + pub fn executed(&self) -> usize { + self.steps + .iter() + .filter(|step| step.status == StepStatus::Executed) + .count() + } + + /// 跳过的步数(幂等命中)。 + #[must_use] + pub fn skipped(&self) -> usize { + self.steps + .iter() + .filter(|step| step.status == StepStatus::Skipped) + .count() + } + + /// JSON 视图。 + #[must_use] + pub fn to_json(&self) -> Value { + json!({ + "java": { + "binary": self.java.binary.display().to_string(), + "source": self.java.origin.as_str(), + "major": self.java.major, + "version": self.java.version, + }, + "executed": self.executed(), + "skipped": self.skipped(), + "steps": self.steps.iter().map(StepOutcome::to_json).collect::>(), + "warnings": self.warnings, + }) + } +} + +/// 每步完成的回调(CLI 往 stderr 打实时进度用)。 +pub type StepSink = Arc; + +/// 按 M33 的解析链挑一个能跑 installer 的 Java:托管 > `JAVA_HOME` > `PATH` > 扫盘。 +/// +/// 与启动路径的差异只有一点:**扫盘只在前面都不满足时才做**(别为了一次安装白跑 +/// N 次 `java -version`),这里复用 [`crate::game::java::pick`] 的同一套优先序。 +#[must_use] +pub fn select_java(managed_root: &Path, requirement: &JavaRequirement) -> Option { + let mut candidates = java::discover_managed(managed_root); + candidates.extend(java::system_candidates()); + if let Some(runtime) = java::pick(&candidates, requirement) { + if usable(runtime) { + return Some(runtime.clone()); + } + } + candidates.extend(java::standard_candidates()); + let deduped = java::discover::dedupe(candidates); + java::pick(&deduped, requirement).cloned().filter(usable) +} + +/// 候选是否真的能用:探测到主版本,或者那个文件确实在盘上。 +fn usable(runtime: &JavaRuntime) -> bool { + runtime.major.is_some() || runtime.binary.is_file() +} + +/// 顺序执行所有 processor。 +/// +/// 全部产物已就绪时走快路径:不复制 client jar、不解包 installer、不起任何进程。 +/// +/// # Errors +/// +/// 临时目录/解包/复制失败(IO)、依赖缺失、`java -cp` 起不来或非零退出、 +/// 产物没落盘时返回 [`ProcessorError`]。 +pub fn run( + plan: &ProcessorPlan, + runtime: &JavaRuntime, + sink: Option, +) -> Result { + let redactor = host_redactor(); + let mut warnings = Vec::new(); + let mut outcomes = Vec::with_capacity(plan.steps.len()); + let satisfied: Vec = plan + .steps + .iter() + .map(|step| outputs_satisfied(step, &mut warnings)) + .collect(); + if satisfied.iter().any(|done| !done) { + prepare(plan)?; + } + for (step, done) in plan.steps.iter().zip(satisfied) { + let outcome = if done { + StepOutcome { + index: step.index, + label: step.label.clone(), + status: StepStatus::Skipped, + outputs: step.outputs.len(), + elapsed_ms: 0, + stdout_tail: None, + stderr_tail: None, + } + } else { + run_step(plan, runtime, step, &redactor, &mut warnings)? + }; + if let Some(sink) = &sink { + sink(&outcome); + } + outcomes.push(outcome); + } + + Ok(ProcessorRun { + java: runtime.clone(), + steps: outcomes, + warnings, + }) +} + +/// 起一次 java,跑完校验产物。 +fn run_step( + plan: &ProcessorPlan, + runtime: &JavaRuntime, + step: &ProcessorStep, + redactor: &Redactor, + warnings: &mut Vec, +) -> Result { + ensure_dependencies(step)?; + let main_class = match &step.main_class { + Some(main_class) => main_class.clone(), + None => read_main_class(&step.jar)?, + }; + let classpath = step.classpath_join()?; + + let started = Instant::now(); + let output = Command::new(&runtime.binary) + .arg("-cp") + .arg(&classpath) + .arg(&main_class) + .args(&step.args) + .current_dir(&plan.scratch) + .output() + .map_err(|error| { + ProcessorError::io(format!( + "起 java 失败({}):{error}", + runtime.binary.display() + )) + .with_hint( + "确认 `ull java list` 里的路径可用,或用 `ull java install ` 装一个托管 JDK", + ) + })?; + let elapsed_ms = started.elapsed().as_millis(); + let stdout = redactor + .scrub(&String::from_utf8_lossy(&output.stdout)) + .into_owned(); + let stderr = redactor + .scrub(&String::from_utf8_lossy(&output.stderr)) + .into_owned(); + + if !output.status.success() { + let exit = output + .status + .code() + .map_or_else(|| "signal".to_string(), |code| code.to_string()); + return Err( + ProcessorError::failed(&step.label, &exit, &tail(&stderr)).with_hint( + "installer 的 processor 是官方产物,失败多半是 Java 版本/磁盘权限/网络(部分步骤要联网取映射)", + ), + ); + } + + verify_outputs(step, warnings)?; + Ok(StepOutcome { + index: step.index, + label: step.label.clone(), + status: StepStatus::Executed, + outputs: step.outputs.len(), + elapsed_ms, + stdout_tail: non_empty(tail(&stdout)), + stderr_tail: non_empty(tail(&stderr)), + }) +} + +/// 依赖 jar 是否都在盘上(缺了直接报错:起了 java 也只会得到 `NoClassDefFoundError`)。 +fn ensure_dependencies(step: &ProcessorStep) -> Result<(), ProcessorError> { + if !step.jar.is_file() { + return Err(ProcessorError::library_missing(format!( + "processor {} 的可执行 jar 不在盘上:{}", + step.label, + step.jar.display() + ))); + } + let missing: Vec = step + .classpath + .iter() + .filter(|path| !path.is_file()) + .map(|path| path.display().to_string()) + .collect(); + if missing.is_empty() { + return Ok(()); + } + Err(ProcessorError::library_missing(format!( + "processor {} 有 {} 个依赖 jar 不在盘上:{}", + step.label, + missing.len(), + missing.join(";") + )) + .with_hint("重跑一次 `ull install` 会补齐依赖(下载走镜像/健康度引擎)")) +} + +/// 产物存在性(硬)与摘要(软)校验。 +fn verify_outputs(step: &ProcessorStep, warnings: &mut Vec) -> Result<(), ProcessorError> { + let missing: Vec = step + .outputs + .iter() + .filter(|output| !output.path.is_file()) + .map(|output| output.path.display().to_string()) + .collect(); + if !missing.is_empty() { + return Err(ProcessorError::output_missing(format!( + "processor {} 跑完了但产物没落盘:{}", + step.label, + missing.join(";") + )) + .with_hint("磁盘权限/杀软拦截是常见原因;也可以删掉 versions/ 下这版后重装")); + } + for output in &step.outputs { + let Some(expected) = &output.sha1 else { + continue; + }; + let Ok(actual) = verify::digest_file(&output.path, HashAlgo::Sha1) else { + continue; + }; + if !verify::matches(expected, &actual) { + warnings.push(format!( + "processor {} 的产物摘要与 profile 不符(期望 {expected} 实际 {actual}):非标准 zlib 实现会压出不同字节,按可用处理", + step.label + )); + } + } + Ok(()) +} + +/// 产物是否都已就绪(幂等判据:存在 + 摘要命中;profile 没给摘要就只认存在)。 +fn outputs_satisfied(step: &ProcessorStep, warnings: &mut Vec) -> bool { + if step.outputs.is_empty() { + // 没有产物声明 → 无法判断是否跑过,只能每次都跑(HMCL 同样处理)。 + return false; + } + for output in &step.outputs { + if !output.path.is_file() { + return false; + } + let Some(expected) = &output.sha1 else { + continue; + }; + let Ok(actual) = verify::digest_file(&output.path, HashAlgo::Sha1) else { + return false; + }; + if !verify::matches(expected, &actual) { + warnings.push(format!( + "{} 的摘要与 profile 不符,重新执行该 processor", + output.path.display() + )); + return false; + } + } + true +} diff --git a/crates/core/src/addon/loader/processors/jar.rs b/crates/core/src/addon/loader/processors/jar.rs new file mode 100644 index 0000000..93b265a --- /dev/null +++ b/crates/core/src/addon/loader/processors/jar.rs @@ -0,0 +1,83 @@ +//! 从 processor jar 的 `MANIFEST.MF` 里取 `Main-Class`。 +//! +//! 官方 install profile **不写主类**(`processors[].jar` 只给坐标),主类在 jar 的清单里; +//! 少数 fork 会额外给 `mainClass`(那条路走 [`super::plan::ProcessorStep::main_class`], +//! 不到这里)。清单按 RFC 822 折行:续行以**单个空格**开头。 + +use std::path::Path; + +use super::error::ProcessorError; + +/// 读 jar 的 `Main-Class`。 +/// +/// # Errors +/// +/// jar 打不开/不是 zip(IO/规划错误)、没有 `META-INF/MANIFEST.MF` 或清单里没有 +/// `Main-Class`(规划错误)。 +pub(super) fn read_main_class(jar: &Path) -> Result { + use std::io::Read; + + let file = std::fs::File::open(jar).map_err(|error| { + ProcessorError::io(format!( + "打开 processor jar 失败({}):{error}", + jar.display() + )) + })?; + let mut archive = zip::ZipArchive::new(file).map_err(|error| { + ProcessorError::plan(format!("{} 不是合法 jar:{error}", jar.display())) + })?; + let mut manifest = String::new(); + { + let mut entry = archive.by_name("META-INF/MANIFEST.MF").map_err(|error| { + ProcessorError::plan(format!( + "{} 里没有 META-INF/MANIFEST.MF(拿不到主类):{error}", + jar.display() + )) + })?; + entry.read_to_string(&mut manifest).map_err(|error| { + ProcessorError::io(format!("读 {} 的 MANIFEST 失败:{error}", jar.display())) + })?; + } + parse_main_class(&manifest).ok_or_else(|| { + ProcessorError::plan(format!( + "{} 的 MANIFEST 里没有 Main-Class(install profile 也没给 mainClass)", + jar.display() + )) + }) +} + +/// 从 MANIFEST 文本里取 `Main-Class`(续行以单个空格开头)。 +fn parse_main_class(manifest: &str) -> Option { + let mut lines = manifest.lines().peekable(); + while let Some(line) = lines.next() { + let Some(rest) = line.strip_prefix("Main-Class:") else { + continue; + }; + let mut value = rest.trim().to_string(); + while let Some(next) = lines.peek() { + let Some(continuation) = next.strip_prefix(' ') else { + break; + }; + value.push_str(continuation.trim()); + lines.next(); + } + return (!value.is_empty()).then_some(value); + } + None +} + +#[cfg(test)] +mod tests { + use super::parse_main_class; + + #[test] + fn parses_main_class_with_continuation_lines() { + let manifest = "Manifest-Version: 1.0\r\nMain-Class: com.example.\r\n Tool\r\n\r\n"; + assert_eq!( + parse_main_class(manifest).as_deref(), + Some("com.example.Tool") + ); + assert!(parse_main_class("Manifest-Version: 1.0\n").is_none()); + assert!(parse_main_class("Main-Class: \n").is_none()); + } +} diff --git a/crates/core/src/addon/loader/processors/mod.rs b/crates/core/src/addon/loader/processors/mod.rs new file mode 100644 index 0000000..aa68102 --- /dev/null +++ b/crates/core/src/addon/loader/processors/mod.rs @@ -0,0 +1,56 @@ +//! Installer Processors 执行器(M35):把 install profile 的 `processors[]` 跑起来。 +//! +//! 设计输入:`DevDocs/analyse/05-modloader-modpack.md` §1.2(Forge 新式 installer 的 +//! processor 流水线)与 HMCL `ForgeNewInstallTask` / `ProcessorTask` 的实现。 +//! +//! # 覆盖范围 +//! +//! | 版本段 | 有无客户端 processor | 本模块 | +//! |--------|----------------------|--------| +//! | Forge 1.13 ~ 1.20.x(spec 1) | 有(拆包/映射/打补丁) | ✅ 真跑 | +//! | NeoForge 1.20.5+(26.x 只剩 1 个 `PROCESS_MINECRAFT_JAR`) | 有 | ✅ 真跑 | +//! | Forge spec 0(1.12.2 重建版)/ Fabric / Quilt | 无 | 不需要(走 M32 的 json 路线) | +//! +//! # 流水线 +//! +//! ```text +//! plan install profile 的 data 表 → 变量表({MINECRAFT_JAR}/{SIDE}/[坐标]/'字面量') +//! → 每步 java -cp 参数 + 产物/摘要 + processor 依赖库待下清单 +//! download 依赖库(走 M27 引擎:镜像/健康度/退避/校验) +//! exec 串行外调 java(主类取自 jar 的 MANIFEST)→ 产物存在性(硬)+ 摘要(软) +//! ``` +//! +//! # 三条边界 +//! +//! 1. **不写版本 JSON**:那是 M27 `execute` 的事,且必须等 processors 全部成功 +//! (版本清单存在 = 这一版真的装完了,半成品不许留); +//! 2. **不改共享层**:原版 client jar 先复制到临时目录再交给 processor, +//! processor 只会改临时副本; +//! 3. **不猜**:`data` 表里缺变量、processor 缺 `jar`、产物没落盘,一律明确报错, +//! 不拿半截参数去试(那种失败的 stderr 更难查)。 +//! +//! # 幂等 +//! +//! 两级:①每步按 `outputs` 的 SHA-1 命中整步跳过(重跑零成本、不碰文件系统); +//! ②调用方以「版本 JSON 已落盘」为粗粒度判据整段跳过。 + +pub mod download; +pub mod error; +pub mod exec; +pub mod jar; +pub mod plan; +pub mod prepare; +pub mod spec; +pub mod text; +pub mod vars; + +#[cfg(test)] +mod tests; + +pub use download::download_libraries; +pub use error::{ProcessorError, ProcessorErrorKind}; +pub use exec::{run, select_java, ProcessorRun, StepOutcome, StepSink, StepStatus}; +pub use plan::{ProcessorPlan, ProcessorPlanInput, ProcessorStep, StepOutput}; +pub use spec::{Datum, Processor}; +pub use text::{host_redactor, tail}; +pub use vars::{EmbeddedFile, Variables, VariablesInput, MINECRAFT_JAR_NAME}; diff --git a/crates/core/src/addon/loader/processors/plan.rs b/crates/core/src/addon/loader/processors/plan.rs new file mode 100644 index 0000000..8665ad6 --- /dev/null +++ b/crates/core/src/addon/loader/processors/plan.rs @@ -0,0 +1,366 @@ +//! processor 执行计划:`data` 表展开 → 每步的 `java -cp …` 命令行 → 依赖库待下载清单。 +//! +//! 纯计算(不下载、不起进程),因此 `--dry-run` 能直接把「将执行的 processor 清单 + +//! 所需 Java + 依赖库」打出来。唯一读盘的动作是 [`crate::game::java::requirement_for_version`] +//! 读原版版本 JSON 判 Java 要求——那是诊断信息,读不到就按静态表兜底。 +//! +//! # 为什么依赖库与启动 classpath 要分开 +//! +//! install profile 的 `libraries` 是 **processor 依赖**(installertools / jarsplitter / +//! srgutils…),它们不进游戏 classpath。M32 只把数量记进告警,M35 起真正下载它们 +//! (否则 `java -cp` 一排文件都不在盘上),但仍然**绝不**并进 loader 版本 JSON。 + +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; + +use super::error::ProcessorError; +use super::spec::Processor; +use super::vars::{absolute, Variables, VariablesInput, MINECRAFT_JAR_NAME}; +use crate::addon::loader::forge::InstallProfile; +use crate::addon::loader::{maven, LoaderKind}; +use crate::download::ResourceKind; +use crate::game::arguments::Environment; +use crate::game::install::libraries; +use crate::game::install::{FileRole, PlanFile}; +use crate::game::java::{requirement_for_version, JavaRequirement}; +use crate::game::layout::GameLayout; +use crate::game::library::MavenCoordinate; + +/// 一个 processor 的某个产物。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StepOutput { + /// 产物绝对路径。 + pub path: PathBuf, + /// 期望 SHA-1(profile 没给或给的不是摘要时为 `None`,只能按存在性判定)。 + pub sha1: Option, +} + +/// 一个待执行的 processor(已展开参数,不再依赖 install profile)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProcessorStep { + /// 序号(从 1 起,顺序即执行顺序)。 + pub index: usize, + /// 人读标识(jar 的 maven 坐标)。 + pub label: String, + /// 可执行 jar 的磁盘路径。 + pub jar: PathBuf, + /// 主类(profile 没写就留 `None`,执行期读 jar 的 MANIFEST)。 + pub main_class: Option, + /// `-cp` 的依赖 jar(不含 `jar` 自己)。 + pub classpath: Vec, + /// 已展开的参数。 + pub args: Vec, + /// 产物与期望摘要。 + pub outputs: Vec, +} + +impl ProcessorStep { + /// 一步的 `-cp` 全串(依赖在前、可执行 jar 在后,与 HMCL 一致)。 + /// + /// # Errors + /// + /// 路径里含分隔符时返回 IO 错误(Windows 的 `;` 不可能出现在合法路径里, + /// 真出现说明路径是拼错的)。 + pub fn classpath_join(&self) -> Result { + let paths = self + .classpath + .iter() + .chain(std::iter::once(&self.jar)) + .map(|path| path.as_path()); + std::env::join_paths(paths) + .map(|joined| joined.to_string_lossy().into_owned()) + .map_err(|error| ProcessorError::io(format!("classpath 拼不起来:{error}"))) + } +} + +/// [`ProcessorPlan::build`] 的输入。 +pub struct ProcessorPlanInput<'a> { + /// 加载器种类(只影响补库时的 maven 基址兜底)。 + pub kind: LoaderKind, + /// 目标原版版本。 + pub mc_version: &'a str, + /// loader 版本 id(依赖库 `hint=local` 的落点)。 + pub version_id: &'a str, + /// installer 里读出来的 install profile。 + pub profile: &'a InstallProfile, + /// 游戏目录布局。 + pub layout: &'a GameLayout, + /// installer jar 的磁盘路径。 + pub installer: &'a Path, + /// 临时目录(解包/`MINECRAFT_JAR` 副本都落这里)。 + pub scratch: &'a Path, + /// 规则求值环境。 + pub environment: &'a Environment, +} + +/// processor 执行计划。 +#[derive(Debug, Clone)] +pub struct ProcessorPlan { + /// loader 版本 id。 + pub version_id: String, + /// installer jar 路径。 + pub installer: PathBuf, + /// 临时目录。 + pub scratch: PathBuf, + /// 原版 client jar(复制源)。 + pub minecraft_jar: PathBuf, + /// 交给 processor 的 client jar 副本(`{MINECRAFT_JAR}`)。 + pub minecraft_jar_copy: PathBuf, + /// 展开后的变量表。 + pub variables: Variables, + /// 按顺序待执行的 processor。 + pub steps: Vec, + /// processor 依赖库(走 M27 引擎下载)。 + pub libraries: Vec, + /// 跑 installer 需要的 Java 要求。 + pub java_requirement: JavaRequirement, + /// 规划期告警(不致命)。 + pub warnings: Vec, +} + +impl ProcessorPlan { + /// 组计划。 + /// + /// # Errors + /// + /// `data` 表变量缺定义、构件坐标不合法、processor 缺 `jar` 字段时返回规划错误。 + pub fn build(input: ProcessorPlanInput<'_>) -> Result { + let scratch = absolute(input.scratch); + let variables = Variables::build(VariablesInput { + data: &input.profile.data, + layout: input.layout, + mc_version: input.mc_version, + installer: input.installer, + scratch: &scratch, + })?; + + let mut warnings = Vec::new(); + let mut steps = Vec::new(); + let mut needed: Vec<(String, PathBuf)> = Vec::new(); + for processor in input + .profile + .processors + .iter() + .filter(|processor| processor.applies_to_client()) + { + let coordinate = processor.jar_coordinate().ok_or_else(|| { + ProcessorError::plan("install profile 里有 processor 缺 `jar` 字段") + })?; + let jar = library_path(input.layout, coordinate)?; + needed.push((coordinate.to_string(), jar.clone())); + let mut classpath = Vec::with_capacity(processor.classpath.len()); + for entry in &processor.classpath { + let path = library_path(input.layout, entry)?; + needed.push((entry.trim().to_string(), path.clone())); + classpath.push(path); + } + let mut args = Vec::with_capacity(processor.args.len()); + for arg in &processor.args { + args.push(variables.expand(arg)?); + } + let outputs = step_outputs(&variables, processor, coordinate, &scratch, &mut warnings)?; + steps.push(ProcessorStep { + index: steps.len() + 1, + label: coordinate.to_string(), + jar, + main_class: processor.main_class().map(str::to_string), + classpath, + args, + outputs, + }); + } + + let (mut libraries, library_warnings) = libraries::plan( + input.layout, + input.version_id, + &input.profile.libraries, + input.environment, + ); + warnings.extend(library_warnings); + let base = library_base(input.kind, input.profile); + for (coordinate, dest) in &needed { + if libraries.iter().any(|file| &file.dest == dest) { + continue; + } + warnings.push(format!( + "processor 依赖 {coordinate} 不在 install profile 的 libraries 节里,按 maven 基址补一条" + )); + let Ok(parsed) = MavenCoordinate::parse(coordinate) else { + continue; + }; + libraries.push(PlanFile { + role: FileRole::Library, + name: coordinate.clone(), + url: join_url(&base, &parsed.relative_path()), + sha1: None, + size: None, + dest: dest.clone(), + resource: ResourceKind::Library, + }); + } + // NeoForge 的 install profile 会把同一条依赖**声明多次**(实测 21.1.80: + // cli-utils / srgutils / jopt-simple / gson 各 2~3 次)。不下重:同一落点被下两次 + // 会在同一个 `.part` 中间文件上互相抢(真机现场:原子落盘 os error 2)。 + let (libraries, duplicated) = dedupe_by_dest(libraries); + if duplicated > 0 { + warnings.push(format!( + "install profile 重复声明了 {duplicated} 个 processor 依赖,已按落点去重" + )); + } + + let java_requirement = requirement_for_version(input.layout, input.mc_version); + let minecraft_jar_copy = variables + .get("MINECRAFT_JAR") + .map_or_else(|| scratch.join(MINECRAFT_JAR_NAME), PathBuf::from); + Ok(Self { + version_id: input.version_id.to_string(), + installer: absolute(input.installer), + scratch, + minecraft_jar: input.layout.version_jar(input.mc_version), + minecraft_jar_copy, + variables, + steps, + libraries, + java_requirement, + warnings, + }) + } + + /// 是否一个 processor 都没有。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.steps.is_empty() + } + + /// 汇总行数(CLI 人读输出用)。 + #[must_use] + pub fn step_count(&self) -> usize { + self.steps.len() + } + + /// `--dry-run` / 报告里的 processors 载荷。 + #[must_use] + pub fn to_json(&self) -> Value { + json!({ + "count": self.steps.len(), + "installer": self.installer.display().to_string(), + "scratch": self.scratch.display().to_string(), + "minecraft_jar": self.minecraft_jar.display().to_string(), + "libraries": self.libraries.len(), + "library_files": self.libraries.iter().map(|file| json!({ + "name": file.name, + "url": file.url, + "dest": file.dest.display().to_string(), + "sha1": file.sha1, + "size": file.size, + })).collect::>(), + "variables": self.variables.len(), + "embedded_files": self.variables.embedded().len(), + "java_requirement": { + "minimum": self.java_requirement.minimum, + "maximum": self.java_requirement.maximum, + "describe": self.java_requirement.describe(), + "source": self.java_requirement.source.as_str(), + }, + "steps": self.steps.iter().map(step_json).collect::>(), + "warnings": self.warnings, + }) + } +} + +/// 一条 processor 的 JSON 视图。 +fn step_json(step: &ProcessorStep) -> Value { + json!({ + "index": step.index, + "jar": step.label, + "jar_path": step.jar.display().to_string(), + "main_class": step.main_class, + "classpath": step.classpath.len(), + "args": step.args, + "outputs": step.outputs.iter().map(|output| json!({ + "path": output.path.display().to_string(), + "sha1": output.sha1, + })).collect::>(), + }) +} + +/// 展开一步的 `outputs` 表(路径 + 期望摘要)。 +fn step_outputs( + variables: &Variables, + processor: &Processor, + coordinate: &str, + scratch: &Path, + warnings: &mut Vec, +) -> Result, ProcessorError> { + let mut outputs = Vec::with_capacity(processor.outputs.len()); + for (raw_path, raw_sha1) in &processor.outputs { + let expanded = variables.expand(raw_path)?; + let path = PathBuf::from(&expanded); + let path = if path.is_absolute() { + path + } else { + scratch.join(path) + }; + let expanded_sha1 = variables.expand(raw_sha1).unwrap_or_default(); + let sha1 = is_sha1(&expanded_sha1).then(|| expanded_sha1.to_ascii_lowercase()); + if sha1.is_none() { + warnings.push(format!( + "processor {coordinate} 的输出 {} 没给 SHA-1,只能按存在性判定", + path.display() + )); + } + outputs.push(StepOutput { path, sha1 }); + } + Ok(outputs) +} + +/// 40 位十六进制(SHA-1 的样子)。 +fn is_sha1(value: &str) -> bool { + value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit()) +} + +/// 坐标 → `libraries/` 下的路径。 +fn library_path(layout: &GameLayout, raw: &str) -> Result { + let coordinate = MavenCoordinate::parse(raw.trim()).map_err(|error| { + ProcessorError::plan(format!("processor 依赖坐标不合法:`{raw}`({error})")) + })?; + Ok(layout.libraries_dir().join(coordinate.relative_path())) +} + +/// 补库时用的 maven 基址:install profile 自己声明的 `url` 优先,否则按加载器兜底。 +fn library_base(kind: LoaderKind, profile: &InstallProfile) -> String { + profile + .libraries + .iter() + .filter_map(|library| library.url.as_deref()) + .map(str::trim) + .find(|url| !url.is_empty()) + .map_or_else(|| maven::base(kind).to_string(), str::to_string) +} + +/// `基址 + 相对路径`(保证中间恰好一个 `/`)。 +fn join_url(base: &str, relative: &Path) -> String { + let path = relative.to_string_lossy().replace('\\', "/"); + if base.ends_with('/') { + format!("{base}{path}") + } else { + format!("{base}/{path}") + } +} + +/// 按落点去重(保留首次出现的条目),返回(去重后的清单,丢掉几条)。 +fn dedupe_by_dest(files: Vec) -> (Vec, usize) { + let total = files.len(); + let mut seen: Vec = Vec::with_capacity(total); + let mut kept: Vec = Vec::with_capacity(total); + for file in files { + if seen.contains(&file.dest) { + continue; + } + seen.push(file.dest.clone()); + kept.push(file); + } + let dropped = total - kept.len(); + (kept, dropped) +} diff --git a/crates/core/src/addon/loader/processors/prepare.rs b/crates/core/src/addon/loader/processors/prepare.rs new file mode 100644 index 0000000..aa4a49d --- /dev/null +++ b/crates/core/src/addon/loader/processors/prepare.rs @@ -0,0 +1,201 @@ +//! 临时目录准备:建目录、解包 installer 内的文件、复制原版 client jar。 +//! +//! 三条都是「起 java 之前的脏活」,失败一律归 `processor_io`/`processor_plan` +//! (区别于 processor 自己跑挂的 `processor_failed`)。 + +use super::error::ProcessorError; +use super::plan::ProcessorPlan; + +/// 单条目的**预分配**上限(64 MiB):installer jar 下载后只校验「是合法 zip」, +/// 没有校验和(`meta.rs` 的 `fetch_installer`),因此 zip header 里声明的解压尺寸 +/// 是不可信输入——损坏或镜像投毒的 installer 可以声明天文数字,让一次小文件解包直接 +/// 申请多 GB 内存。`Vec` 仍按实际读入增长,合法条目行为不变。 +const PREALLOC_MAX: u64 = 64 << 20; + +/// 单条目的**实际字节**上限(256 MiB):预分配封顶挡不住「声明小、解压极大」的 +/// zip bomb,所以读入过程本身也要有硬顶。超过就明确报错(不静默截断)。 +const MAX_EMBEDDED_BYTES: u64 = 256 << 20; + +/// 执行前的准备:建临时目录、解包 installer 内的文件、复制原版 client jar。 +pub(super) fn prepare(plan: &ProcessorPlan) -> Result<(), ProcessorError> { + std::fs::create_dir_all(&plan.scratch).map_err(|error| { + ProcessorError::io(format!( + "建临时目录失败({}):{error}", + plan.scratch.display() + )) + })?; + extract_embedded(plan)?; + copy_minecraft_jar(plan) +} + +/// 把 install profile 声明要解包的文件从 installer zip 里抽到临时目录。 +fn extract_embedded(plan: &ProcessorPlan) -> Result<(), ProcessorError> { + let embedded = plan.variables.embedded(); + if embedded.is_empty() { + return Ok(()); + } + use std::io::Read; + + let installer = &plan.installer; + let file = std::fs::File::open(installer).map_err(|error| { + ProcessorError::io(format!( + "打开 installer 失败({}):{error}", + installer.display() + )) + })?; + let mut archive = zip::ZipArchive::new(file).map_err(|error| { + ProcessorError::plan(format!("{} 不是合法 zip:{error}", installer.display())) + })?; + for item in embedded { + let mut entry = archive.by_name(&item.entry).map_err(|error| { + ProcessorError::plan(format!( + "installer 里没有 {}(data 表声明要解包):{error}", + item.entry + )) + })?; + let mut bytes = Vec::with_capacity(prealloc_hint(entry.size())); + let read = (&mut entry) + .take(MAX_EMBEDDED_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| { + ProcessorError::io(format!("读 installer 条目 {} 失败:{error}", item.entry)) + })?; + if read as u64 > MAX_EMBEDDED_BYTES { + return Err(ProcessorError::plan(format!( + "installer 条目 {} 解压后超过 {} MiB,拒绝继续(installer 可能损坏或被投毒)", + item.entry, + MAX_EMBEDDED_BYTES >> 20 + ))); + } + crate::platform::atomic::write_atomic(&item.dest, &bytes).map_err(|error| { + ProcessorError::io(format!( + "写临时文件失败({}):{error}", + item.dest.display() + )) + })?; + } + Ok(()) +} + +/// 预分配提示:**不信任** header 声明的尺寸,封顶 [`PREALLOC_MAX`]。 +fn prealloc_hint(declared: u64) -> usize { + declared.min(PREALLOC_MAX) as usize +} + +/// 复制原版 client jar 到临时目录(processor 会改写输入,不能让它碰共享层)。 +fn copy_minecraft_jar(plan: &ProcessorPlan) -> Result<(), ProcessorError> { + let source = &plan.minecraft_jar; + let target = &plan.minecraft_jar_copy; + let fresh = match (std::fs::metadata(source), std::fs::metadata(target)) { + (Ok(left), Ok(right)) => left.len() != right.len(), + (Ok(_), Err(_)) => true, + (Err(error), _) => { + return Err(ProcessorError::io(format!( + "原版 client jar 不在盘上({}):{error}", + source.display() + )) + .with_hint("先装原版:`ull install `")) + } + }; + if !fresh { + return Ok(()); + } + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + ProcessorError::io(format!("建目录失败({}):{error}", parent.display())) + })?; + } + std::fs::copy(source, target).map_err(|error| { + ProcessorError::io(format!( + "复制 {} → {} 失败:{error}", + source.display(), + target.display() + )) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::{prealloc_hint, prepare, PREALLOC_MAX}; + use crate::addon::loader::forge::InstallProfile; + use crate::addon::loader::processors::{ProcessorPlan, ProcessorPlanInput}; + use crate::addon::loader::LoaderKind; + use crate::game::arguments::Environment; + use crate::game::layout::GameLayout; + + /// 一步 processor + 一个要从 installer 里解包的 data 值。 + const PROFILE: &str = r#"{ + "spec": 1, "minecraft": "1.20.1", "json": "/version.json", + "processors": [{"jar": "net.minecraftforge:binarypatcher:1.1.1", "args": ["--apply", "{BINPATCH}"]}], + "libraries": [], + "data": {"BINPATCH": {"client": "/data/client.lzma"}} + }"#; + + #[test] + fn preallocation_is_capped_so_a_forged_header_cannot_oom() { + // installer jar 没有校验和:header 可以声称 16 EiB 的解压尺寸。 + assert_eq!(prealloc_hint(u64::MAX), PREALLOC_MAX as usize); + assert_eq!(prealloc_hint(1 << 40), PREALLOC_MAX as usize); + // 合法条目照旧按实际尺寸预分配(封顶不影响常见大小)。 + assert_eq!(prealloc_hint(0), 0); + assert_eq!(prealloc_hint(4096), 4096); + assert_eq!(prealloc_hint(PREALLOC_MAX), PREALLOC_MAX as usize); + assert_eq!(prealloc_hint(PREALLOC_MAX - 1), PREALLOC_MAX as usize - 1); + } + + #[test] + fn prepare_extracts_the_declared_entry_and_copies_the_client_jar() { + let root = super::super::tests::temp_root("prepare-extract"); + let layout = GameLayout::new(&root); + let profile: InstallProfile = serde_json::from_str(PROFILE).expect("夹具应可解析"); + let installer = root.join("cache/installer.jar"); + write_zip(&installer, "data/client.lzma", b"patch-bytes"); + let vanilla = root.join("versions/1.20.1/1.20.1.jar"); + std::fs::create_dir_all(vanilla.parent().expect("有父目录")).expect("建目录"); + std::fs::write(&vanilla, b"client-jar").expect("写原版 jar"); + + let plan = ProcessorPlan::build(ProcessorPlanInput { + kind: LoaderKind::Forge, + mc_version: "1.20.1", + version_id: "1.20.1-forge-47.2.0", + profile: &profile, + layout: &layout, + installer: &installer, + scratch: &root.join("scratch"), + environment: &Environment::host(), + }) + .expect("计划应能组出来"); + assert_eq!(plan.variables.embedded().len(), 1); + + prepare(&plan).expect("准备应成功"); + let dest = &plan.variables.embedded()[0].dest; + assert_eq!(std::fs::read(dest).expect("读解包产物"), b"patch-bytes"); + // processor 拿到的是副本,共享层的原版 jar 内容不受影响。 + assert_eq!( + std::fs::read(&plan.minecraft_jar_copy).expect("读 client jar 副本"), + b"client-jar" + ); + assert_ne!(plan.minecraft_jar_copy, plan.minecraft_jar); + // 幂等:重跑按大小判定,不重复复制、不报错。 + prepare(&plan).expect("重跑应成功"); + } + + /// 写一个只含一条 Stored 条目的 zip(installer 夹具)。 + fn write_zip(path: &Path, entry: &str, bytes: &[u8]) { + use std::io::Write; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("建目录"); + } + let file = std::fs::File::create(path).expect("建 zip"); + let mut writer = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + writer.start_file(entry, options).expect("写条目"); + writer.write_all(bytes).expect("写内容"); + writer.finish().expect("收尾 zip"); + } +} diff --git a/crates/core/src/addon/loader/processors/spec.rs b/crates/core/src/addon/loader/processors/spec.rs new file mode 100644 index 0000000..09372cc --- /dev/null +++ b/crates/core/src/addon/loader/processors/spec.rs @@ -0,0 +1,121 @@ +//! install profile 的 `processors[]` 与 `data` 表:**纯解析,不动作**。 +//! +//! 字段含义照 Forge installer spec(与 HMCL `ForgeNewInstallProfile.Processor/Datum` 一致): +//! +//! ```json +//! "processors": [{ +//! "sides": ["client"], // 缺省 = 两侧都跑 +//! "jar": "net.minecraftforge:installertools:1.3.0", // 可执行 jar(主类在 MANIFEST 里) +//! "classpath": ["net.minecraftforge:jarsplitter:1.1.4"], // 依赖 jar(jar 自己不在里面) +//! "args": ["--task", "MCP_DATA", "--output", "{MAPPINGS}"], +//! "outputs": {"{MAPPINGS}": "ab12…(期望 SHA-1)"} +//! }], +//! "data": {"MAPPINGS": {"client": "[de.oceanlabs.mcp:mcp_config:1.20.1@zip]", "server": "…"}} +//! ``` +//! +//! 三条容易看错的点: +//! +//! 1. `data` 的每条是**按侧**的(`client` / `server`),client 侧只取 `.client`; +//! 2. 官方只写 `jar`,**不写主类**——主类在 jar 的 `META-INF/MANIFEST.MF`(`Main-Class`); +//! 少数 fork 会额外给 `mainClass`,所以这里留了口子; +//! 3. `classpath` **不含** `jar` 自己(Forge 的约定),拼 `-cp` 时要补上。 + +use std::collections::BTreeMap; + +use serde::Deserialize; + +/// install profile 里的一个 processor。 +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +pub struct Processor { + /// 生效侧(`client` / `server`;空表示两侧都要)。 + #[serde(default)] + pub sides: Vec, + /// 可执行 jar 的 maven 坐标。 + #[serde(default)] + pub jar: Option, + /// 主类覆盖(官方 profile 不写,留给少数 fork)。 + #[serde(default, rename = "mainClass")] + pub main_class: Option, + /// 依赖 jar 的 maven 坐标(**不含** `jar` 自己)。 + #[serde(default)] + pub classpath: Vec, + /// 命令行参数(可含 `{VAR}` / `[坐标]` / `'字面量'` 三种占位)。 + #[serde(default)] + pub args: Vec, + /// 产物表:路径占位 → 期望 SHA-1(既有产物可用于幂等跳过)。 + #[serde(default)] + pub outputs: BTreeMap, +} + +impl Processor { + /// 是否在 client 侧生效。 + #[must_use] + pub fn applies_to_client(&self) -> bool { + self.sides.is_empty() || self.sides.iter().any(|side| side == "client") + } + + /// `jar` 坐标(去掉首尾空白;缺失或空串返回 `None`)。 + #[must_use] + pub fn jar_coordinate(&self) -> Option<&str> { + self.jar + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + } + + /// 主类覆盖(缺失或空串返回 `None`)。 + #[must_use] + pub fn main_class(&self) -> Option<&str> { + self.main_class + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + } +} + +/// `data` 表的一条:按侧给值(client 侧只用 `.client`)。 +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +pub struct Datum { + /// client 侧的值(`[坐标]` / `'字面量'` / installer 内的文件路径)。 + #[serde(default)] + pub client: Option, + /// server 侧的值(本期不跑 server 安装,只解析不动作)。 + #[serde(default)] + pub server: Option, +} + +impl Datum { + /// client 侧的值(空串视为没有)。 + #[must_use] + pub fn client_value(&self) -> Option<&str> { + self.client + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + } +} + +#[cfg(test)] +mod tests { + use super::Processor; + + #[test] + fn sides_empty_means_both_and_client_subset_is_recognised() { + let both: Processor = serde_json::from_str(r#"{"jar": "g:a:1"}"#).expect("应可解析"); + assert!(both.applies_to_client()); + let server: Processor = + serde_json::from_str(r#"{"sides": ["server"], "jar": "g:a:1"}"#).expect("应可解析"); + assert!(!server.applies_to_client()); + let client: Processor = + serde_json::from_str(r#"{"sides": ["client"], "jar": "g:a:1"}"#).expect("应可解析"); + assert!(client.applies_to_client()); + } + + #[test] + fn blank_jar_and_main_class_are_treated_as_absent() { + let processor: Processor = + serde_json::from_str(r#"{"jar": " ", "mainClass": ""}"#).expect("应可解析"); + assert!(processor.jar_coordinate().is_none()); + assert!(processor.main_class().is_none()); + } +} diff --git a/crates/core/src/addon/loader/processors/tests/exec.rs b/crates/core/src/addon/loader/processors/tests/exec.rs new file mode 100644 index 0000000..7354b00 --- /dev/null +++ b/crates/core/src/addon/loader/processors/tests/exec.rs @@ -0,0 +1,185 @@ +//! 执行器:幂等跳过(不碰文件系统)、依赖缺失分流、起不来 java 的分类、脱敏器。 + +use std::path::{Path, PathBuf}; + +use crate::addon::loader::forge::InstallProfile; +use crate::addon::loader::processors::{host_redactor, run}; +use crate::addon::loader::processors::{ProcessorErrorKind, ProcessorPlan, ProcessorPlanInput}; +use crate::addon::loader::LoaderKind; +use crate::game::arguments::Environment; +use crate::game::java::{JavaOrigin, JavaRuntime}; +use crate::game::layout::GameLayout; + +use super::temp_root; + +/// 一个最小 profile:一步 processor,产物是临时目录里的 `built.jar`(只按存在性判定)。 +const MINIMAL_PROFILE: &str = r#"{ + "spec": 1, + "minecraft": "1.20.1", + "json": "/version.json", + "processors": [{ + "jar": "net.minecraftforge:installertools:1.3.0", + "classpath": ["net.minecraftforge:jarsplitter:1.1.4"], + "args": ["--task", "SPLIT"], + "outputs": {"{OUT}": "'not-a-sha1'"} + }], + "libraries": [], + "data": {"OUT": {"client": "'built.jar'"}} +}"#; + +fn minimal_profile() -> InstallProfile { + serde_json::from_str(MINIMAL_PROFILE).expect("夹具应可解析") +} + +/// 组装计划(依赖库不落盘:执行器不负责下载,缺失由用例自己摆)。 +fn plan(root: &Path) -> ProcessorPlan { + let profile = minimal_profile(); + let layout = GameLayout::new(root); + ProcessorPlan::build(ProcessorPlanInput { + kind: LoaderKind::Forge, + mc_version: "1.20.1", + version_id: "1.20.1-forge-47.2.0", + profile: &profile, + layout: &layout, + installer: &root.join("installer.jar"), + scratch: &root.join("scratch"), + environment: &Environment::host(), + }) + .expect("计划应能组出来") +} + +/// 一个「必然起不来」的 java:这些用例只验证分类与顺序,不验证 java 行为。 +fn missing_java() -> JavaRuntime { + JavaRuntime::unknown( + PathBuf::from("/nonexistent/ull-test-java"), + JavaOrigin::Path, + ) +} + +fn touch(path: &Path) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("建目录"); + } + std::fs::write(path, b"stub").expect("写夹具文件"); +} + +/// 写一个最小 jar(可选带 `Main-Class`),给"主类从 MANIFEST 读"的用例用。 +fn write_jar(path: &Path, main_class: Option<&str>) { + use std::io::Write; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("建目录"); + } + let file = std::fs::File::create(path).expect("建 jar"); + let mut writer = zip::ZipWriter::new(file); + let options = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + if let Some(main_class) = main_class { + writer + .start_file("META-INF/MANIFEST.MF", options) + .expect("写 manifest 条目"); + write!( + writer, + "Manifest-Version: 1.0\r\nMain-Class: {main_class}\r\n\r\n" + ) + .expect("写 manifest"); + } + writer.finish().expect("收尾 jar"); +} + +#[test] +fn ready_outputs_skip_the_whole_step_without_touching_the_disk() { + let root = temp_root("exec-idempotent"); + let plan = plan(&root); + // 产物已就绪:原版 client jar 压根不存在也无所谓——幂等快路径不该去复制它。 + touch(&plan.steps[0].outputs[0].path); + + let run = run(&plan, &missing_java(), None).expect("产物齐备时不该跑任何进程"); + assert_eq!(run.executed(), 0); + assert_eq!(run.skipped(), 1); + assert_eq!(run.steps[0].status.as_str(), "skipped"); + assert_eq!(run.to_json()["steps"][0]["status"], "skipped"); + // prepare 不该跑:原版 jar 压根不存在、client jar 副本没被复制。 + assert!(!plan.minecraft_jar_copy.exists()); +} + +#[test] +fn missing_minecraft_jar_is_reported_before_any_java_runs() { + let root = temp_root("exec-no-vanilla"); + let plan = plan(&root); + let error = run(&plan, &missing_java(), None).expect_err("缺原版 jar 必须报错"); + assert_eq!(error.kind(), ProcessorErrorKind::Io); + assert!( + error.message().contains("原版 client jar"), + "{}", + error.message() + ); +} + +#[test] +fn missing_processor_jar_is_a_library_error_naming_the_file() { + let root = temp_root("exec-no-lib"); + let plan = plan(&root); + touch(&plan.minecraft_jar); + + let error = run(&plan, &missing_java(), None).expect_err("缺依赖必须报错"); + assert_eq!(error.kind(), ProcessorErrorKind::Library); + assert!( + error.message().contains("installertools-1.3.0.jar"), + "{}", + error.message() + ); +} + +#[test] +fn a_java_that_cannot_start_is_an_io_error_with_a_hint() { + let root = temp_root("exec-no-java"); + let plan = plan(&root); + touch(&plan.minecraft_jar); + // 可执行 jar 得是真的 zip + MANIFEST:主类只从那儿来(profile 没写 mainClass)。 + write_jar( + &plan.steps[0].jar, + Some("net.minecraftforge.installertools.Main"), + ); + touch(&plan.steps[0].classpath[0]); + + let error = run(&plan, &missing_java(), None).expect_err("java 起不来必须报错"); + assert_eq!(error.kind(), ProcessorErrorKind::Io); + assert!( + error.message().contains("起 java 失败"), + "{}", + error.message() + ); + assert!(error.hint().is_some()); + // 走到这一步说明临时目录、MC jar 副本都准备好了(顺序:prepare → 起进程)。 + assert!(plan.minecraft_jar_copy.is_file()); +} + +#[test] +fn a_jar_without_a_main_class_is_a_plan_error() { + let root = temp_root("exec-no-main"); + let plan = plan(&root); + touch(&plan.minecraft_jar); + write_jar(&plan.steps[0].jar, None); + touch(&plan.steps[0].classpath[0]); + + let error = run(&plan, &missing_java(), None).expect_err("拿不到主类必须报错"); + assert_eq!(error.kind(), ProcessorErrorKind::Plan); + assert!(error.message().contains("主类"), "{}", error.message()); +} + +#[test] +fn host_redactor_masks_the_user_home_directory() { + let redactor = host_redactor(); + let home = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")); + let Some(home) = home else { + return; // 极简环境(无 HOME/USERPROFILE)时跳过 + }; + let text = format!("{}/game/libraries/x.jar", home.to_string_lossy()); + let scrubbed = redactor.scrub(&text); + assert!(scrubbed.contains(""), "{scrubbed}"); + assert!( + !scrubbed.contains(&home.to_string_lossy().to_string()), + "{scrubbed}" + ); +} diff --git a/crates/core/src/addon/loader/processors/tests/mod.rs b/crates/core/src/addon/loader/processors/tests/mod.rs new file mode 100644 index 0000000..6e706f4 --- /dev/null +++ b/crates/core/src/addon/loader/processors/tests/mod.rs @@ -0,0 +1,71 @@ +//! `addon::loader::processors` 的单元测试:占位替换 / 计划形状 / 执行与失败分流 / 幂等跳过。 +//! +//! 刻意**全部离线**:installer 用夹具 JSON 描述,依赖库的"在不在盘上"由测试自己摆, +//! 真正外调 java 的用例只走「起不来」这一条确定性分支(假 java 路径不存在), +//! 真机执行由验收(Forge 1.20.1 / NeoForge 21.1.80)覆盖。 + +mod exec; +mod plan; +mod vars; + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::addon::loader::forge::InstallProfile; + +/// 实测 Forge 1.20.1-47.2.0 的形状(裁剪到 3 个 processor:2 个 client 侧 + 1 个仅 server)。 +pub(crate) const FORGE_PROFILE: &str = r#"{ + "spec": 1, + "profile": "forge", + "version": "1.20.1-forge-47.2.0", + "minecraft": "1.20.1", + "json": "/version.json", + "processors": [ + { + "sides": ["client"], + "jar": "net.minecraftforge:installertools:1.3.0", + "classpath": ["net.minecraftforge:jarsplitter:1.1.4"], + "args": ["--task", "MCP_DATA", "--input", "{MAPPINGS}", "--output", "{MC_SLIM}", "--side", "{SIDE}"], + "outputs": {"{MC_SLIM}": "aa11bb22cc33dd44ee55ff660011223344556677"} + }, + { + "sides": ["server"], + "jar": "net.minecraftforge:installertools:1.3.0", + "classpath": [], + "args": ["--task", "SERVER"], + "outputs": {} + }, + { + "jar": "net.minecraftforge:jarsplitter:1.1.4", + "classpath": ["net.minecraftforge:installertools:1.3.0"], + "args": ["--input", "{MINECRAFT_JAR}", "--output", "{MC_EXTRA}", "--singleThreaded"], + "outputs": {"{MC_EXTRA}": "'not-a-sha1'"} + } + ], + "libraries": [ + {"name": "net.minecraftforge:installertools:1.3.0", "url": "https://maven.minecraftforge.net/"}, + {"name": "net.minecraftforge:jarsplitter:1.1.4", "url": "https://maven.minecraftforge.net/"} + ], + "data": { + "MAPPINGS": {"client": "[de.oceanlabs.mcp:mcp_config:1.20.1-20230612.114412@zip]", "server": "[de.oceanlabs.mcp:mcp_config:1.20.1-20230612.114412@zip]"}, + "MC_SLIM": {"client": "[net.minecraft:client:1.20.1-20230612.114412:slim]", "server": "[net.minecraft:server:1.20.1-20230612.114412:slim]"}, + "MC_EXTRA": {"client": "[net.minecraft:client:1.20.1-20230612.114412:extra]", "server": "[net.minecraft:server:1.20.1-20230612.114412:extra]"}, + "BINPATCH": {"client": "/data/client.lzma", "server": "/data/server.lzma"}, + "SERVER_ONLY": {"server": "'server'"} + } +}"#; + +/// 夹具 profile。 +pub(crate) fn profile() -> InstallProfile { + serde_json::from_str(FORGE_PROFILE).expect("夹具 install profile 应可解析") +} + +/// 一次性临时目录(用例结束不清理,落在系统 temp 下)。 +pub(crate) fn temp_root(tag: &str) -> PathBuf { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "ull-processors-{}-{tag}-{unique}", + std::process::id() + )) +} diff --git a/crates/core/src/addon/loader/processors/tests/plan.rs b/crates/core/src/addon/loader/processors/tests/plan.rs new file mode 100644 index 0000000..c5bce32 --- /dev/null +++ b/crates/core/src/addon/loader/processors/tests/plan.rs @@ -0,0 +1,224 @@ +//! 执行计划:client 侧过滤、参数展开、产物/摘要、依赖库补齐、Java 要求。 + +use crate::addon::loader::forge::InstallProfile; +use crate::addon::loader::processors::{ProcessorErrorKind, ProcessorPlan, ProcessorPlanInput}; +use crate::addon::loader::LoaderKind; +use crate::game::arguments::Environment; +use crate::game::layout::GameLayout; + +use super::{profile, temp_root}; + +fn plan_for(root: &std::path::Path, profile: &InstallProfile) -> ProcessorPlan { + let layout = GameLayout::new(root); + ProcessorPlan::build(ProcessorPlanInput { + kind: LoaderKind::Forge, + mc_version: "1.20.1", + version_id: "1.20.1-forge-47.2.0", + profile, + layout: &layout, + installer: &root.join("cache/forge-installer.jar"), + scratch: &root.join("cache/processors"), + environment: &Environment::host(), + }) + .expect("计划应能组出来") +} + +#[test] +fn only_client_side_processors_become_steps_in_declared_order() { + let root = temp_root("plan-order"); + let plan = plan_for(&root, &profile()); + + assert_eq!(plan.step_count(), 2, "server-only 的 processor 必须被过滤"); + assert_eq!(plan.steps[0].index, 1); + assert_eq!( + plan.steps[0].label, + "net.minecraftforge:installertools:1.3.0" + ); + assert_eq!(plan.steps[1].index, 2); + assert_eq!(plan.steps[1].label, "net.minecraftforge:jarsplitter:1.1.4"); + assert_eq!(plan.steps[1].classpath.len(), 1); + assert!(!plan.is_empty()); +} + +#[test] +fn args_are_expanded_with_paths_and_the_client_side() { + let root = temp_root("plan-args"); + let plan = plan_for(&root, &profile()); + let args = &plan.steps[0].args; + + assert_eq!(args[0], "--task"); + assert_eq!(args[1], "MCP_DATA"); + assert!( + args[3].ends_with("mcp_config-1.20.1-20230612.114412.zip"), + "输入构件应展开成 libraries 路径:{}", + args[3] + ); + assert!( + args[5].ends_with("client-1.20.1-20230612.114412-slim.jar"), + "产物路径应按 data 表展开:{}", + args[5] + ); + assert_eq!(args[7], "client"); + // 第二步用的是临时目录里的 client jar 副本,不是共享层的原版 jar。 + assert_eq!( + plan.steps[1].args[1], + plan.minecraft_jar_copy.to_string_lossy() + ); + assert!(plan + .minecraft_jar_copy + .to_string_lossy() + .ends_with("minecraft.jar")); + assert!(!plan.minecraft_jar_copy.starts_with(root.join("versions"))); +} + +#[test] +fn outputs_carry_paths_and_only_real_sha1s() { + let root = temp_root("plan-outputs"); + let plan = plan_for(&root, &profile()); + + let first = &plan.steps[0].outputs; + assert_eq!(first.len(), 1); + assert_eq!( + first[0].sha1.as_deref(), + Some("aa11bb22cc33dd44ee55ff660011223344556677") + ); + assert!(first[0].path.to_string_lossy().ends_with("-slim.jar")); + // 非 SHA-1 的 values 只算"存在性校验",并留一条告警。 + assert!(plan.steps[1].outputs[0].sha1.is_none()); + assert!( + plan.warnings + .iter() + .any(|warning| warning.contains("没给 SHA-1")), + "{:?}", + plan.warnings + ); +} + +#[test] +fn processor_dependencies_are_planned_once_even_when_libraries_omit_them() { + let root = temp_root("plan-libs"); + let lean = serde_json::from_str( + r#"{"spec": 1, "minecraft": "1.20.1", "json": "/version.json", + "processors": [{"jar": "net.minecraftforge:installertools:1.3.0", + "classpath": ["net.minecraftforge:jarsplitter:1.1.4"], + "args": [], "outputs": {}}], + "libraries": [], + "data": {}}"#, + ) + .expect("夹具应可解析"); + let layout = GameLayout::new(&root); + let plan = ProcessorPlan::build(ProcessorPlanInput { + kind: LoaderKind::NeoForge, + mc_version: "1.20.1", + version_id: "1.20.1-neoforge-21.1.80", + profile: &lean, + layout: &layout, + installer: &root.join("installer.jar"), + scratch: &root.join("tmp"), + environment: &Environment::host(), + }) + .expect("计划应能组出来"); + + assert_eq!(plan.libraries.len(), 2, "jar 与 classpath 各补一条"); + for file in &plan.libraries { + assert!( + file.url.contains("neoforged") || file.url.contains("minecraftforge"), + "补库应落在加载器的 maven 上:{}", + file.url + ); + assert!(file.dest.starts_with(root.join("libraries"))); + } + assert!(plan + .warnings + .iter() + .any(|warning| warning.contains("不在 install profile 的 libraries 节里"))); +} + +#[test] +fn java_requirement_follows_the_target_minecraft_version() { + let root = temp_root("plan-java"); + let plan = plan_for(&root, &profile()); + assert_eq!(plan.java_requirement.minimum, 17); + assert_eq!(plan.java_requirement.maximum, None); + assert_eq!(plan.java_requirement.describe(), "Java 17+"); +} + +#[test] +fn json_view_lists_every_step_for_dry_run() { + let root = temp_root("plan-json"); + let plan = plan_for(&root, &profile()); + let json = plan.to_json(); + + assert_eq!(json["count"], 2); + assert_eq!(json["java_requirement"]["describe"], "Java 17+"); + assert_eq!( + json["steps"][0]["jar"], + "net.minecraftforge:installertools:1.3.0" + ); + assert_eq!(json["steps"][0]["classpath"], 1); + assert_eq!( + json["steps"][1]["outputs"][0]["sha1"], + serde_json::Value::Null + ); +} + +#[test] +fn duplicated_library_declarations_are_deduped_by_destination() { + // NeoForge 21.1.80 实测:install profile 把 cli-utils / srgutils 等声明了 2~3 次。 + // 同一落点下两次会在同一个 `.part` 中间文件上互相抢(真机 os error 2),必须去重。 + let root = temp_root("plan-dedup"); + let duplicated: InstallProfile = serde_json::from_str( + r#"{"spec": 1, "minecraft": "1.20.1", "json": "/version.json", + "processors": [{"jar": "net.neoforged.installertools:cli-utils:2.1.2@jar"}], + "libraries": [ + {"name": "net.neoforged.installertools:cli-utils:2.1.2@jar", "url": "https://maven.neoforged.net/releases/"}, + {"name": "net.neoforged.installertools:cli-utils:2.1.2@jar", "url": "https://maven.neoforged.net/releases/"}, + {"name": "net.neoforged.installertools:cli-utils:2.1.2@jar", "url": "https://maven.neoforged.net/releases/"} + ], + "data": {}}"#, + ) + .expect("夹具应可解析"); + let layout = GameLayout::new(&root); + let plan = ProcessorPlan::build(ProcessorPlanInput { + kind: LoaderKind::NeoForge, + mc_version: "1.20.1", + version_id: "1.20.1-neoforge-21.1.80", + profile: &duplicated, + layout: &layout, + installer: &root.join("installer.jar"), + scratch: &root.join("tmp"), + environment: &Environment::host(), + }) + .expect("计划应能组出来"); + + assert_eq!(plan.libraries.len(), 1, "同一落点只应留一条"); + assert!( + plan.warnings.iter().any(|warning| warning.contains("去重")), + "{:?}", + plan.warnings + ); +} + +#[test] +fn processor_without_jar_is_a_plan_error() { + let root = temp_root("plan-nojar"); + let broken: InstallProfile = serde_json::from_str( + r#"{"spec": 1, "minecraft": "1.20.1", "json": "/version.json", + "processors": [{"args": ["--task", "X"]}], "libraries": [], "data": {}}"#, + ) + .expect("夹具应可解析"); + let layout = GameLayout::new(&root); + let error = ProcessorPlan::build(ProcessorPlanInput { + kind: LoaderKind::Forge, + mc_version: "1.20.1", + version_id: "1.20.1-forge-47.2.0", + profile: &broken, + layout: &layout, + installer: &root.join("installer.jar"), + scratch: &root.join("tmp"), + environment: &Environment::host(), + }) + .expect_err("缺 jar 必须报错"); + assert_eq!(error.kind(), ProcessorErrorKind::Plan); + assert!(error.message().contains("jar"), "{}", error.message()); +} diff --git a/crates/core/src/addon/loader/processors/tests/vars.rs b/crates/core/src/addon/loader/processors/tests/vars.rs new file mode 100644 index 0000000..8c33dc9 --- /dev/null +++ b/crates/core/src/addon/loader/processors/tests/vars.rs @@ -0,0 +1,181 @@ +//! 变量表:三种字面量、标准变量、token 替换的边界。 + +use std::collections::BTreeMap; +use std::path::Path; + +use crate::addon::loader::processors::spec::Datum; +use crate::addon::loader::processors::vars::{Variables, VariablesInput}; +use crate::addon::loader::processors::ProcessorErrorKind; +use crate::game::layout::GameLayout; + +use super::{profile, temp_root}; + +fn data(entries: &[(&str, &str)]) -> BTreeMap { + entries + .iter() + .map(|(key, value)| { + ( + (*key).to_string(), + Datum { + client: Some((*value).to_string()), + server: None, + }, + ) + }) + .collect() +} + +fn build<'a>( + layout: &'a GameLayout, + data: &'a BTreeMap, + installer: &'a Path, + scratch: &'a Path, +) -> Variables { + Variables::build(VariablesInput { + data, + layout, + mc_version: "1.20.1", + installer, + scratch, + }) + .expect("变量表应能组装") +} + +#[test] +fn artifact_literals_resolve_into_the_libraries_directory() { + let root = temp_root("vars-artifact"); + let layout = GameLayout::new(&root); + let installer = root.join("installer.jar"); + let scratch = root.join("tmp"); + let table = data(&[("MAPPINGS", "[de.oceanlabs.mcp:mcp_config:1.20.1@zip]")]); + let variables = build(&layout, &table, &installer, &scratch); + + let value = variables.get("MAPPINGS").expect("应有 MAPPINGS"); + assert!( + Path::new(value).ends_with("de/oceanlabs/mcp/mcp_config/1.20.1/mcp_config-1.20.1.zip"), + "{value}" + ); + assert!(Path::new(value).is_absolute(), "{value}"); + assert!( + Path::new(value).starts_with(layout.libraries_dir()), + "{value}" + ); + + // 官方 profile 也把坐标直接写在 `args` 里(`--input [de.oceanlabs.mcp:…@zip]`), + // 整段是坐标时同样要展开成路径。 + let arg = variables + .expand("[de.oceanlabs.mcp:mcp_config:1.20.1@zip]") + .expect("整段坐标应能展开"); + assert_eq!(arg, value); +} + +#[test] +fn quoted_literals_lose_the_quotes() { + let root = temp_root("vars-literal"); + let layout = GameLayout::new(&root); + let table = data(&[("SIDE_HINT", "'client'")]); + let variables = build(&layout, &table, &root.join("i.jar"), &root.join("tmp")); + assert_eq!(variables.get("SIDE_HINT"), Some("client")); +} + +#[test] +fn plain_paths_are_extracted_from_the_installer_into_scratch() { + let root = temp_root("vars-embedded"); + let layout = GameLayout::new(&root); + let installer = root.join("installer.jar"); + let scratch = root.join("tmp"); + let table = data(&[("BINPATCH", "/data/client.lzma")]); + let variables = build(&layout, &table, &installer, &scratch); + + let value = variables.get("BINPATCH").expect("应有 BINPATCH"); + assert!(value.ends_with("data-client.lzma"), "{value}"); + assert!( + value.starts_with(&scratch.to_string_lossy().to_string()), + "{value}" + ); + assert_eq!(variables.embedded().len(), 1); + assert_eq!(variables.embedded()[0].entry, "data/client.lzma"); +} + +#[test] +fn server_only_entries_are_ignored_for_client_installs() { + let root = temp_root("vars-server"); + let layout = GameLayout::new(&root); + let variables = build( + &layout, + &profile().data, + &root.join("i.jar"), + &root.join("tmp"), + ); + assert!(variables.get("SERVER_ONLY").is_none()); + assert!(variables.get("MAPPINGS").is_some()); +} + +#[test] +fn standard_variables_are_absolute_and_side_is_client() { + let root = temp_root("vars-standard"); + let layout = GameLayout::new(&root); + let installer = root.join("installer.jar"); + let scratch = root.join("tmp"); + let variables = build(&layout, &BTreeMap::new(), &installer, &scratch); + + assert_eq!(variables.get("SIDE"), Some("client")); + assert_eq!(variables.get("MINECRAFT_VERSION"), Some("1.20.1")); + for key in ["MINECRAFT_JAR", "ROOT", "INSTALLER", "LIBRARY_DIR"] { + let value = variables.get(key).unwrap_or_else(|| panic!("缺 {key}")); + assert!( + Path::new(value).is_absolute(), + "{key} 不是绝对路径:{value}" + ); + } + assert!(variables + .get("MINECRAFT_JAR") + .unwrap_or_default() + .ends_with("minecraft.jar")); +} + +#[test] +fn token_replacement_handles_quotes_and_escapes() { + let root = temp_root("vars-expand"); + let layout = GameLayout::new(&root); + let variables = build( + &layout, + &BTreeMap::new(), + &root.join("i.jar"), + &root.join("tmp"), + ); + + assert_eq!( + variables.expand("--side '{SIDE}'").expect("应能展开"), + "--side {SIDE}" + ); + assert_eq!(variables.expand("a\\{b").expect("应能展开"), "a{b"); + assert_eq!( + variables.expand("--side {SIDE}").expect("应能展开"), + "--side client" + ); +} + +#[test] +fn missing_or_unclosed_placeholders_are_plan_errors() { + let root = temp_root("vars-error"); + let layout = GameLayout::new(&root); + let variables = build( + &layout, + &BTreeMap::new(), + &root.join("i.jar"), + &root.join("tmp"), + ); + + let missing = variables + .expand("--output {NOPE}") + .expect_err("缺变量必须报错"); + assert_eq!(missing.kind(), ProcessorErrorKind::Plan); + assert!(missing.message().contains("NOPE"), "{}", missing.message()); + + let unclosed = variables.expand("{SIDE").expect_err("未闭合必须报错"); + assert_eq!(unclosed.kind(), ProcessorErrorKind::Plan); + + let bad_escape = variables.expand("abc\\").expect_err("悬空转义必须报错"); + assert_eq!(bad_escape.kind(), ProcessorErrorKind::Plan); +} diff --git a/crates/core/src/addon/loader/processors/text.rs b/crates/core/src/addon/loader/processors/text.rs new file mode 100644 index 0000000..bbf387c --- /dev/null +++ b/crates/core/src/addon/loader/processors/text.rs @@ -0,0 +1,71 @@ +//! 进程输出的尾部截取与脱敏。 +//! +//! processor 的 stdout/stderr 是外部程序(而且部分步骤要联网)写的,内容不可控: +//! 可能带绝对路径、用户名、甚至一大段堆栈。进错误消息/`--json` 前必须做两件事: +//! **只留尾巴**(够定位、不灌满日志)与**抹掉主目录**(`C:\Users\<名>` → ``)。 + +use std::path::PathBuf; + +use crate::launch::redact::Redactor; + +/// 尾巴保留的行数(够看异常栈头)。 +const TAIL_LINES: usize = 12; +/// 尾巴保留的字符上限。 +const TAIL_CHARS: usize = 1200; + +/// 宿主脱敏器:把用户主目录换成 ``。 +/// +/// 复用 [`crate::launch::redact::Redactor`](与 M15/M31 落地的同一套规则:token、 +/// ``、长不透明串、带点 token/JWT)。注册 `USERPROFILE`(Windows)/`HOME`(macOS、Linux) +/// 两个来源,因为 MSYS/CI 环境下两者可能只有一个在场。 +#[must_use] +pub fn host_redactor() -> Redactor { + let mut redactor = Redactor::new(); + for key in ["USERPROFILE", "HOME"] { + if let Some(value) = std::env::var_os(key) { + redactor = redactor.with_home(PathBuf::from(value)); + } + } + redactor +} + +/// 取文本尾部(末 [`TAIL_LINES`] 行,再截到 [`TAIL_CHARS`] 字符)。 +#[must_use] +pub fn tail(text: &str) -> String { + let trimmed = text.trim_end(); + if trimmed.is_empty() { + return String::new(); + } + let lines: Vec<&str> = trimmed.lines().collect(); + let start = lines.len().saturating_sub(TAIL_LINES); + let mut joined = lines[start..].join("\n"); + let length = joined.chars().count(); + if length > TAIL_CHARS { + joined = joined.chars().skip(length - TAIL_CHARS).collect(); + joined.insert(0, '…'); + } + joined +} + +/// 空串 → `None`(JSON 里不吐空字段)。 +pub(super) fn non_empty(value: String) -> Option { + (!value.is_empty()).then_some(value) +} + +#[cfg(test)] +mod tests { + use super::tail; + + #[test] + fn tail_keeps_the_last_lines_and_truncates_long_ones() { + let text: String = (1..=40).map(|index| format!("line {index}\n")).collect(); + let tailed = tail(&text); + assert!(tailed.starts_with("line 29"), "{tailed}"); + assert!(tailed.ends_with("line 40")); + let long = "x".repeat(5000); + let tailed = tail(&long); + assert!(tailed.starts_with('…')); + assert_eq!(tailed.chars().count(), 1201); + assert_eq!(tail(" \n"), ""); + } +} diff --git a/crates/core/src/addon/loader/processors/vars.rs b/crates/core/src/addon/loader/processors/vars.rs new file mode 100644 index 0000000..a0b7524 --- /dev/null +++ b/crates/core/src/addon/loader/processors/vars.rs @@ -0,0 +1,324 @@ +//! 占位替换:install profile 的 `data` 表 + 标准变量 → 一张 `{键 → 值}` 表。 +//! +//! 这是 Forge processor 协议里最容易出错的一层,三种字面量语义(照 installer spec, +//! 与 HMCL `ForgeNewInstallTask.parseLiteral` 同构): +//! +//! | 写法 | 含义 | 例 | +//! |------|------|----| +//! | `[group:artifact:version(:classifier)(@ext)]` | maven 构件 → `libraries/` 下的绝对路径 | `[de.oceanlabs.mcp:mcp_config:1.20.1@zip]` | +//! | `'literal'` | 去掉引号的常量 | `'client'` | +//! | 其它 | **installer 里的文件路径** → 解包到临时目录后的绝对路径 | `/data/client.lzma` | +//! +//! 展开(`{KEY}`)的规则与 HMCL 的 `replaceTokens` 一致:`\` 转义下一个字符、 +//! `'…'` 原样输出、`{KEY}` 查表替换(**查不到就报错**,不静默留空——留空会让 +//! processor 拿着半截参数跑出更难查的错)。 +//! +//! # 为什么 `MINECRAFT_JAR` 指向临时目录的副本 +//! +//! HMCL 在起 processor 前把原版 client jar **复制到临时目录**再用,注释写得很直白: +//! `External processors must not receive the shared cache path`。processor 会改写输入的 +//! jar(拆包/打补丁),直接把 `versions//.jar` 递过去等于让外挂程序改共享层。 + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use super::error::ProcessorError; +use super::spec::Datum; +use crate::game::layout::GameLayout; +use crate::game::library::MavenCoordinate; + +/// 临时目录里那份原版 client jar 的文件名。 +pub const MINECRAFT_JAR_NAME: &str = "minecraft.jar"; + +/// 一个需要从 installer zip 里解出来的文件。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmbeddedFile { + /// installer 内的条目路径(`data/client.lzma`)。 + pub entry: String, + /// 解包落点(临时目录下的扁平文件名)。 + pub dest: PathBuf, +} + +/// 变量表的构造输入。 +pub struct VariablesInput<'a> { + /// install profile 的 `data` 表。 + pub data: &'a BTreeMap, + /// 游戏目录布局(`libraries/` 与 `versions/` 的路径规则)。 + pub layout: &'a GameLayout, + /// 目标原版版本(`net.minecraft:client:` 这类坐标回落用)。 + pub mc_version: &'a str, + /// installer jar 的磁盘路径(`{INSTALLER}`,也是解包来源)。 + pub installer: &'a Path, + /// 临时目录(`{MINECRAFT_JAR}` 与解包文件都落这里)。 + pub scratch: &'a Path, +} + +/// 展开后的变量表。 +#[derive(Debug, Clone, Default)] +pub struct Variables { + values: BTreeMap, + embedded: Vec, + /// 参数里 `[坐标]` 的解析上下文(官方 profile 会在 `args` 里直接写坐标,不只写在 `data` 里)。 + artifacts: Option, +} + +/// `[坐标]` 的解析上下文。 +#[derive(Debug, Clone)] +struct ArtifactRoots { + libraries: PathBuf, + vanilla_jar: PathBuf, +} + +impl Variables { + /// 组装变量表:先展开 `data` 表,再注入标准变量。 + /// + /// # Errors + /// + /// 构件坐标不合法、`data` 表里的值是空路径、变量引用缺定义时返回 + /// [`ProcessorErrorKind::Plan`](super::error::ProcessorErrorKind::Plan) 错误。 + pub fn build(input: VariablesInput<'_>) -> Result { + let mut variables = Self { + artifacts: Some(ArtifactRoots { + libraries: input.layout.libraries_dir(), + vanilla_jar: input.layout.version_jar(input.mc_version), + }), + ..Self::default() + }; + for (key, datum) in input.data { + let Some(raw) = datum.client_value() else { + continue; + }; + let value = variables.resolve_literal(raw, &input)?; + variables.values.insert(key.clone(), value); + } + variables.inject_standard(&input); + Ok(variables) + } + + /// 标准变量(installer spec 没有写死,但各家启动器都注入同一套)。 + fn inject_standard(&mut self, input: &VariablesInput<'_>) { + let scratch = absolute(input.scratch); + let standard = [ + ("SIDE", "client".to_string()), + ("MINECRAFT_VERSION", input.mc_version.to_string()), + ( + "MINECRAFT_JAR", + absolute(&scratch.join(MINECRAFT_JAR_NAME)) + .to_string_lossy() + .into_owned(), + ), + ( + "ROOT", + absolute(input.layout.root()).to_string_lossy().into_owned(), + ), + ( + "INSTALLER", + absolute(input.installer).to_string_lossy().into_owned(), + ), + ( + "LIBRARY_DIR", + absolute(&input.layout.libraries_dir()) + .to_string_lossy() + .into_owned(), + ), + ]; + for (key, value) in standard { + self.values.insert(key.to_string(), value); + } + } + + /// 把一个 `data` 表的值展开成实际字符串。 + fn resolve_literal( + &mut self, + raw: &str, + input: &VariablesInput<'_>, + ) -> Result { + let trimmed = raw.trim(); + if let Some(inner) = trim_brackets(trimmed) { + return self.resolve_artifact(inner); + } + if let Some(inner) = trimmed + .strip_prefix('\'') + .and_then(|rest| rest.strip_suffix('\'')) + { + return Ok(inner.to_string()); + } + if trimmed.starts_with('{') { + // data 表自己引用别的键:官方 profile 不这么写,但展开一次成本为零。 + return self.expand(trimmed); + } + let entry = trimmed.trim_start_matches('/').to_string(); + if entry.is_empty() { + return Err(ProcessorError::plan(format!( + "install profile 的 data 表里有空路径:`{raw}`" + ))); + } + // 其余一律当 installer 内的文件:解到临时目录(执行期才真正解包)。 + let dest = absolute(&absolute(input.scratch).join(embedded_name(&entry))); + self.embedded.push(EmbeddedFile { + entry, + dest: dest.clone(), + }); + Ok(dest.to_string_lossy().into_owned()) + } + + /// `[坐标]` → `libraries/` 下的绝对路径。 + fn resolve_artifact(&self, descriptor: &str) -> Result { + let Some(roots) = &self.artifacts else { + return Err(ProcessorError::plan(format!( + "构件坐标 `{descriptor}` 需要游戏目录上下文才能解析" + ))); + }; + artifact_path(&roots.libraries, &roots.vanilla_jar, descriptor) + } + + /// 展开一段含占位的文本(`{KEY}` / `'字面量'` / `[坐标]` / `\` 转义)。 + /// + /// `[坐标]` 只在**整段就是坐标**时生效(与 HMCL `parseLiteral` 一致): + /// official profile 的 `args` 里常直接写 `--input [de.oceanlabs.mcp:…@zip]`。 + /// + /// # Errors + /// + /// `{KEY}` 在表里查不到、`\` 结尾无字符可转义、`{`/`'` 未闭合、坐标不合法时返回规划错误。 + pub fn expand(&self, pattern: &str) -> Result { + let trimmed = pattern.trim(); + if let Some(inner) = trim_brackets(trimmed) { + return self.resolve_artifact(inner); + } + let mut out = String::with_capacity(pattern.len()); + let mut chars = pattern.chars(); + while let Some(ch) = chars.next() { + match ch { + '\\' => match chars.next() { + Some(next) => out.push(next), + None => { + return Err(ProcessorError::plan(format!( + "占位文本以 `\\` 结尾(无法转义):`{pattern}`" + ))) + } + }, + '{' => { + let key = collect_until(&mut chars, '}', pattern)?; + let value = self.values.get(&key).ok_or_else(|| { + ProcessorError::plan(format!( + "占位 {{{key}}} 在 install profile 的 data 表里没有定义(原文 `{pattern}`)" + )) + })?; + out.push_str(value); + } + '\'' => out.push_str(&collect_until(&mut chars, '\'', pattern)?), + other => out.push(other), + } + } + Ok(out) + } + + /// 某个变量的值。 + #[must_use] + pub fn get(&self, key: &str) -> Option<&str> { + self.values.get(key).map(String::as_str) + } + + /// 需要从 installer 里解包的文件(执行期用)。 + #[must_use] + pub fn embedded(&self) -> &[EmbeddedFile] { + &self.embedded + } + + /// 变量条数(`--dry-run` 只报数量,不吐全部路径)。 + #[must_use] + pub fn len(&self) -> usize { + self.values.len() + } + + /// 是否一条变量都没有(实际不可能:标准变量恒在)。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } +} + +/// 读到 `closing` 为止(跳过 `\` 转义),返回中间文本。 +fn collect_until( + chars: &mut impl Iterator, + closing: char, + pattern: &str, +) -> Result { + let mut out = String::new(); + while let Some(ch) = chars.next() { + if ch == '\\' { + match chars.next() { + Some(next) => out.push(next), + None => { + return Err(ProcessorError::plan(format!( + "占位文本以 `\\` 结尾(无法转义):`{pattern}`" + ))) + } + } + } else if ch == closing { + return Ok(out); + } else { + out.push(ch); + } + } + Err(ProcessorError::plan(format!( + "占位文本缺少收尾的 `{closing}`:`{pattern}`" + ))) +} + +/// 整段是 `[坐标]` 时取出坐标(不到首尾就返回 `None`)。 +fn trim_brackets(raw: &str) -> Option<&str> { + raw.strip_prefix('[') + .and_then(|rest| rest.strip_suffix(']')) +} + +/// `[坐标]` → `libraries/` 下的绝对路径。 +/// +/// 少数坐标进不了 `libraries/`:`net.minecraft:client:`(无 classifier)对应的是 +/// `versions//.jar`。官方 installer 会把 client jar 塞进本地 maven 布局, +/// 我们不复制(磁盘上原版 jar 只有一份),因此按目标版本回落。 +fn artifact_path( + libraries: &Path, + vanilla_jar: &Path, + descriptor: &str, +) -> Result { + let coordinate = MavenCoordinate::parse(descriptor).map_err(|error| { + ProcessorError::plan(format!( + "install profile 里的构件坐标不合法:`{descriptor}`({error})" + )) + })?; + let path = libraries.join(coordinate.relative_path()); + if coordinate.group == "net.minecraft" + && coordinate.artifact == "client" + && coordinate.classifier.is_none() + && vanilla_jar.is_file() + { + return Ok(absolute(vanilla_jar).to_string_lossy().into_owned()); + } + Ok(absolute(&path).to_string_lossy().into_owned()) +} + +/// installer 内的条目 → 临时目录里的扁平文件名(`data/client.lzma` → `data-client.lzma`)。 +/// +/// 压平是为了防路径穿越:`../` 与绝对路径在拼进临时目录时都会失去意义。 +fn embedded_name(entry: &str) -> String { + entry + .chars() + .map(|ch| { + if matches!(ch, '/' | '\\' | ':') { + '-' + } else { + ch + } + }) + .collect() +} + +/// 尽量转绝对路径(相对路径按当前工作目录;失败就原样返回)。 +#[must_use] +pub fn absolute(path: &Path) -> PathBuf { + if path.is_absolute() { + return path.to_path_buf(); + } + std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()) +} diff --git a/crates/core/src/addon/loader/tests/fabric.rs b/crates/core/src/addon/loader/tests/fabric.rs new file mode 100644 index 0000000..ca9a43e --- /dev/null +++ b/crates/core/src/addon/loader/tests/fabric.rs @@ -0,0 +1,135 @@ +//! Fabric / Quilt:profile 归一化的形状对拍。 + +use super::{FABRIC_PROFILE, QUILT_PROFILE}; +use crate::addon::loader::fabric::{build_release, parse_loader_list, pick_default}; +use crate::addon::loader::{LoaderErrorKind, LoaderKind, LoaderSupport}; + +#[test] +fn fabric_profile_becomes_inheriting_loader_version() { + let release = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", FABRIC_PROFILE) + .expect("fabric profile 应可归一化"); + + assert_eq!(release.version_id, "1.20.1-fabric-0.15.11"); + let json = &release.version_json; + assert_eq!(json.id.as_deref(), Some("1.20.1-fabric-0.15.11")); + assert_eq!(json.inherits_from.as_deref(), Some("1.20.1")); + // ★ 关键:jar 必须钉成原版 id,否则继承解析会把 jar 兜底成 loader 自身 id。 + assert_eq!(json.jar.as_deref(), Some("1.20.1")); + assert_eq!( + json.main_class.as_deref(), + Some("net.fabricmc.loader.impl.launch.knot.KnotClient") + ); + // jvm 参数(`-DFabricMcEmu`)必须原样保留:老版本启动靠它。 + let jvm = json + .arguments + .as_ref() + .map(|arguments| arguments.jvm.len()) + .unwrap_or_default(); + assert_eq!(jvm, 1, "profile 的 jvm 参数丢了"); + assert_eq!(release.support, LoaderSupport::JsonRoute); + assert!(release.is_installable()); + assert!(release.warnings.is_empty(), "{:?}", release.warnings); +} + +#[test] +fn fabric_flat_digest_is_lifted_into_artifact() { + let release = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", FABRIC_PROFILE) + .expect("fabric profile 应可归一化"); + let libraries = release.version_json.libraries.expect("应有库"); + let asm = libraries + .iter() + .find(|library| library.name.starts_with("org.ow2.asm:asm:")) + .expect("应有 asm"); + let artifact = asm + .downloads + .as_ref() + .and_then(|downloads| downloads.artifact.as_ref()) + .expect("扁平 sha1/size 应提升成 downloads.artifact"); + assert_eq!( + artifact.sha1.as_deref(), + Some("aa205cf0a06dbd8e04ece91c0b37c3f5d567546a") + ); + assert_eq!(artifact.size, Some(123_598)); + assert_eq!( + artifact.url.as_deref(), + Some("https://maven.fabricmc.net/org/ow2/asm/asm/9.6/asm-9.6.jar") + ); +} + +#[test] +fn quilt_profile_is_isomorphic() { + let release = build_release(LoaderKind::Quilt, "1.20.1", "0.20.0-beta.9", QUILT_PROFILE) + .expect("quilt profile 应可归一化"); + + assert_eq!(release.version_id, "1.20.1-quilt-0.20.0-beta.9"); + assert_eq!(release.version_json.jar.as_deref(), Some("1.20.1")); + assert_eq!( + release.version_json.main_class.as_deref(), + Some("org.quiltmc.loader.impl.launch.knot.KnotClient") + ); + assert_eq!(release.support, LoaderSupport::JsonRoute); + // Quilt 的 profile 没有 loading 库时不该编造;这里有 quilt-loader,因此无告警。 + assert!(release.warnings.is_empty(), "{:?}", release.warnings); +} + +#[test] +fn missing_main_class_is_metadata_error() { + let profile = r#"{"id":"x","inheritsFrom":"1.20.1","libraries":[]}"#; + let error = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", profile) + .expect_err("缺主类必须报错"); + assert_eq!(error.kind(), LoaderErrorKind::Metadata); +} + +#[test] +fn missing_loader_library_is_appended_with_warning() { + // 去掉 fabric-loader 那条,看归一化会不会补。 + let profile = r#"{ + "id": "fabric-loader-0.15.11-1.20.1", + "inheritsFrom": "1.20.1", + "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient", + "libraries": [{"name": "org.ow2.asm:asm:9.6", "url": "https://maven.fabricmc.net/"}] + }"#; + let release = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", profile) + .expect("缺 loader 库应降级为告警 + 补一条"); + assert_eq!(release.warnings.len(), 1); + let libraries = release.version_json.libraries.expect("应有库"); + assert!(libraries + .iter() + .any(|library| library.name == "net.fabricmc:fabric-loader:0.15.11")); +} + +#[test] +fn loader_list_parses_and_prefers_stable() { + let list = r#"[ + {"loader": {"version": "0.16.0-beta.1", "stable": false, "maven": "net.fabricmc:fabric-loader:0.16.0-beta.1"}}, + {"loader": {"version": "0.15.11", "stable": true, "maven": "net.fabricmc:fabric-loader:0.15.11"}}, + {"loader": {"version": "0.15.10", "stable": true, "maven": "net.fabricmc:fabric-loader:0.15.10"}} + ]"#; + let entries = parse_loader_list(LoaderKind::Fabric, list).expect("列表应可解析"); + assert_eq!(entries.len(), 3); + assert_eq!( + pick_default(&entries).map(|entry| entry.version.as_str()), + Some("0.15.11") + ); +} + +#[test] +fn quilt_loader_list_without_stable_flag_still_picks_first() { + // 实测 Quilt 的 loader 条目没有 stable 键。 + let list = r#"[ + {"loader": {"version": "0.20.0-beta.9", "maven": "org.quiltmc:quilt-loader:0.20.0-beta.9"}}, + {"loader": {"version": "0.19.2", "maven": "org.quiltmc:quilt-loader:0.19.2"}} + ]"#; + let entries = parse_loader_list(LoaderKind::Quilt, list).expect("列表应可解析"); + assert!(entries.iter().all(|entry| entry.stable)); + assert_eq!( + pick_default(&entries).map(|entry| entry.version.as_str()), + Some("0.20.0-beta.9") + ); +} + +#[test] +fn empty_loader_list_is_not_found() { + let error = parse_loader_list(LoaderKind::Fabric, "[]").expect_err("空列表应报错"); + assert_eq!(error.kind(), LoaderErrorKind::NotFound); +} diff --git a/crates/core/src/addon/loader/tests/forge.rs b/crates/core/src/addon/loader/tests/forge.rs new file mode 100644 index 0000000..123c426 --- /dev/null +++ b/crates/core/src/addon/loader/tests/forge.rs @@ -0,0 +1,258 @@ +//! NeoForge / Forge:install profile 解析与三条路线的判定。 + +use crate::addon::loader::forge::{build_release, InstallProfile}; +use crate::addon::loader::{LoaderErrorKind, LoaderKind, LoaderSupport}; +use crate::game::manifest::VersionJson; + +/// spec 1 + client 侧 processor(实测 NeoForge 21.1.80 / Forge 1.20.1 的形状,裁剪过)。 +pub(crate) const NEOFORGE_PROFILE: &str = r#"{ + "spec": 1, + "profile": "NeoForge", + "version": "neoforge-21.1.80", + "minecraft": "1.21.1", + "json": "/version.json", + "path": null, + "processors": [ + {"sides": ["server"], "jar": "net.neoforged.installertools:installertools:2.1.2"}, + {"sides": ["client"], "jar": "net.neoforged.installertools:installertools:2.1.2"}, + {"jar": "net.neoforged.installertools:installertools:2.1.2"}, + {"sides": ["client"], "jar": "net.neoforged.installertools:installertools:2.1.2"} + ], + "libraries": [ + {"name": "com.google.code.gson:gson:2.8.9@jar"}, + {"name": "org.apache.commons:commons-text:1.3@jar"} + ], + "data": {"MC_SLIM": {"client": "[net.neoforged:neoforge:21.1.80:client-slim]"}} +}"#; + +/// spec 1 版本 JSON 的裁剪版(内容形状照 21.1.80)。 +pub(crate) const NEOFORGE_VERSION_JSON: &str = r#"{ + "id": "neoforge-21.1.80", + "inheritsFrom": "1.21.1", + "type": "release", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "arguments": {"game": ["--launchTarget", "forgeclient"], "jvm": ["-DignoreList=bootstraplauncher"]}, + "libraries": [ + {"name": "cpw.mods:securejarhandler:2.1.10", + "downloads": {"artifact": {"path": "cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar", + "url": "https://maven.neoforged.net/releases/cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar", + "sha1": "51e6a22c6c716beb11e244bf5b8be480f51dd6b5", "size": 88749}}} + ] +}"#; + +/// spec 0 + 无 processors(实测 Forge 1.12.2-14.23.5.2860),universal jar 的 url 是空串。 +pub(crate) const FORGE_LEGACY_PROFILE: &str = r#"{ + "spec": 0, + "profile": "forge", + "version": "1.12.2-forge-14.23.5.2860", + "minecraft": "1.12.2", + "json": "/version.json", + "path": "net.minecraftforge:forge:1.12.2-14.23.5.2860", + "processors": [], + "libraries": [{"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860"}] +}"#; + +pub(crate) const FORGE_LEGACY_VERSION_JSON: &str = r#"{ + "id": "1.12.2-forge-14.23.5.2860", + "inheritsFrom": "1.12.2", + "mainClass": "net.minecraft.launchwrapper.Launch", + "minecraftArguments": "--username ${auth_player_name} --tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker", + "libraries": [ + {"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860", + "downloads": {"artifact": {"path": "net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860.jar", + "url": "", "sha1": "029250575d3aa2cf80b56dffb66238a1eeaea2ac", "size": 4466148}}} + ] +}"#; + +fn profile(json: &str) -> InstallProfile { + serde_json::from_str(json).expect("夹具 profile 应可解析") +} + +fn version_json(json: &str) -> VersionJson { + VersionJson::from_json_str(json).expect("夹具版本 JSON 应可解析") +} + +#[test] +fn client_processors_mark_version_as_installer_required() { + let release = build_release( + LoaderKind::NeoForge, + "1.21.1", + "21.1.80", + &profile(NEOFORGE_PROFILE), + Some(&version_json(NEOFORGE_VERSION_JSON)), + ) + .expect("版本 JSON 齐备时应能生成清单"); + + // 2 条 sides 含 client + 1 条无 sides(两侧都要)= 3 条 client 侧。 + assert_eq!(profile(NEOFORGE_PROFILE).client_processors(), 3); + // M35 起 RequiresInstaller 是可真装的路线(外调 java 跑 processors), + // 但"要跑 installer"这个事实仍要能被上层看见。 + assert!(release.is_installable()); + assert!(release.requires_installer()); + let reason = release + .installer_reason() + .expect("应给需要 installer 的原因"); + assert!(reason.contains("3 个 client 侧 processor"), "{reason}"); + // 版本清单本身仍然正确生成(--dry-run 能看到它)。 + assert_eq!(release.version_id, "1.21.1-neoforge-21.1.80"); + assert_eq!( + release.version_json.inherits_from.as_deref(), + Some("1.21.1") + ); + assert_eq!(release.version_json.jar.as_deref(), Some("1.21.1")); + // processor 依赖不进启动 classpath,只记数量进告警。 + assert_eq!(release.version_json.libraries.map_or(0, |l| l.len()), 1); + assert!(release + .warnings + .iter() + .any(|warning| warning.contains("processor 依赖"))); +} + +#[test] +fn spec_zero_profile_is_json_route_and_fills_empty_url() { + let release = build_release( + LoaderKind::Forge, + "1.12.2", + "1.12.2-14.23.5.2860", + &profile(FORGE_LEGACY_PROFILE), + Some(&version_json(FORGE_LEGACY_VERSION_JSON)), + ) + .expect("spec 0 应走 json 路线"); + + assert_eq!(release.support, LoaderSupport::JsonRoute); + assert_eq!(release.loader_version, "14.23.5.2860"); + assert_eq!(release.version_id, "1.12.2-forge-14.23.5.2860"); + assert_eq!( + release.version_json.main_class.as_deref(), + Some("net.minecraft.launchwrapper.Launch") + ); + + // 空 url 必须补成 maven 地址:本体 jar 在官方 maven 上**只发布在 -universal classifier 下** + // (无 classifier 路径官方 404,仅靠 BMCLAPI 302 别名兜底),所以 URL 要带 -universal。 + let library = release + .version_json + .libraries + .as_ref() + .and_then(|libraries| libraries.first()) + .expect("应有 forge 本体库"); + let artifact = library + .downloads + .as_ref() + .and_then(|downloads| downloads.artifact.as_ref()) + .expect("应有 artifact"); + assert_eq!( + artifact.url.as_deref(), + Some( + "https://maven.minecraftforge.net/net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860-universal.jar" + ), + "URL 必须带 -universal(官方 maven 只托管这个 classifier)" + ); + assert_eq!(artifact.size, Some(4_466_148)); + assert!(library + .url + .as_deref() + .unwrap_or_default() + .contains("minecraftforge")); + + // 磁盘落点仍用原 path(与官方 installer 内嵌条目同名,启动 classpath 布局不变)。 + assert_eq!( + artifact.path.as_deref(), + Some("net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860.jar") + ); + let dest = library.relative_path().expect("落点应可推导"); + assert!( + dest.ends_with("forge-1.12.2-14.23.5.2860.jar"), + "落点不该带 classifier:{}", + dest.display() + ); +} + +#[test] +fn legacy_inline_protocol_is_unsupported() { + // 老协议:没有独立 version.json,只有内联 versionInfo + 解包信息。 + let profile = profile( + r#"{"spec": 1, "minecraft": "1.7.10", + "install": {"filePath": "forge-1.7.10-10.13.4.1614-universal.jar"}, + "versionInfo": {"id": "1.7.10-Forge10.13.4.1614", "mainClass": "net.minecraft.launchwrapper.Launch"}, + "processors": []}"#, + ); + assert!(profile.is_legacy_protocol()); + let error = build_release( + LoaderKind::Forge, + "1.7.10", + "1.7.10-10.13.4.1614", + &profile, + None, + ) + .expect_err("老协议必须明确 unsupported"); + assert_eq!(error.kind(), LoaderErrorKind::Unsupported); + assert!(error.message().contains("老协议"), "{}", error.message()); + assert!(error.hint().is_some()); +} + +#[test] +fn universal_classifier_rewrite_is_scoped_to_the_installer_body() { + // 只有 install profile 的 `path` 声明的本体构件走 -universal; + // 其它同样"空 url + 有 path"的库必须保持原路径(乱加 classifier 会 404)。 + let embedded = version_json( + r#"{"id": "1.12.2-forge-14.23.5.2860", "inheritsFrom": "1.12.2", + "mainClass": "net.minecraft.launchwrapper.Launch", + "libraries": [ + {"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860", + "downloads": {"artifact": {"path": "net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860.jar", "url": ""}}}, + {"name": "net.minecraftforge:mergetool:1.1.5", + "downloads": {"artifact": {"path": "net/minecraftforge/mergetool/1.1.5/mergetool-1.1.5.jar", "url": ""}}} + ]}"#, + ); + let release = build_release( + LoaderKind::Forge, + "1.12.2", + "1.12.2-14.23.5.2860", + &profile(FORGE_LEGACY_PROFILE), + Some(&embedded), + ) + .expect("spec 0 应走 json 路线"); + let libraries = release.version_json.libraries.expect("应有库"); + let url_of = |name: &str| { + libraries + .iter() + .find(|library| library.name == name) + .and_then(|library| library.downloads.as_ref()) + .and_then(|downloads| downloads.artifact.as_ref()) + .and_then(|artifact| artifact.url.clone()) + .unwrap_or_default() + }; + assert!(url_of("net.minecraftforge:forge:1.12.2-14.23.5.2860").ends_with("-universal.jar")); + assert_eq!( + url_of("net.minecraftforge:mergetool:1.1.5"), + "https://maven.minecraftforge.net/net/minecraftforge/mergetool/1.1.5/mergetool-1.1.5.jar" + ); +} + +#[test] +fn version_mismatch_between_installer_and_target_is_metadata_error() { + let error = build_release( + LoaderKind::NeoForge, + "1.20.1", + "21.1.80", + &profile(NEOFORGE_PROFILE), + Some(&version_json(NEOFORGE_VERSION_JSON)), + ) + .expect_err("原版版本不符必须报错"); + assert_eq!(error.kind(), LoaderErrorKind::Metadata); + assert!(error.message().contains("1.21.1"), "{}", error.message()); +} + +#[test] +fn missing_main_class_is_metadata_error() { + let embedded = version_json(r#"{"id": "x", "inheritsFrom": "1.12.2", "libraries": []}"#); + let error = build_release( + LoaderKind::Forge, + "1.12.2", + "1.12.2-14.23.5.2860", + &profile(FORGE_LEGACY_PROFILE), + Some(&embedded), + ) + .expect_err("缺主类必须报错"); + assert_eq!(error.kind(), LoaderErrorKind::Metadata); +} diff --git a/crates/core/src/addon/loader/tests/maven.rs b/crates/core/src/addon/loader/tests/maven.rs new file mode 100644 index 0000000..2d30b12 --- /dev/null +++ b/crates/core/src/addon/loader/tests/maven.rs @@ -0,0 +1,134 @@ +//! maven 版本表:XML 扫描、NeoForge 版本反解、版本挑选、installer 地址。 + +use crate::addon::loader::maven::{ + display_loader_version, installer_url, matches_mc, metadata_url, neoforge_mc_version, + normalize_maven_version, parse_metadata_versions, select_versions, +}; +use crate::addon::loader::LoaderKind; + +/// 裁剪过的 maven-metadata.xml(真实结构,只留 `` 列表)。 +const METADATA: &str = r#" + + net.neoforged + neoforge + + 26.1.2.125 + + 20.4.237 + 21.1.9 + 21.1.80 + 26.1.2.125 + 0.0.1-snapshot-1 + + +"#; + +#[test] +fn metadata_versions_are_scanned() { + let versions = parse_metadata_versions(METADATA); + assert_eq!( + versions, + vec![ + "20.4.237", + "21.1.9", + "21.1.80", + "26.1.2.125", + "0.0.1-snapshot-1" + ] + ); + assert!(parse_metadata_versions("not xml").is_empty()); +} + +#[test] +fn neoforge_versions_map_back_to_minecraft() { + // analyse/05 §1.3 的三条规则。 + assert_eq!(neoforge_mc_version("21.1.80").as_deref(), Some("1.21.1")); + assert_eq!(neoforge_mc_version("20.4.237").as_deref(), Some("1.20.4")); + assert_eq!(neoforge_mc_version("26.1.2.125").as_deref(), Some("26.1")); + // 快照(主版本 0)不挂原版版本号。 + assert_eq!(neoforge_mc_version("0.0.1-snapshot-1"), None); +} + +#[test] +fn select_versions_filters_and_sorts_numerically() { + let versions = parse_metadata_versions(METADATA); + let picked = select_versions(LoaderKind::NeoForge, "1.21.1", &versions); + // 数字序:9 < 80(字符串序会反过来)。 + assert_eq!(picked, vec!["21.1.9", "21.1.80"]); + assert!(select_versions(LoaderKind::NeoForge, "1.19.2", &versions).is_empty()); +} + +#[test] +fn forge_versions_carry_the_minecraft_prefix() { + let versions = vec![ + "1.20.1-47.3.0".to_string(), + "1.20.1-47.2.0".to_string(), + "1.12.2-14.23.5.2860".to_string(), + ]; + let picked = select_versions(LoaderKind::Forge, "1.20.1", &versions); + assert_eq!(picked, vec!["1.20.1-47.2.0", "1.20.1-47.3.0"]); + assert!(matches_mc(LoaderKind::Forge, "1.20.1", "1.20.1-47.3.0")); + assert!(!matches_mc(LoaderKind::Forge, "1.20.2", "1.20.1-47.3.0")); +} + +#[test] +fn neoforge_1201_uses_forge_layout() { + // 1.20.1 的 NeoForge 早产版挂在 net/neoforged/forge 下,版本串带原版前缀。 + assert!(metadata_url(LoaderKind::NeoForge, "1.20.1") + .ends_with("net/neoforged/forge/maven-metadata.xml")); + assert!(metadata_url(LoaderKind::NeoForge, "1.21.1") + .ends_with("net/neoforged/neoforge/maven-metadata.xml")); + assert!(metadata_url(LoaderKind::Forge, "1.20.1") + .ends_with("net/minecraftforge/forge/maven-metadata.xml")); + + let versions = vec!["1.20.1-47.1.106".to_string(), "1.20.1-47.1.90".to_string()]; + let picked = select_versions(LoaderKind::NeoForge, "1.20.1", &versions); + assert_eq!(picked.len(), 2); + assert_eq!( + display_loader_version(LoaderKind::NeoForge, "1.20.1", "1.20.1-47.1.106"), + "47.1.106" + ); +} + +#[test] +fn versions_are_normalized_and_displayed() { + // Forge:用户给 "47.2.0",maven 上是 "1.20.1-47.2.0"。 + assert_eq!( + normalize_maven_version(LoaderKind::Forge, "1.20.1", "47.2.0"), + "1.20.1-47.2.0" + ); + assert_eq!( + normalize_maven_version(LoaderKind::Forge, "1.20.1", "1.20.1-47.2.0"), + "1.20.1-47.2.0" + ); + assert_eq!( + display_loader_version(LoaderKind::Forge, "1.20.1", "1.20.1-47.2.0"), + "47.2.0" + ); + // NeoForge(非 1.20.1 布局)本身就是完整 maven 版本。 + assert_eq!( + normalize_maven_version(LoaderKind::NeoForge, "1.21.1", "21.1.80"), + "21.1.80" + ); + assert_eq!( + display_loader_version(LoaderKind::NeoForge, "1.21.1", "21.1.80"), + "21.1.80" + ); +} + +#[test] +fn installer_urls_point_at_official_maven() { + assert_eq!( + installer_url(LoaderKind::Forge, "1.20.1", "1.20.1-47.2.0"), + "https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-installer.jar" + ); + assert_eq!( + installer_url(LoaderKind::NeoForge, "1.21.1", "21.1.80"), + "https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.80/neoforge-21.1.80-installer.jar" + ); + // 1.20.1 的 NeoForge 走 forge 布局(文件名也是 forge-)。 + assert_eq!( + installer_url(LoaderKind::NeoForge, "1.20.1", "1.20.1-47.1.106"), + "https://maven.neoforged.net/releases/net/neoforged/forge/1.20.1-47.1.106/forge-1.20.1-47.1.106-installer.jar" + ); +} diff --git a/crates/core/src/addon/loader/tests/mod.rs b/crates/core/src/addon/loader/tests/mod.rs new file mode 100644 index 0000000..4d2f93f --- /dev/null +++ b/crates/core/src/addon/loader/tests/mod.rs @@ -0,0 +1,56 @@ +//! `addon::loader` 的单元测试:离线夹具对拍 + 镜像选择 + unsupported 判定。 +//! +//! 刻意**不打真实网络**:JSON 归一化、版本表挑选、镜像候选、计划形状全是纯计算。 +//! 夹具是官方响应的**裁剪版**(保留形状与关键字段,去掉几百行库与 base64 图标)。 + +mod fabric; +mod forge; +mod maven; +mod plan; + +/// 裁剪过的 Fabric `/profile/json`(1.20.1 + fabric-loader 0.15.11)。 +pub(crate) const FABRIC_PROFILE: &str = r#"{ + "id": "fabric-loader-0.15.11-1.20.1", + "inheritsFrom": "1.20.1", + "releaseTime": "2026-10-29T10:25:18+0000", + "time": "2026-10-29T10:25:18+0000", + "type": "release", + "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient", + "arguments": {"game": [], "jvm": ["-DFabricMcEmu= net.minecraft.client.main.Main "]}, + "libraries": [ + {"name": "org.ow2.asm:asm:9.6", "url": "https://maven.fabricmc.net/", + "sha1": "aa205cf0a06dbd8e04ece91c0b37c3f5d567546a", "size": 123598}, + {"name": "net.fabricmc:sponge-mixin:0.13.3+mixin.0.8.5", "url": "https://maven.fabricmc.net/"}, + {"name": "net.fabricmc:intermediary:1.20.1", "url": "https://maven.fabricmc.net/"}, + {"name": "net.fabricmc:fabric-loader:0.15.11", "url": "https://maven.fabricmc.net/"} + ] +}"#; + +/// 裁剪过的 Quilt `/profile/json`(1.20.1 + quilt-loader 0.20.0-beta.9)。 +pub(crate) const QUILT_PROFILE: &str = r#"{ + "id": "quilt-loader-0.20.0-beta.9-1.20.1", + "inheritsFrom": "1.20.1", + "type": "release", + "mainClass": "org.quiltmc.loader.impl.launch.knot.KnotClient", + "arguments": {"game": []}, + "libraries": [ + {"name": "net.fabricmc:tiny-mappings-parser:0.3.0+build.17", "url": "https://maven.fabricmc.net/"}, + {"name": "org.quiltmc:quilt-json5:1.0.4+final", "url": "https://maven.quiltmc.org/repository/release/"}, + {"name": "org.quiltmc:quilt-loader:0.20.0-beta.9", "url": "https://maven.quiltmc.org/repository/release/"} + ] +}"#; + +/// 原版 1.20.1 的**已裁剪**版本 JSON(继承链解析的父层)。 +pub(crate) const VANILLA_1201: &str = r#"{ + "id": "1.20.1", + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "assets": "5", + "assetIndex": {"id": "5", "sha1": "0000", "size": 1, + "url": "https://piston-meta.mojang.com/v1/packages/5.json"}, + "downloads": {"client": {"sha1": "1111", "size": 2, + "url": "https://piston-data.mojang.com/v1/objects/1/client.jar"}}, + "libraries": [ + {"name": "com.mojang:brigadier:1.0.18", "url": "https://libraries.minecraft.net/"} + ] +}"#; diff --git a/crates/core/src/addon/loader/tests/plan.rs b/crates/core/src/addon/loader/tests/plan.rs new file mode 100644 index 0000000..fbe193e --- /dev/null +++ b/crates/core/src/addon/loader/tests/plan.rs @@ -0,0 +1,192 @@ +//! 计划形状 / 继承链两级解析 / 镜像选择。 + +use std::collections::BTreeMap; + +use super::{FABRIC_PROFILE, VANILLA_1201}; +use crate::addon::loader::fabric::{build_release, loader_list_url, profile_url}; +use crate::addon::loader::maven; +use crate::addon::loader::{ + LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease, LoaderSupport, +}; +use crate::download::mirrors::MirrorTable; +use crate::download::region::RegionProfile; +use crate::download::types::ResourceKind; +use crate::game::arguments::Environment; +use crate::game::install::FileRole; +use crate::game::{inherit, layout::GameLayout, VersionJson}; + +fn fabric_release() -> LoaderRelease { + build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", FABRIC_PROFILE).expect("夹具应可归一化") +} + +fn environment() -> Environment { + Environment { + os: "windows".to_string(), + os_version: String::new(), + arch: "x86_64".to_string(), + ..Environment::host() + } +} + +#[test] +fn plan_contains_only_loader_libraries() { + let layout = GameLayout::new("/g/.minecraft"); + let release = fabric_release(); + let plan = LoaderInstallPlan::build(LoaderPlanInput { + layout: &layout, + release: &release, + environment: &environment(), + vanilla_installed: false, + }); + + assert_eq!(plan.install.version_id, "1.20.1-fabric-0.15.11"); + // jar 与资源归原版层,loader 计划里只有库。 + assert_eq!(plan.install.jar_id, "1.20.1"); + assert!(plan + .files() + .iter() + .all(|file| file.role == FileRole::Library)); + assert!( + plan.files() + .iter() + .any(|file| file.name.contains("fabric-loader")), + "缺 fabric-loader:{:?}", + plan.files().iter().map(|f| &f.name).collect::>() + ); + assert!(plan.install.version_json_path.ends_with( + std::path::Path::new("versions") + .join("1.20.1-fabric-0.15.11") + .join("1.20.1-fabric-0.15.11.json") + )); + // 只下 loader 那几个库,不该冒出资产/客户端 jar。 + assert!(plan.install.file_count() >= 4); + assert!(plan.install.client_jar().is_none()); +} + +#[test] +fn plan_marks_missing_vanilla_and_unsupported() { + let layout = GameLayout::new("/g/.minecraft"); + let release = fabric_release(); + let plan = LoaderInstallPlan::build(LoaderPlanInput { + layout: &layout, + release: &release, + environment: &environment(), + vanilla_installed: false, + }); + assert!(plan.vanilla_install_needed); + assert!(plan + .warnings() + .iter() + .any(|warning| warning.contains("原版 1.20.1 尚未安装"))); + + let installed = LoaderInstallPlan::build(LoaderPlanInput { + layout: &layout, + release: &release, + environment: &environment(), + vanilla_installed: true, + }); + assert!(!installed.vanilla_install_needed); + + // 把 support 改成"需要 installer",计划里要出现"要外调 java"的告警(M35 起可真装)。 + let mut blocked = fabric_release(); + blocked.support = LoaderSupport::RequiresInstaller { + reason: "需要 processors".to_string(), + }; + let plan = LoaderInstallPlan::build(LoaderPlanInput { + layout: &layout, + release: &blocked, + environment: &environment(), + vanilla_installed: true, + }); + assert!(plan + .warnings() + .iter() + .any(|warning| warning.contains("需要外调 java 跑 installer processors"))); +} + +#[test] +fn summary_json_carries_loader_block() { + let layout = GameLayout::new("/g/.minecraft"); + let release = fabric_release(); + let plan = LoaderInstallPlan::build(LoaderPlanInput { + layout: &layout, + release: &release, + environment: &environment(), + vanilla_installed: true, + }); + let summary = plan.summary_json(); + assert_eq!(summary["loader"]["kind"], "fabric"); + assert_eq!(summary["loader"]["version_id"], "1.20.1-fabric-0.15.11"); + assert_eq!(summary["loader"]["support"]["mode"], "json_route"); + assert_eq!(summary["loader"]["inherits_from"], "1.20.1"); + assert_eq!(summary["vanilla_install_needed"], false); +} + +#[test] +fn loader_json_resolves_two_level_chain() { + // loader → 原版 两级继承:解析后必须是扁平的、jar 指原版、库两侧合并。 + let vanilla = VersionJson::from_json_str(VANILLA_1201).expect("原版夹具"); + let loader = fabric_release().version_json; + let mut table: BTreeMap = BTreeMap::new(); + table.insert("1.20.1".to_string(), vanilla); + table.insert("1.20.1-fabric-0.15.11".to_string(), loader.clone()); + + let resolved = inherit::resolve(&table, &loader).expect("两级链应可解析"); + assert!(resolved.is_resolved()); + assert_eq!(resolved.inherits_from, None); + assert_eq!(resolved.jar.as_deref(), Some("1.20.1")); + assert_eq!( + resolved.main_class.as_deref(), + Some("net.fabricmc.loader.impl.launch.knot.KnotClient") + ); + // 资源来自原版层。 + assert_eq!(resolved.assets.as_deref(), Some("5")); + assert!(resolved.client_jar().is_some()); + // 库两层的并集(父在前,子在后)。 + let libraries = resolved.libraries.expect("应有库"); + assert_eq!(libraries.len(), 5); + assert_eq!(libraries[0].name, "com.mojang:brigadier:1.0.18"); +} + +#[test] +fn loader_meta_and_maven_urls_hit_bmclapi_mirrors() { + let table = MirrorTable::builtin(); + let mirror_first = RegionProfile::mirror_first(0); + + let fabric_meta = loader_list_url(LoaderKind::Fabric, "1.20.1"); + let candidates = table.expand(&fabric_meta, ResourceKind::VersionManifest, &mirror_first); + let sources: Vec<&str> = candidates.iter().map(|c| c.source.as_str()).collect(); + assert_eq!( + sources.first(), + Some(&"bmclapi"), + "镜像优先时应先走 BMCLAPI" + ); + assert!(candidates.iter().any(|candidate| candidate + .url + .contains("/fabric-meta/v2/versions/loader/1.20.1"))); + + let quilt_profile = profile_url(LoaderKind::Quilt, "1.20.1", "0.20.0-beta.9"); + let candidates = table.expand(&quilt_profile, ResourceKind::VersionManifest, &mirror_first); + assert_eq!(candidates[0].source, "bmclapi"); + assert!(candidates[0].url.contains("/quilt-meta/")); + + let neoforge_installer = maven::installer_url(LoaderKind::NeoForge, "1.21.1", "21.1.80"); + let candidates = table.expand(&neoforge_installer, ResourceKind::Library, &mirror_first); + assert_eq!(candidates[0].source, "bmclapi"); + assert!(candidates[0] + .url + .starts_with("https://bmclapi2.bangbang93.com/maven/net/neoforged/neoforge/")); + + let forge_meta = maven::metadata_url(LoaderKind::Forge, "1.20.1"); + let candidates = table.expand(&forge_meta, ResourceKind::VersionManifest, &mirror_first); + assert_eq!(candidates[0].source, "bmclapi"); + assert!(candidates[0] + .url + .starts_with("https://bmclapi2.bangbang93.com/maven/net/minecraftforge/forge/")); + + // 直连模式:官方在前,镜像只兜底。 + let direct = RegionProfile::direct(0); + let candidates = table.expand(&fabric_meta, ResourceKind::VersionManifest, &direct); + assert_eq!(candidates[0].source, "official"); + assert_eq!(candidates[0].url, fabric_meta); +} diff --git a/crates/core/src/addon/mod.rs b/crates/core/src/addon/mod.rs new file mode 100644 index 0000000..8cb22d4 --- /dev/null +++ b/crates/core/src/addon/mod.rs @@ -0,0 +1,19 @@ +//! Mod 与整合包:加载器安装(Fabric/Quilt/NeoForge/Forge)、整合包双格式导入导出、 +//! 本地 Mod 管理、CurseForge / Modrinth 客户端。 +//! +//! 设计定稿:`DevDocs/PlanDoc/08-mod-and-recommend.md`(决策 D10)。 +//! 安全要点:整合包路径穿越双检;AI 推荐走「结构化过滤在前 + LLM 排序在后 + 来源链接防幻觉」。 +//! +//! # 当前公共面 +//! +//! - [`loader`](P2 · M32):加载器安装——官方元数据 → 带 `inheritsFrom` 的 loader 版本 JSON +//! → 补库清单,落盘与实例登记复用 `core::game::install`(M27)。 +//! +//! 规划中:整合包导入导出(D10 §2)、本地 mod 管理(§3)、CF/Modrinth 客户端(§5)。 + +pub mod loader; + +pub use loader::{ + LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease, + LoaderSource, LoaderSupport, +}; diff --git a/crates/core/src/auth/account.rs b/crates/core/src/auth/account.rs new file mode 100644 index 0000000..3aff5fc --- /dev/null +++ b/crates/core/src/auth/account.rs @@ -0,0 +1,339 @@ +//! 账户模型:**条目 id 与 profileID 解耦**的三轨统一抽象。 +//! +//! 承 `analyse/04` §2.3 / §3.2 / §12.1(1) 的三条硬教训: +//! 1. 条目 id(`account:<32hex>`)**不得**等于 profileID / 登录名 / 服务器 URL—— +//! 这些值可能被多个条目共享(同一角色导入两次、服务器换域名); +//! 2. **元数据 / 凭据双写分离**:`AccountMeta` 只装「能公开」的字段(列表渲染不需要解密); +//! 3. 反序列化**允许不完整对象**(`profile_name` 可空 = 「有档案但名字待刷新」), +//! 补齐推迟到登录时——不能因为名字未知就整条丢弃。 + +use serde::{Deserialize, Serialize}; + +use crate::auth::authlib::AuthlibSession; +use crate::auth::error::{AuthError, AuthResult}; +use crate::auth::microsoft::MicrosoftSession; +use crate::auth::secret::random_hex32; + +/// 条目 id 前缀(防被误当 UUID;承 HMCL `AccountID.PREFIX`)。 +pub const ACCOUNT_ID_PREFIX: &str = "account:"; + +/// 账户条目 id:`account:<32 位十六进制>`。 +/// +/// 与 profileID 无关:改服务器地址、改玩家名、同角色多条目都不影响它。 +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct AccountId(String); + +impl AccountId { + /// 生成一个新条目 id。 + #[must_use] + pub fn generate() -> Self { + Self(format!("{ACCOUNT_ID_PREFIX}{}", random_hex32())) + } + + /// 从磁盘字符串解析(宽容:坏数据返回 `None`,不 panic)。 + #[must_use] + pub fn parse(raw: &str) -> Option { + let body = raw.strip_prefix(ACCOUNT_ID_PREFIX)?; + if body.len() != 32 || !body.chars().all(|ch| ch.is_ascii_hexdigit()) { + return None; + } + Some(Self(raw.to_ascii_lowercase())) + } + + /// 字符串视图。 + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for AccountId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +/// 三条轨道。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AccountKind { + /// 离线(本地生成的 UUID,不联网)。 + Offline, + /// 微软(device-code)。 + Microsoft, + /// authlib-injector 第三方认证服务器(标准 Yggdrasil 协议)。 + AuthlibInjector, +} + +impl AccountKind { + /// 落盘用的稳定名(承 HMCL 的 `offline` / `microsoft` / `authlibInjector`)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Offline => "offline", + Self::Microsoft => "microsoft", + Self::AuthlibInjector => "authlibInjector", + } + } + + /// 解析(未知值返回 `None`,让上层跳过该条记录)。 + #[must_use] + pub fn parse(raw: &str) -> Option { + match raw { + "offline" => Some(Self::Offline), + "microsoft" => Some(Self::Microsoft), + // 兼容两种写法:HMCL 用 camelCase,别的地方有人写 kebab。 + "authlibInjector" | "authlib-injector" => Some(Self::AuthlibInjector), + _ => None, + } + } + + /// 人读标签(CLI/JSON 展示用)。 + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Offline => "离线", + Self::Microsoft => "微软", + Self::AuthlibInjector => "authlib-injector", + } + } +} + +/// 账户元数据(**可公开**,不含任何凭据)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountMeta { + /// 条目 id。 + pub id: AccountId, + /// 轨道。 + pub kind: AccountKind, + /// Minecraft 档案 UUID(32 位无连字符)。**不可变**。 + pub profile_id: String, + /// 展示名/玩家名;空串 = 「有档案但名字待刷新」(合法中间态)。 + #[serde(default)] + pub profile_name: String, + /// 登录名(authlib 轨的邮箱/用户名;其余轨为 `None`)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub login_name: Option, + /// authlib 认证服务器 API 根(其余轨为 `None`;凭据之外唯一需要持久化的服务器信息)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_url: Option, + /// 创建时刻(Unix 毫秒;仅用于排序/展示)。 + pub created_at_ms: u64, +} + +impl AccountMeta { + /// 构造一条元数据(`profile_name` 可后补)。 + #[must_use] + pub fn new( + kind: AccountKind, + profile_id: impl Into, + profile_name: impl Into, + created_at_ms: u64, + ) -> Self { + Self { + id: AccountId::generate(), + kind, + profile_id: profile_id.into(), + profile_name: profile_name.into(), + login_name: None, + server_url: None, + created_at_ms, + } + } + + /// 展示名:优先玩家名,其次登录名,最后条目 id(永不 panic、永不空串)。 + #[must_use] + pub fn display_name(&self) -> &str { + if !self.profile_name.trim().is_empty() { + return &self.profile_name; + } + if let Some(login_name) = &self.login_name { + if !login_name.trim().is_empty() { + return login_name; + } + } + self.id.as_str() + } + + /// 该条目是否匹配一个查找键(名字 / 登录名 / 条目 id)。 + /// + /// 大小写不敏感:玩家名在 MC 里大小写不敏感,CLI 用户不该为此折腾。 + #[must_use] + pub fn matches(&self, key: &str) -> bool { + let key = key.trim(); + if key.is_empty() { + return false; + } + self.profile_name.eq_ignore_ascii_case(key) + || self + .login_name + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case(key)) + || self.id.as_str().eq_ignore_ascii_case(key) + } + + /// 该条目的凭据是否与轨道匹配(防止磁盘上元数据被换成另一轨)。 + /// + /// # Errors + /// + /// 轨道与凭据种类不一致时返回 [`AuthError::Malformed`]。 + pub fn check_credentials(&self, credentials: &Credentials) -> AuthResult<()> { + if credentials.kind() == self.kind { + return Ok(()); + } + Err(AuthError::Malformed(format!( + "账户 {} 的元数据轨道是 {},凭据却是 {}", + self.id, + self.kind.as_str(), + credentials.kind().as_str() + ))) + } + + /// 从 JSON 值宽容解析(单条坏记录只返回 `Err`,由调用方跳过并记 warning)。 + /// + /// # Errors + /// + /// 缺字段/类型不符/轨道未知时返回 [`AuthError::Malformed`]。 + pub fn from_value(value: &serde_json::Value) -> AuthResult { + serde_json::from_value(value.clone()) + .map_err(|error| AuthError::Malformed(format!("账户元数据不合法:{error}"))) + } +} + +/// 凭据载荷(**私有**,只落 `credentials.dat`,永不进列表/日志)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Credentials { + /// 离线账户没有凭据(accessToken 每次登录随机生成,不落盘)。 + Offline, + /// 微软会话(access + refresh + `notAfter`)。 + Microsoft(MicrosoftSession), + /// authlib 会话(accessToken + clientToken)。 + AuthlibInjector(AuthlibSession), +} + +impl Credentials { + /// 所属轨道。 + #[must_use] + pub const fn kind(&self) -> AccountKind { + match self { + Self::Offline => AccountKind::Offline, + Self::Microsoft(_) => AccountKind::Microsoft, + Self::AuthlibInjector(_) => AccountKind::AuthlibInjector, + } + } + + /// 该凭据里的所有明文敏感值(武装脱敏器用;离线轨为空)。 + #[must_use] + pub fn secrets(&self) -> Vec<&str> { + match self { + Self::Offline => Vec::new(), + Self::Microsoft(session) => session + .secrets() + .into_iter() + .map(|secret| secret.expose()) + .collect(), + Self::AuthlibInjector(session) => session + .secrets() + .into_iter() + .map(|secret| secret.expose()) + .collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::{AccountId, AccountKind, AccountMeta, Credentials}; + use crate::auth::microsoft::MicrosoftSession; + use crate::auth::secret::Secret; + + #[test] + fn account_id_shape_and_parse() { + let id = AccountId::generate(); + assert!(id.as_str().starts_with("account:")); + assert_eq!(id.as_str().len(), "account:".len() + 32); + assert_eq!(AccountId::parse(id.as_str()), Some(id.clone())); + assert_eq!(AccountId::parse("account:xyz"), None); + assert_eq!(AccountId::parse("0192-blah"), None); + } + + #[test] + fn kind_roundtrip() { + for kind in [ + AccountKind::Offline, + AccountKind::Microsoft, + AccountKind::AuthlibInjector, + ] { + assert_eq!(AccountKind::parse(kind.as_str()), Some(kind)); + assert!(!kind.label().is_empty()); + } + assert_eq!(AccountKind::parse("nope"), None); + } + + #[test] + fn meta_matches_name_login_and_id() { + let mut meta = AccountMeta::new(AccountKind::Offline, "uuid32", "Steve", 1); + meta.login_name = Some("steve@example.com".to_string()); + assert!(meta.matches("Steve")); + assert!(meta.matches("steve")); + assert!(meta.matches("steve@example.com")); + assert!(meta.matches(meta.id.as_str())); + assert!(!meta.matches("Alex")); + assert!(!meta.matches(" ")); + } + + #[test] + fn display_name_falls_back() { + let mut meta = AccountMeta::new(AccountKind::Microsoft, "uuid", "", 1); + assert_eq!(meta.display_name(), meta.id.as_str()); + meta.login_name = Some("me@example.com".to_string()); + assert_eq!(meta.display_name(), "me@example.com"); + meta.profile_name = "Steve".to_string(); + assert_eq!(meta.display_name(), "Steve"); + } + + #[test] + fn credential_kind_must_match_meta() { + let meta = AccountMeta::new(AccountKind::Microsoft, "uuid", "Steve", 1); + let session = MicrosoftSession::new( + "uuid", + "Steve", + Secret::new("a"), + Secret::new("r"), + 60, + 0, + None, + ); + assert!(meta + .check_credentials(&Credentials::Microsoft(session)) + .is_ok()); + assert!(meta.check_credentials(&Credentials::Offline).is_err()); + } + + #[test] + fn secrets_only_expose_real_credentials() { + assert!(Credentials::Offline.secrets().is_empty()); + let session = MicrosoftSession::new( + "uuid", + "Steve", + Secret::new("mc-token"), + Secret::new("live-refresh"), + 60, + 0, + None, + ); + assert_eq!( + Credentials::Microsoft(session).secrets(), + vec!["mc-token", "live-refresh"] + ); + } + + #[test] + fn malformed_record_is_an_error_not_a_panic() { + let value = serde_json::json!({ "id": "account:zz", "kind": "offline" }); + assert!(AccountMeta::from_value(&value).is_err()); + } +} diff --git a/crates/core/src/auth/authlib.rs b/crates/core/src/auth/authlib.rs new file mode 100644 index 0000000..f2dabff --- /dev/null +++ b/crates/core/src/auth/authlib.rs @@ -0,0 +1,347 @@ +//! authlib-injector 轨(**账户侧**):标准 Yggdrasil 协议的登录/刷新/校验/注销。 +//! +//! 边界(mission 明示):本模块只做「账户」;`-javaagent` 三件套(jar 下载、 +//! `-Dauthlibinjector.side=client`、`yggdrasil.prefetched` 的 Base64)归后续 mission。 +//! 依据 `analyse/04` §6.1–§6.2(端点布局与四动作报文)与 §6.3(服务发现约定)。 +//! +//! 三条照抄要点: +//! - 端点 = `authserver/{authenticate,refresh,validate,invalidate}`,`apiRoot` +//! 必须带尾斜杠(`https://littleskin.cn/api/yggdrasil/` 这种目录布局是规范要求); +//! - `clientToken` 是**启动器侧生成的随机值**,服务端回传必须一致(防会话被替换/重放); +//! - 「token 无效」用**返回值 `false`** 表达(`validate` 的 `ForbiddenOperationException`), +//! 「服务器抽风」才抛错——调用方据此决定「刷新」还是「重试」。 + +pub mod server_meta; + +pub use server_meta::AuthlibServerMeta; + +use reqwest::Client; +use serde_json::json; + +use crate::auth::error::{AuthError, AuthResult}; +use crate::auth::http::{post_json, Reply}; +use crate::auth::secret::Secret; +use crate::download::config::EngineConfig; +use crate::download::http::build_client; +use crate::launch::request::AuthPlaceholders; + +/// authlib 轨的 launch 用户类型(承 HMCL `YggdrasilSession.toAuthInfo`:也是 `msa`)。 +pub const USER_TYPE_MSA: &str = "msa"; + +/// 把用户输入规范成 API 根:补 `https://`、补尾斜杠、去空白。 +/// +/// # Errors +/// +/// 空串或只剩协议头时返回 [`AuthError::Malformed`]。 +pub fn normalize_api_root(input: &str) -> AuthResult { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(AuthError::Malformed( + "认证服务器地址不能为空(例:https://littleskin.cn/api/yggdrasil/)".to_string(), + )); + } + let with_scheme = if trimmed.starts_with("https://") || trimmed.starts_with("http://") { + trimmed.to_string() + } else { + format!("https://{trimmed}") + }; + let host = with_scheme + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(""); + if host.trim_matches('/').is_empty() { + return Err(AuthError::Malformed(format!( + "认证服务器地址不完整:`{input}`" + ))); + } + Ok(if with_scheme.ends_with('/') { + with_scheme + } else { + format!("{with_scheme}/") + }) +} + +/// Yggdrasil 四个端点(都从 API 根派生)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthlibEndpoints { + /// `authserver/authenticate`。 + pub authenticate: String, + /// `authserver/refresh`。 + pub refresh: String, + /// `authserver/validate`。 + pub validate: String, + /// `authserver/invalidate`。 + pub invalidate: String, +} + +impl AuthlibEndpoints { + /// 从 API 根派生(先 [`normalize_api_root`])。 + /// + /// # Errors + /// + /// 地址不合法时返回 [`AuthError::Malformed`]。 + pub fn from_api_root(api_root: &str) -> AuthResult { + let root = normalize_api_root(api_root)?; + let join = |path: &str| format!("{root}{path}"); + Ok(Self { + authenticate: join("authserver/authenticate"), + refresh: join("authserver/refresh"), + validate: join("authserver/validate"), + invalidate: join("authserver/invalidate"), + }) + } +} + +/// 一个 Yggdrasil 角色(`selectedProfile`)。 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct AuthlibProfile { + /// 32 位无连字符 UUID。 + pub id: String, + /// 角色名。 + pub name: String, +} + +/// authlib 会话 = 该账户的凭据载荷。 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthlibSession { + /// Yggdrasil accessToken。 + pub access_token: Secret, + /// 启动器生成的 clientToken(刷新/校验都要带)。 + pub client_token: Secret, + /// 当前选中的角色(多角色服务器里由 refresh 的 `selectedProfile` 决定)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_profile: Option, +} + +impl AuthlibSession { + /// 该会话的全部凭据值(武装脱敏器)。 + #[must_use] + pub fn secrets(&self) -> Vec<&Secret> { + vec![&self.access_token, &self.client_token] + } + + /// 有没有角色(没角色的会话不能启动游戏)。 + #[must_use] + pub fn has_profile(&self) -> bool { + self.selected_profile + .as_ref() + .is_some_and(|profile| !profile.name.trim().is_empty() && !profile.id.is_empty()) + } + + /// 转成 launch 占位符。 + /// + /// `user_properties` 用 `{}`:纹理由游戏进程经 authlib-injector 向服务器取 + /// (注入参数属后续 mission),启动参数不需要它。 + /// + /// # Errors + /// + /// 会话没有角色时返回 [`AuthError::CredentialExpired`](需要重新选角色/登录)。 + pub fn to_placeholders(&self) -> AuthResult { + let profile = self + .selected_profile + .as_ref() + .filter(|profile| !profile.name.trim().is_empty()) + .ok_or(AuthError::CredentialExpired)?; + Ok(AuthPlaceholders { + player_name: profile.name.clone(), + uuid: profile.id.clone(), + access_token: self.access_token.expose().to_string(), + user_type: USER_TYPE_MSA.to_string(), + user_properties: "{}".to_string(), + }) + } +} + +/// authlib 认证客户端。 +#[derive(Debug, Clone)] +pub struct AuthlibAuth { + client: Client, +} + +impl AuthlibAuth { + /// 构造(HTTP 基座复用 `core::download`)。 + /// + /// # Errors + /// + /// 客户端建不起来时返回 [`AuthError::Network`]。 + pub fn new() -> AuthResult { + let client = build_client(&EngineConfig::default()) + .map_err(|error| AuthError::network("http-client", error.to_string()))?; + Ok(Self { client }) + } + + /// 注入客户端(测试)。 + #[must_use] + pub fn with_client(mut self, client: Client) -> Self { + self.client = client; + self + } + + /// 密码登录(**新账户**)。密码只在本调用栈里出现,不落盘、不入日志。 + /// + /// # Errors + /// + /// 密码错/服务器拒绝 → [`AuthError::Denied`];网络与结构问题各有分流。 + pub async fn authenticate( + &self, + api_root: &str, + login_name: &str, + password: &Secret, + client_token: &Secret, + ) -> AuthResult { + let endpoints = AuthlibEndpoints::from_api_root(api_root)?; + let body = json!({ + "agent": { "name": "Minecraft", "version": 1 }, + "username": login_name, + "password": password.expose(), + "clientToken": client_token.expose(), + "requestUser": true, + }); + let reply = post_json(&self.client, &endpoints.authenticate, &body, None).await?; + if !reply.is_success() { + return Err(rejected(&reply, &endpoints.authenticate, "登录")); + } + let session = session_from(&reply, client_token, &endpoints.authenticate)?; + if !session.has_profile() { + // 服务端没自动选角色(多角色服务器):交给调用方走 refresh + selectedProfile。 + return Ok(session); + } + Ok(session) + } + + /// 刷新(可顺带 `selectedProfile` 换角色)。 + /// + /// # Errors + /// + /// token 失效 → [`AuthError::CredentialExpired`];换角色失败 → [`AuthError::Malformed`]。 + pub async fn refresh( + &self, + api_root: &str, + session: &AuthlibSession, + select: Option<&AuthlibProfile>, + ) -> AuthResult { + let endpoints = AuthlibEndpoints::from_api_root(api_root)?; + let mut body = json!({ + "accessToken": session.access_token.expose(), + "clientToken": session.client_token.expose(), + "requestUser": true, + }); + if let Some(profile) = select { + body["selectedProfile"] = json!({ "id": profile.id, "name": profile.name }); + } + let reply = post_json(&self.client, &endpoints.refresh, &body, None).await?; + if !reply.is_success() { + if is_forbidden(&reply) { + return Err(AuthError::CredentialExpired); + } + return Err(rejected(&reply, &endpoints.refresh, "刷新")); + } + let refreshed = session_from(&reply, &session.client_token, &endpoints.refresh)?; + if let Some(wanted) = select { + let got = refreshed.selected_profile.as_ref(); + if got.map(|profile| profile.id.as_str()) != Some(wanted.id.as_str()) { + return Err(AuthError::Malformed(format!( + "服务器没有切到角色 {}(refresh 未生效)", + wanted.name + ))); + } + } + Ok(refreshed) + } + + /// 校验 token 是否仍有效(`ForbiddenOperationException` = `Ok(false)`)。 + /// + /// # Errors + /// + /// 服务器 5xx/网络失败时返回错误(调用方据此区分「要刷新」与「要重试」)。 + pub async fn validate(&self, api_root: &str, session: &AuthlibSession) -> AuthResult { + let endpoints = AuthlibEndpoints::from_api_root(api_root)?; + let body = json!({ + "accessToken": session.access_token.expose(), + "clientToken": session.client_token.expose(), + }); + let reply = post_json(&self.client, &endpoints.validate, &body, None).await?; + if is_forbidden(&reply) { + return Ok(false); + } + if !reply.is_success() { + return Err(rejected(&reply, &endpoints.validate, "校验")); + } + // 成功时 body 为空或 `{}`,都算有效。 + Ok(true) + } + + /// 主动注销(删账户时顺手清理服务端会话;失败按「尽力而为」返回错误,调用方可不理)。 + /// + /// # Errors + /// + /// 网络/服务端失败时返回错误。 + pub async fn invalidate(&self, api_root: &str, session: &AuthlibSession) -> AuthResult<()> { + let endpoints = AuthlibEndpoints::from_api_root(api_root)?; + let body = json!({ + "accessToken": session.access_token.expose(), + "clientToken": session.client_token.expose(), + }); + let reply = post_json(&self.client, &endpoints.invalidate, &body, None).await?; + if reply.is_success() || is_forbidden(&reply) { + return Ok(()); + } + Err(rejected(&reply, &endpoints.invalidate, "注销")) + } +} + +/// 响应 → 会话(`clientToken` 回传必须一致;缺失时保留原值)。 +fn session_from(reply: &Reply, client_token: &Secret, url: &str) -> AuthResult { + let access_token = reply + .text("accessToken") + .ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 accessToken")))?; + if let Some(returned) = reply.text("clientToken") { + if returned != client_token.expose() { + return Err(AuthError::Malformed( + "服务器回传的 clientToken 与本地不一致(会话可能被替换)".to_string(), + )); + } + } + Ok(AuthlibSession { + access_token: Secret::new(access_token), + client_token: client_token.clone(), + selected_profile: selected_profile(reply), + }) +} + +fn selected_profile(reply: &Reply) -> Option { + let profile = reply + .body + .as_ref() + .and_then(|body| body.get("selectedProfile"))?; + let id = profile.get("id")?.as_str()?; + let name = profile.get("name")?.as_str()?; + Some(AuthlibProfile { + id: id.to_string(), + name: name.to_string(), + }) +} + +fn is_forbidden(reply: &Reply) -> bool { + reply.text("error") == Some("ForbiddenOperationException") +} + +fn rejected(reply: &Reply, url: &str, what: &str) -> AuthError { + match reply.text("error") { + Some(code) => AuthError::Denied { + code: code.to_string(), + description: reply + .text("errorMessage") + .unwrap_or("服务器未给描述") + .to_string(), + }, + None => AuthError::network( + url.split('?').next().unwrap_or(url), + format!("{what}失败(HTTP {})", reply.status), + ), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/core/src/auth/authlib/server_meta.rs b/crates/core/src/auth/authlib/server_meta.rs new file mode 100644 index 0000000..5885293 --- /dev/null +++ b/crates/core/src/auth/authlib/server_meta.rs @@ -0,0 +1,86 @@ +//! 认证服务器元数据里我们真正需要的字段(服务发现的产物)。 +//! +//! authlib-injector 规范里根路径 `/` 返回 `{ meta, skinDomains, signaturePublickey }` +//! (`analyse/04` §6.3)。本期**只保存**名字/非邮箱登录/skinDomains: +//! `signaturePublickey` 与 `prefetched` 的 Base64 属于注入参数(后续 mission), +//! 现在存了也没人消费,不如不存。 + +use serde_json::Value; + +/// 服务器元数据(缺失字段一律回落默认值)。 +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthlibServerMeta { + /// 服务器显示名(缺省回落 URL)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// 是否允许非邮箱登录名(决定输入框校验)。 + #[serde(default)] + pub non_email_login: bool, + /// `skinDomains`(纹理白名单,注入参数会用;本期只保存)。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skin_domains: Vec, +} + +impl AuthlibServerMeta { + /// 从根元数据 JSON 解析(容错:缺字段就用默认值)。 + #[must_use] + pub fn from_value(value: &Value) -> Self { + let meta = value.get("meta"); + let name = meta + .and_then(|meta| meta.get("serverName")) + .and_then(Value::as_str) + .map(str::to_string); + let non_email_login = meta + .and_then(|meta| meta.get("feature.non_email_login")) + .and_then(Value::as_bool) + .unwrap_or(false); + let skin_domains = value + .get("skinDomains") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + Self { + name, + non_email_login, + skin_domains, + } + } +} + +#[cfg(test)] +mod tests { + use super::AuthlibServerMeta; + use serde_json::json; + + #[test] + fn parses_full_metadata() { + let value = json!({ + "meta": { + "serverName": "Example Skin Site", + "feature.non_email_login": true + }, + "skinDomains": ["example.com", "*.example.com"], + "signaturePublickey": "-----BEGIN PUBLIC KEY-----" + }); + let meta = AuthlibServerMeta::from_value(&value); + assert_eq!(meta.name.as_deref(), Some("Example Skin Site")); + assert!(meta.non_email_login); + assert_eq!(meta.skin_domains.len(), 2); + } + + #[test] + fn tolerates_missing_fields() { + let meta = AuthlibServerMeta::from_value(&json!({})); + assert_eq!(meta, AuthlibServerMeta::default()); + assert!(meta.name.is_none()); + assert!(!meta.non_email_login); + assert!(meta.skin_domains.is_empty()); + } +} diff --git a/crates/core/src/auth/authlib/tests.rs b/crates/core/src/auth/authlib/tests.rs new file mode 100644 index 0000000..2cb9bcc --- /dev/null +++ b/crates/core/src/auth/authlib/tests.rs @@ -0,0 +1,97 @@ +//! authlib 轨的纯函数/模型测试(HTTP 部分在 `tests/auth/authlib_api.rs`)。 + +use super::{normalize_api_root, AuthlibEndpoints, AuthlibProfile, AuthlibSession, USER_TYPE_MSA}; +use crate::auth::secret::Secret; + +#[test] +fn api_root_normalization() { + assert_eq!( + normalize_api_root("littleskin.cn/api/yggdrasil").expect("补协议"), + "https://littleskin.cn/api/yggdrasil/" + ); + assert_eq!( + normalize_api_root(" http://127.0.0.1:8080/api/yggdrasil/ ").expect("保留协议"), + "http://127.0.0.1:8080/api/yggdrasil/" + ); + assert_eq!( + normalize_api_root("https://littleskin.cn").expect("根域名"), + "https://littleskin.cn/" + ); + assert!(normalize_api_root(" ").is_err()); + assert!(normalize_api_root("https://").is_err()); +} + +#[test] +fn endpoints_follow_directory_layout() { + let endpoints = + AuthlibEndpoints::from_api_root("https://example.invalid/api/yggdrasil").expect("端点"); + assert_eq!( + endpoints.authenticate, + "https://example.invalid/api/yggdrasil/authserver/authenticate" + ); + assert_eq!( + endpoints.refresh, + "https://example.invalid/api/yggdrasil/authserver/refresh" + ); + assert_eq!( + endpoints.validate, + "https://example.invalid/api/yggdrasil/authserver/validate" + ); + assert_eq!( + endpoints.invalidate, + "https://example.invalid/api/yggdrasil/authserver/invalidate" + ); +} + +#[test] +fn placeholders_require_a_profile() { + let without = AuthlibSession { + access_token: Secret::new("token"), + client_token: Secret::new("client"), + selected_profile: None, + }; + assert!(!without.has_profile()); + assert!(without.to_placeholders().is_err()); + + let with = AuthlibSession { + selected_profile: Some(AuthlibProfile { + id: "0123456789abcdef0123456789abcdef".to_string(), + name: "Steve".to_string(), + }), + ..without + }; + assert!(with.has_profile()); + let placeholders = with.to_placeholders().expect("占位符"); + assert_eq!(placeholders.player_name, "Steve"); + assert_eq!(placeholders.uuid, "0123456789abcdef0123456789abcdef"); + assert_eq!(placeholders.user_type, USER_TYPE_MSA); + assert_eq!(placeholders.access_token, "token"); +} + +#[test] +fn session_debug_never_leaks_tokens() { + let session = AuthlibSession { + access_token: Secret::new("access-token-value"), + client_token: Secret::new("client-token-value"), + selected_profile: None, + }; + let debug = format!("{session:?}"); + assert!(!debug.contains("access-token-value")); + assert!(!debug.contains("client-token-value")); + assert_eq!(session.secrets().len(), 2); +} + +#[test] +fn session_survives_json_roundtrip() { + let session = AuthlibSession { + access_token: Secret::new("access-token-value"), + client_token: Secret::new("client-token-value"), + selected_profile: Some(AuthlibProfile { + id: "0123456789abcdef0123456789abcdef".to_string(), + name: "Steve".to_string(), + }), + }; + let text = serde_json::to_string(&session).expect("序列化"); + let back: AuthlibSession = serde_json::from_str(&text).expect("反序列化"); + assert_eq!(back, session); +} diff --git a/crates/core/src/auth/body/mod.rs b/crates/core/src/auth/body/mod.rs new file mode 100644 index 0000000..91a1c53 --- /dev/null +++ b/crates/core/src/auth/body/mod.rs @@ -0,0 +1,279 @@ +//! 非 2xx 应答的**正文诊断**(M38):把 M31「响应体一律丢掉」这个盲区补上。 +//! +//! 起因是 `findings/20261030-tower-improve-login-with-xbox-403.md`:MSA 真机验收两轮都是 +//! OAuth→XBL→XSTS 全通、`login_with_xbox` 稳定 403,而旧 `rejected()` 只报状态码, +//! 于是「区域/网络拦截页」和「服务端拒绝(许可/区域)」在文案上分不开。 +//! +//! 这里只做三件事: +//! 1. **分类**:JSON(真实服务应答)/ HTML 或纯文本(多为网关、风控或代理拦截页)/ 空体; +//! 2. **提取**:JSON 里的 `error` / `errorMessage` / `reason` / `Message` 等服务端文案直出; +//! 3. **摘要**:正文压成 ≤[`DIGEST_CHARS`] 字符,**先过 [`Redactor`] 再进错误消息**。 +//! +//! 凭据红线(M31 的 `Secret`/脱敏体系不破): +//! - 摘要只从**非 2xx** 响应构造——OAuth 成功响应体(含 token)永远不会走到这里; +//! - `Reply` 的原文快照只在 JSON 解析失败时保留(见 [`crate::auth::http`]), +//! 解析成功的应答不留原文,避免把 token 文本复制进第二个字段; +//! - 摘要与提取出的服务端文案都过同一套脱敏:[`Redactor::scrub`](已登记 token 整串替换、 +//! ≥64 不透明串打码、JWT 形状的带点 token 打码——第三条 M40 已上提到 `launch::redact`, +//! 本模块不再自己补规则)。 + +use serde_json::Value; + +use crate::auth::error::AuthError; +use crate::auth::http::Reply; +use crate::launch::redact::Redactor; + +#[cfg(test)] +mod tests; + +/// 错误文案 / 事件里正文摘要的上限(字符)。 +pub const DIGEST_CHARS: usize = 200; + +/// 服务端文案的取值字段(按优先级,命中即收;`Message` 是 XSTS 的写法)。 +const SERVER_MESSAGE_KEYS: [&str; 6] = [ + "errorMessage", + "error_message", + "error", + "errorType", + "reason", + "Message", +]; + +/// 一个非 2xx 应答的正文形状。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BodyKind { + /// 合法 JSON(真实服务应答)。 + Json, + /// HTML 页面(网关/风控/代理拦截页的典型形状)。 + Html, + /// 其它非 JSON 文本。 + Text, + /// 空体。 + Empty, +} + +impl BodyKind { + /// 稳定名(错误文案与 JSONL 事件共用)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Json => "json", + Self::Html => "html", + Self::Text => "text", + Self::Empty => "empty", + } + } + + /// 是否是「拦截页」形状(HTML / 纯文本):这一类先怀疑网络,而不是账号。 + #[must_use] + pub const fn is_interception(self) -> bool { + matches!(self, Self::Html | Self::Text) + } +} + +/// 一次非 2xx 的诊断结果([`AuthError::Response`] 的载荷,也是 JSONL 事件的字段来源)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResponseDetail { + /// 链上的步骤(`xbl` / `xsts` / `login_with_xbox` / `entitlements` / `profile`)。 + pub step: String, + /// 这一步在做什么(人话前缀,如「Minecraft 登录被拒」)。 + pub what: String, + /// HTTP 状态码。 + pub status: u16, + /// `Content-Type`(服务端没给时为 `None`)。 + pub content_type: Option, + /// 正文形状。 + pub kind: BodyKind, + /// 服务端文案(已脱敏、已截断)。 + pub server_message: Option, + /// 正文摘要(已脱敏,≤[`DIGEST_CHARS`] 字符)。 + pub digest: String, +} + +impl ResponseDetail { + /// 细分机读码(**只增不改**):JSON 拒绝沿用 M31 的 `_`; + /// 非 JSON 用新增后缀 `_non_json`;空体用 `_empty_body`。 + #[must_use] + pub fn detail_code(&self) -> String { + let suffix = match self.kind { + BodyKind::Json => self.status.to_string(), + BodyKind::Html | BodyKind::Text => "non_json".to_string(), + BodyKind::Empty => "empty_body".to_string(), + }; + format!("{}_{suffix}", self.step) + } + + /// 是否是「疑似网络拦截页」(HTML / 纯文本)。 + #[must_use] + pub const fn is_interception(&self) -> bool { + self.kind.is_interception() + } + + /// 「;服务端文案…;响应摘要…」片段(都没有时为空串,方便复用文案拼装)。 + #[must_use] + pub fn note(&self) -> String { + let mut note = String::new(); + if let Some(message) = &self.server_message { + note.push_str(&format!(";服务端文案「{message}」")); + } + if !self.digest.is_empty() { + note.push_str(&format!(";响应摘要:{}", self.digest)); + } + note + } + + /// 人读一句话(**不含凭据**),403 的两种分流在这里分开。 + #[must_use] + pub fn message(&self) -> String { + let mut text = format!("{}(HTTP {}", self.what, self.status); + if let Some(content_type) = &self.content_type { + text.push_str(&format!(",Content-Type: {content_type}")); + } + text.push(')'); + match self.kind { + BodyKind::Json => text.push_str(":服务端返回了 JSON 拒绝"), + BodyKind::Html | BodyKind::Text => { + text.push_str(":响应非 JSON(可能网络拦截/网关页)"); + } + BodyKind::Empty => text.push_str(":响应体为空(可能网络拦截/网关页)"), + } + text.push(';'); + text.push_str(advice(&self.step, self.kind)); + text.push_str(&self.note()); + text + } +} + +/// 下一步该往哪查(403 分流的核心:拦截页查网络/代理,JSON 拒绝查账号许可)。 +#[must_use] +pub fn advice(step: &str, kind: BodyKind) -> &'static str { + if kind.is_interception() { + return "请检查网络或代理(关掉/换掉代理后再试,必要时用镜像方案)"; + } + match step { + "login_with_xbox" => "若网络正常,请检查该账号的 Minecraft 许可与所在区域", + "entitlements" => "若网络正常,请检查该账号是否拥有 Minecraft Java 版", + "profile" => "若网络正常,请去 minecraft.net 检查档案状态", + _ => "请按服务端文案处理;如反复失败,检查网络代理是否生效", + } +} + +/// 分类一个非 2xx 应答。 +pub(crate) fn classify(reply: &Reply) -> BodyKind { + if reply.body.is_some() { + return BodyKind::Json; + } + let Some(raw) = reply + .raw + .as_deref() + .map(str::trim) + .filter(|raw| !raw.is_empty()) + else { + return BodyKind::Empty; + }; + let content_type = reply + .content_type + .as_deref() + .unwrap_or_default() + .to_ascii_lowercase(); + if content_type.contains("html") || looks_like_html(raw) { + BodyKind::Html + } else { + BodyKind::Text + } +} + +/// 正文是不是 HTML 文档(没给 `Content-Type` 时的兜底判断)。 +fn looks_like_html(raw: &str) -> bool { + raw.trim_start().starts_with('<') +} + +/// 组装一条诊断(分类 + 服务端文案 + 摘要)。 +pub(crate) fn rejection( + step: &str, + reply: &Reply, + what: &str, + redactor: &Redactor, +) -> ResponseDetail { + ResponseDetail { + step: step.to_string(), + what: what.to_string(), + status: reply.status, + content_type: reply.content_type.clone(), + kind: classify(reply), + server_message: server_message(reply, redactor), + digest: digest(reply, redactor), + } +} + +/// 从 JSON 体里挑出服务端文案(去重后以 ` / ` 连接)。 +fn server_message(reply: &Reply, redactor: &Redactor) -> Option { + let body = reply.body.as_ref()?; + let mut parts: Vec = Vec::new(); + for key in SERVER_MESSAGE_KEYS { + let Some(text) = body.get(key).and_then(Value::as_str) else { + continue; + }; + let text = one_line(text); + if text.is_empty() || parts.iter().any(|part| part == &text) { + continue; + } + parts.push(text); + } + if parts.is_empty() { + return None; + } + Some(truncate(&scrub(&parts.join(" / "), redactor), DIGEST_CHARS)) +} + +/// 正文摘要:JSON 用紧凑文本,非 JSON 用原文快照;先脱敏再截断。 +fn digest(reply: &Reply, redactor: &Redactor) -> String { + let raw = match (&reply.body, &reply.raw) { + (Some(body), _) => body.to_string(), + (None, Some(raw)) => raw.to_string(), + (None, None) => return String::new(), + }; + truncate(&scrub(&one_line(&raw), redactor), DIGEST_CHARS) +} + +/// 走完整套脱敏:[`Redactor::scrub`] 的三条规则(已登记 token + ≥64 不透明串 + 带点 token)。 +/// +/// M40 之前这里自己补了一条「带点 token」规则:`Redactor` 只按「连续不透明串 ≥64」判形状, +/// 而 JWT(`header.payload.signature`)里有点号,把连续段切短,恰好躲过那条规则——服务端 +/// 把 token 回显在错误体里时就会原样进摘要。现在规则已上提到 `launch::redact`(全局底座), +/// 这里只调共用实现,两处规则不会再漂。 +fn scrub(text: &str, redactor: &Redactor) -> String { + redactor.scrub(text).into_owned() +} + +/// 压成一行(HTML 的分行会让错误消息难以阅读)。 +fn one_line(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut space = false; + for ch in text.chars() { + if ch.is_whitespace() { + space = !out.is_empty(); + continue; + } + if std::mem::take(&mut space) { + out.push(' '); + } + out.push(ch); + } + out +} + +/// 按字符截断(超长补 `…`)。 +fn truncate(text: &str, limit: usize) -> String { + if text.chars().count() <= limit { + return text.to_string(); + } + let mut out: String = text.chars().take(limit.saturating_sub(1)).collect(); + out.push('…'); + out +} + +/// 把诊断包成 [`AuthError::Response`](链上各步骤的统一出口)。 +pub(crate) fn response(step: &str, reply: &Reply, what: &str, redactor: &Redactor) -> AuthError { + AuthError::Response(Box::new(rejection(step, reply, what, redactor))) +} diff --git a/crates/core/src/auth/body/tests.rs b/crates/core/src/auth/body/tests.rs new file mode 100644 index 0000000..de5143c --- /dev/null +++ b/crates/core/src/auth/body/tests.rs @@ -0,0 +1,168 @@ +//! [`super`] 的单测:分类、服务端文案提取、摘要脱敏与 403 分流文案。 + +use super::{ + advice, classify, digest, one_line, response, server_message, truncate, BodyKind, DIGEST_CHARS, +}; +use crate::auth::error::AuthError; +use crate::auth::http::Reply; +use crate::auth::secret::{redactor_for, Secret}; +use crate::launch::redact::Redactor; +use serde_json::json; + +const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g"; + +fn json_reply(status: u16, body: serde_json::Value) -> Reply { + Reply { + status, + body: Some(body), + content_type: Some("application/json".to_string()), + raw: None, + } +} + +fn text_reply(status: u16, content_type: Option<&str>, raw: &str) -> Reply { + Reply { + status, + body: None, + content_type: content_type.map(str::to_string), + raw: Some(raw.to_string()), + } +} + +#[test] +fn classification_splits_json_html_text_and_empty() { + assert_eq!( + classify(&json_reply(403, json!({ "error": "x" }))), + BodyKind::Json + ); + assert_eq!( + classify(&text_reply(403, Some("text/html"), "")), + BodyKind::Html + ); + // 没给 Content-Type 时靠正文形状兜底。 + assert_eq!( + classify(&text_reply(403, None, "blocked")), + BodyKind::Html + ); + assert_eq!( + classify(&text_reply(502, Some("text/plain"), "Bad Gateway")), + BodyKind::Text + ); + assert_eq!(classify(&text_reply(403, None, " ")), BodyKind::Empty); +} + +#[test] +fn server_message_prefers_error_fields_and_dedups() { + let redactor = Redactor::new(); + let reply = json_reply( + 403, + json!({ "error": "UNAUTHORIZED", "errorMessage": "no license", "path": "/x" }), + ); + let message = server_message(&reply, &redactor).expect("应有服务端文案"); + assert!(message.contains("no license"), "{message}"); + assert!(message.contains("UNAUTHORIZED"), "{message}"); + + let same = json_reply(403, json!({ "error": "DENIED", "errorMessage": "DENIED" })); + assert_eq!(server_message(&same, &redactor).as_deref(), Some("DENIED")); + assert!(server_message(&json_reply(403, json!({ "path": "/x" })), &redactor).is_none()); + assert!(server_message(&text_reply(500, None, "boom"), &redactor).is_none()); +} + +#[test] +fn digest_is_redacted_and_capped() { + let token = Secret::new("mc-access-token-value"); + let redactor = redactor_for([&token]); + let reply = json_reply( + 403, + json!({ "error": "DENIED", "echo": format!("Bearer {}", token.expose()) }), + ); + let masked_digest = digest(&reply, &redactor); + assert!( + !masked_digest.contains("mc-access-token-value"), + "{masked_digest}" + ); + + let long = "blocked ".repeat(60); + let capped = digest(&text_reply(500, Some("text/plain"), &long), &redactor); + assert!(capped.chars().count() <= DIGEST_CHARS); + assert!(capped.ends_with('…')); +} + +#[test] +fn opaque_runs_are_masked_even_without_known_secret() { + let masked = digest(&text_reply(401, Some("text/plain"), JWT), &Redactor::new()); + assert!(!masked.contains("eyJhbGci"), "{masked}"); + assert!(masked.contains("redacted"), "{masked}"); +} + +#[test] +fn dotted_tokens_are_masked_by_the_shared_redactor() { + // 规则本体在 `launch::redact`(M40 上提),这里只验「auth 摘要走的是同一份实现」。 + let redactor = Redactor::new(); + assert!(!redactor.scrub(JWT).contains("eyJhbGci"), "JWT 应被打码"); + // 普通文案里的点不会触发(分段太短)。 + assert_eq!( + redactor.scrub("Service.Unavailable.today"), + "Service.Unavailable.today" + ); + let masked = redactor.scrub(&format!("token={JWT}&end")).into_owned(); + assert!(!masked.contains("eyJhbGci"), "{masked}"); + assert!(masked.ends_with("&end"), "{masked}"); +} + +#[test] +fn one_line_collapses_whitespace() { + assert_eq!(one_line(" a\n\n b\tc "), "a b c"); + assert_eq!(truncate("abcdef", 4), "abc…"); + assert_eq!(truncate("abc", 4), "abc"); +} + +#[test] +fn detail_codes_and_advice_split_network_from_license() { + let redactor = Redactor::new(); + let blocked = response( + "login_with_xbox", + &text_reply(403, Some("text/html"), "blocked"), + "Minecraft 登录被拒", + &redactor, + ); + let AuthError::Response(detail) = &blocked else { + panic!("应是 Response,实际 {blocked:?}"); + }; + assert_eq!(detail.detail_code(), "login_with_xbox_non_json"); + assert!( + detail.message().contains("网络拦截"), + "{}", + detail.message() + ); + assert!(detail.message().contains("代理"), "{}", detail.message()); + + let denied = response( + "login_with_xbox", + &json_reply( + 403, + json!({ "error": "UNAUTHORIZED", "errorMessage": "nope" }), + ), + "Minecraft 登录被拒", + &redactor, + ); + let AuthError::Response(detail) = &denied else { + panic!("应是 Response,实际 {denied:?}"); + }; + assert_eq!(detail.detail_code(), "login_with_xbox_403"); + assert!(detail.message().contains("许可"), "{}", detail.message()); + assert!(!detail.message().contains("代理"), "{}", detail.message()); + + let empty = response( + "profile", + &text_reply(500, None, ""), + "拉取档案被拒", + &redactor, + ); + let AuthError::Response(detail) = &empty else { + panic!("应是 Response,实际 {empty:?}"); + }; + assert_eq!(detail.detail_code(), "profile_empty_body"); + assert!(!advice("xbl", BodyKind::Html).is_empty()); + assert!(!advice("xbl", BodyKind::Json).is_empty()); +} diff --git a/crates/core/src/auth/error.rs b/crates/core/src/auth/error.rs new file mode 100644 index 0000000..a9fd332 --- /dev/null +++ b/crates/core/src/auth/error.rs @@ -0,0 +1,250 @@ +//! 认证错误:把 HMCL 的异常树压成「调用方能分流」的一层(`analyse/04` §2.6 / §4.5)。 +//! +//! 三条设计: +//! 1. **`CredentialExpired` 是唯一「要人再登录」的信号**(承 HMCL `CredentialExpiredException`), +//! 其余错误都不该弹交互; +//! 2. **取消 / 超时 / 被拒 三分**(mission 明示的错误分流):用户关窗口 = `Canceled`, +//! 设备码 15 分钟硬超时 = `Timeout`,`access_denied` = `Denied`; +//! 3. **文案里永不含凭据**:错误只描述发生了什么,token 一律不进 message。 + +use std::fmt; + +use crate::auth::body::ResponseDetail; + +/// Xbox Live 错误码:账号被封(`MicrosoftService.BANNED`)。 +pub const XERR_BANNED: u32 = 2_148_916_227; +/// 没有 Xbox 账号(需先建档案)。 +pub const XERR_MISSING_XBOX_ACCOUNT: u32 = 2_148_916_233; +/// 地区不可用。 +pub const XERR_COUNTRY_UNAVAILABLE: u32 = 2_148_916_235; +/// 儿童账号未加入家庭组。 +pub const XERR_ADD_FAMILY: u32 = 2_148_916_238; + +/// 认证失败。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthError { + /// 网络层失败(DNS/连接/TLS/超时以外的 IO)。 + Network { + /// 目标 URL(不含 query 里的凭据)。 + url: String, + /// 底层描述。 + detail: String, + }, + /// 超时:连接超时、整体超时,或设备码流程等满 `expires_in`/15 分钟。 + Timeout { + /// 在等什么。 + what: String, + }, + /// 用户主动取消(关窗口、Ctrl+C、`access_denied` 也归这里)。 + Canceled, + /// 凭据过期/被撤销,必须重新交互式登录(`AADSTS70000` 族)。 + CredentialExpired, + /// 服务端明确拒绝(OAuth `error` + `error_description`)。 + Denied { + /// OAuth 错误码(`invalid_grant` / `authorization_declined` …)。 + code: String, + /// 服务端描述。 + description: String, + }, + /// 链上某一步拿到非 2xx,且带回了正文诊断(M38)。 + /// + /// 与 [`Self::Denied`] 同一分流层(`code()` 仍是 `denied`,向后兼容 M31 的调用方), + /// 细分码看 [`AuthError::detail_code`]:JSON 拒绝沿用 `_`, + /// 拦截页(HTML/纯文本)是新增的 `_non_json`,空体是 `_empty_body`。 + /// + /// 载荷装箱:`ResponseDetail` 有 7 个字段(含 4 个字符串),不装箱会把整个枚举撑到 + /// 128 字节以上,`Result` 的每个 Ok 也跟着变胖(clippy `result_large_err`)。 + Response(Box), + /// Xbox Live / XSTS 业务错误码(`XErr`)。 + Xbox { + /// `XErr` 数值。 + code: u32, + }, + /// 响应结构不合法(JSON 缺字段、类型不对、profile 被换掉)。 + Malformed(String), + /// 凭据/元数据落盘或读取失败。 + Store { + /// 涉及的路径。 + path: String, + /// 底层描述。 + detail: String, + }, + /// 找不到指定账户。 + NotFound { + /// 用户给的查找键。 + account: String, + }, + /// 账户重复(同名同轨已存在)。 + Conflict { + /// 冲突的账户名。 + account: String, + }, + /// 该轨/该能力本期未实现(如 CLI 上的 authlib 密码登录)。 + Unsupported(String), +} + +impl AuthError { + /// 稳定机读码(CLI `--json` 与日志都用它,不随文案变)。 + #[must_use] + pub const fn code(&self) -> &'static str { + match self { + Self::Network { .. } => "network", + Self::Timeout { .. } => "timeout", + Self::Canceled => "canceled", + Self::CredentialExpired => "credential_expired", + Self::Denied { .. } => "denied", + // 与 `Denied` 同一粗分流层:M31 时代的调用方(CLI 提示、launch 身份解析)无需改。 + Self::Response(_) => "denied", + Self::Xbox { .. } => "xbox", + Self::Malformed(_) => "malformed", + Self::Store { .. } => "store", + Self::NotFound { .. } => "not_found", + Self::Conflict { .. } => "conflict", + Self::Unsupported(_) => "unsupported", + } + } + + /// 是否是「必须重新登录」的信号(唯一该弹交互的一类)。 + #[must_use] + pub const fn is_credential_expired(&self) -> bool { + matches!(self, Self::CredentialExpired) + } + + /// 是否属于「网络/服务端抽风,可重试或跳过」的一类。 + #[must_use] + pub const fn is_transient(&self) -> bool { + match self { + Self::Network { .. } | Self::Timeout { .. } | Self::Xbox { .. } => true, + // 拦截页/空体多半是网络侧问题;JSON 拒绝是服务端明确表态,重试无意义。 + Self::Response(detail) => detail.kind.is_interception(), + _ => false, + } + } + + /// 细分机读码(M38 新增):OAuth 拒绝返回 `` 里带的错误码,链上非 2xx 返回 + /// `_` / `_non_json` / `_empty_body`,其余回落到 [`Self::code`]。 + /// + /// 与 [`Self::code`] 的分工:`code()` 是**分流层**(决定弹不弹交互、能不能重试), + /// 这个方法给日志与 `--json` 的**排障层**用。既有码一律不改,只增。 + #[must_use] + pub fn detail_code(&self) -> String { + match self { + Self::Response(detail) => detail.detail_code(), + Self::Denied { code, .. } => code.clone(), + other => other.code().to_string(), + } + } + + /// 网络错误的构造(`url` 只放 host+path,别把 query 里的 code 带进来)。 + #[must_use] + pub fn network(url: impl Into, detail: impl Into) -> Self { + Self::Network { + url: url.into(), + detail: detail.into(), + } + } + + /// 落盘错误的构造。 + #[must_use] + pub fn store(path: impl fmt::Display, detail: impl Into) -> Self { + Self::Store { + path: path.to_string(), + detail: detail.into(), + } + } + + /// 人读一句话(**不含凭据**)。 + #[must_use] + pub fn message(&self) -> String { + match self { + Self::Network { url, detail } => format!("网络请求失败({url}):{detail}"), + Self::Timeout { what } => format!("等待超时:{what}"), + Self::Canceled => "登录已取消".to_string(), + Self::CredentialExpired => "凭据已过期,需要重新登录".to_string(), + Self::Denied { code, description } => { + format!("服务端拒绝({code}):{description}") + } + Self::Response(detail) => detail.message(), + Self::Xbox { code } => format!("Xbox 授权失败(XErr {code}):{}", xbox_hint(*code)), + Self::Malformed(detail) => format!("响应不符合预期:{detail}"), + Self::Store { path, detail } => format!("账户数据读写失败({path}):{detail}"), + Self::NotFound { account } => format!("找不到账户 `{account}`"), + Self::Conflict { account } => format!("账户 `{account}` 已存在"), + Self::Unsupported(detail) => format!("本期未支持:{detail}"), + } + } +} + +impl fmt::Display for AuthError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message()) + } +} + +impl std::error::Error for AuthError {} + +/// `XErr` 码 → 中文说明(承 `analyse/04` §5.2 的四码表)。 +#[must_use] +pub fn xbox_hint(code: u32) -> &'static str { + match code { + XERR_BANNED => "该微软账号已被 Xbox Live 封禁", + XERR_MISSING_XBOX_ACCOUNT => "该微软账号还没有 Xbox 档案,请先去 xbox.com 创建", + XERR_COUNTRY_UNAVAILABLE => "该账号所在地区不被 Xbox Live 支持", + XERR_ADD_FAMILY => "儿童账号需要先加入 Microsoft 家庭组", + _ => "Xbox Live 拒绝了本次授权(可能无 Xbox 档案或账号受限)", + } +} + +/// 结果别名。 +pub type AuthResult = Result; + +#[cfg(test)] +mod tests { + use super::{xbox_hint, AuthError, XERR_ADD_FAMILY}; + + #[test] + fn codes_are_stable() { + assert_eq!(AuthError::Canceled.code(), "canceled"); + assert_eq!(AuthError::CredentialExpired.code(), "credential_expired"); + assert!(AuthError::Xbox { + code: XERR_ADD_FAMILY + } + .is_transient()); + assert!(!AuthError::Canceled.is_transient()); + } + + #[test] + fn messages_carry_no_secret_shape() { + let error = AuthError::Denied { + code: "invalid_grant".to_string(), + description: "AADSTS70000: bad redirect".to_string(), + }; + assert!(error.message().contains("AADSTS70000")); + assert!(AuthError::Xbox { code: 2148916238 } + .message() + .contains("家庭组")); + assert!(!xbox_hint(0).is_empty()); + } + + #[test] + fn response_shares_denied_coarse_code_but_has_its_own_detail_code() { + let error = AuthError::Response(Box::new(super::ResponseDetail { + step: "login_with_xbox".to_string(), + what: "Minecraft 登录被拒".to_string(), + status: 403, + content_type: Some("text/html".to_string()), + kind: crate::auth::body::BodyKind::Html, + server_message: None, + digest: String::new(), + })); + // 粗分流层向后兼容:仍是 `denied`;排障层才是细分码。 + assert_eq!(error.code(), "denied"); + assert_eq!(error.detail_code(), "login_with_xbox_non_json"); + assert!(error.is_transient(), "拦截页可以重试"); + assert_eq!( + AuthError::Canceled.detail_code(), + "canceled", + "没有细分的错误回落到 code()" + ); + } +} diff --git a/crates/core/src/auth/http.rs b/crates/core/src/auth/http.rs new file mode 100644 index 0000000..ad42508 --- /dev/null +++ b/crates/core/src/auth/http.rs @@ -0,0 +1,208 @@ +//! 认证用的 HTTP 薄封装(三条轨共用):**复用既有 download 引擎的客户端基座** +//! (mission Context:网络操作走既有 HTTP 基座),只补三件认证需要的东西: +//! 表单/JSON 体、Bearer 头、单请求整体超时。 +//! +//! 三条纪律: +//! 1. **响应体不原样进错误文案**——OAuth 的 token 响应体本身就是凭据,把它塞进 +//! `Malformed` 的 message 就等于把 token 写进日志(`analyse/04` §11 观察项 9 的坑); +//! 因此解析失败只说「不是合法 JSON」,贴原文的唯一通道是 +//! [`crate::auth::body`] 的脱敏摘要(M38:仅非 2xx、≤200 字符、过 Redactor)。 +//! 2. 状态码不做归类,**原样交给调用方**:XSTS 的 401 带 `XErr`、`/minecraft/profile` 的 +//! 404 要复判 license,都是「非 200 但有语义」的分支,提前归一化会丢掉信息。 +//! 3. **诊断留证,但不留凭据**(M38):`Reply` 额外携带 `Content-Type` 与非 JSON 正文的快照 +//! ([`RAW_PREVIEW_BYTES`] 字节封顶),诊断由 [`crate::auth::body`] 分类/脱敏后再进错误消息。 +//! 原文快照**只在 JSON 解析失败时**保留,所以 OAuth 成功响应体不会被复制进第二个字段。 + +use std::time::Duration; + +use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; +use reqwest::Client; +use serde_json::Value; + +use crate::auth::error::{AuthError, AuthResult}; +use crate::auth::secret::Secret; + +/// 单请求整体超时(设备码轮询的每个请求都远快于此)。 +pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// 非 JSON 正文保留的快照上限(字节):够判断「是不是 HTML」并抓前几句文案。 +pub(crate) const RAW_PREVIEW_BYTES: usize = 512; + +/// 一次应答:状态码 + 可解析的 JSON 体 + 诊断用的头/正文快照。 +#[derive(Debug, Clone)] +pub(crate) struct Reply { + /// HTTP 状态码。 + pub status: u16, + /// 解析成功时的 JSON 体(空体/非 JSON 为 `None`)。 + pub body: Option, + /// `Content-Type` 原样(诊断文案与分流判断用;缺失为 `None`)。 + pub content_type: Option, + /// 非 JSON 正文的前 [`RAW_PREVIEW_BYTES`] 字节(有损 UTF-8;JSON 解析成功时为 `None`)。 + pub raw: Option, +} + +impl Reply { + /// 是否 2xx。 + pub(crate) fn is_success(&self) -> bool { + (200..300).contains(&self.status) + } + + /// 取一个字符串字段。 + pub(crate) fn text(&self, path: &str) -> Option<&str> { + self.body.as_ref()?.get(path)?.as_str() + } + + /// 取一个 u64 字段(数字或数字字符串都接受)。 + pub(crate) fn number(&self, path: &str) -> Option { + let value = self.body.as_ref()?.get(path)?; + value + .as_u64() + .or_else(|| value.as_str().and_then(|raw| raw.trim().parse().ok())) + } +} + +/// 表单 `POST`(OAuth 的设备码/token 端点)。 +pub(crate) async fn post_form( + client: &Client, + url: &str, + form: Vec<(&str, String)>, +) -> AuthResult { + let request = client + .post(url) + .header(ACCEPT, "application/json") + .timeout(REQUEST_TIMEOUT) + .form(&form); + send(request, url).await +} + +/// JSON `POST`(XBL / XSTS / login_with_xbox)。 +pub(crate) async fn post_json( + client: &Client, + url: &str, + body: &Value, + bearer: Option<&Secret>, +) -> AuthResult { + let text = serde_json::to_string(body) + .map_err(|error| AuthError::Malformed(format!("请求体序列化失败:{error}")))?; + let mut request = client + .post(url) + .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json") + .timeout(REQUEST_TIMEOUT) + .body(text); + if let Some(token) = bearer { + request = request.header(AUTHORIZATION, format!("Bearer {}", token.expose())); + } + send(request, url).await +} + +/// 带 Bearer 的 `GET`(entitlements / profile)。 +pub(crate) async fn get_json(client: &Client, url: &str, bearer: &Secret) -> AuthResult { + let request = client + .get(url) + .header(ACCEPT, "application/json") + .header(AUTHORIZATION, format!("Bearer {}", bearer.expose())) + .timeout(REQUEST_TIMEOUT); + send(request, url).await +} + +/// 发请求并解析 JSON(无凭据泄漏的错误映射)。 +async fn send(request: reqwest::RequestBuilder, url: &str) -> AuthResult { + let response = request + .send() + .await + .map_err(|error| http_error(url, &error))?; + let status = response.status().as_u16(); + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let bytes = response + .bytes() + .await + .map_err(|error| http_error(url, &error))?; + let body = if bytes.is_empty() { + None + } else { + serde_json::from_slice::(&bytes).ok() + }; + // 只有解析失败(HTML 拦截页 / 纯文本 / 坏 JSON)才留原文快照: + // 解析成功的响应体可能含 token,不留第二份副本。 + let raw = match (&body, bytes.is_empty()) { + (None, false) => Some(preview(&bytes)), + _ => None, + }; + Ok(Reply { + status, + body, + content_type, + raw, + }) +} + +/// 取正文前 [`RAW_PREVIEW_BYTES`] 字节(有损解码,截断时补 `…`)。 +fn preview(bytes: &[u8]) -> String { + let truncated = bytes.len() > RAW_PREVIEW_BYTES; + let head = &bytes[..bytes.len().min(RAW_PREVIEW_BYTES)]; + let mut text = String::from_utf8_lossy(head).into_owned(); + if truncated { + text.push('…'); + } + text +} + +/// `reqwest::Error` → [`AuthError`](超时与非超时分开,文案只带 host+path)。 +fn http_error(url: &str, error: &reqwest::Error) -> AuthError { + let target = url.split('?').next().unwrap_or(url).to_string(); + if error.is_timeout() { + AuthError::Timeout { + what: format!("请求 {target}"), + } + } else { + AuthError::network(target, error.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::{preview, Reply, RAW_PREVIEW_BYTES}; + use serde_json::json; + + fn reply(status: u16, body: Option) -> Reply { + Reply { + status, + body, + content_type: Some("application/json".to_string()), + raw: None, + } + } + + #[test] + fn reply_reads_text_and_numbers() { + let sample = reply( + 200, + Some(json!({ "user_code": "ABC", "expires_in": 900, "interval": "5" })), + ); + assert!(sample.is_success()); + assert_eq!(sample.text("user_code"), Some("ABC")); + assert_eq!(sample.number("expires_in"), Some(900)); + assert_eq!(sample.number("interval"), Some(5)); + assert_eq!(sample.number("missing"), None); + assert_eq!(sample.text("missing"), None); + } + + #[test] + fn non_success_is_not_success() { + assert!(!reply(401, None).is_success()); + } + + #[test] + fn preview_caps_and_marks_truncation() { + let short = preview(b"Bad Gateway"); + assert_eq!(short, "Bad Gateway"); + let long = preview(&vec![b'x'; RAW_PREVIEW_BYTES + 64]); + assert!(long.ends_with('…')); + assert_eq!(long.trim_end_matches('…').len(), RAW_PREVIEW_BYTES); + } +} diff --git a/crates/core/src/auth/journal.rs b/crates/core/src/auth/journal.rs new file mode 100644 index 0000000..6c54c67 --- /dev/null +++ b/crates/core/src/auth/journal.rs @@ -0,0 +1,266 @@ +//! auth 失败事件的落盘(`.ULL/logs/auth.jsonl`,M38)。 +//! +//! 起因:MSA 真机验收两轮 403,`ull accounts add msa` 失败后终端只有一行文案, +//! 验收人拿不到状态码/正文摘要去对账。这里把**失败**写成一行一条 JSONL: +//! 便携模式数据根还没初始化时会先补建 `logs/` 分区,403 的细节不再随终端滚走。 +//! +//! 三条纪律: +//! 1. **只记失败**,不记成功(成功路径的凭据载荷一律不落日志); +//! 2. **记的是诊断字段**(`code` / `detail_code` / `status` / `content_type` / `body_kind` / +//! 已脱敏的 `server_message`、`digest`),没有任何 token/密码字段; +//! 3. **落盘是尽力而为**:写日志失败绝不让登录本身失败(调用方忽略返回值)。 +//! +//! 数据根从哪来:默认走 `platform::data_dir` 的 **Portable** 解析(exe 同目录的 `.ULL`, +//! 跟随 `redirect.json`)。宿主若用 `--data-dir` 等非便携模式,应显式 +//! [`AuthJournal::in_logs_dir`] 后交给 [`crate::auth::MicrosoftAuth::with_journal`]。 + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; + +use crate::auth::body::BodyKind; +use crate::auth::error::AuthError; +use crate::launch::redact::Redactor; +use crate::platform::data_dir::{DataDirRequest, Resolver}; +use crate::platform::layout::LauncherLayout; +use crate::platform::paths::Platform; + +/// 认证失败事件文件名(在 `.ULL/logs/` 下)。 +pub const AUTH_LOG_FILE: &str = "auth.jsonl"; + +/// 一行认证失败事件。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthFailureEvent { + /// Unix 毫秒。 + pub ts_ms: u64, + /// 触发入口(`device_code_login` / `refresh` / `validate`)。 + pub scope: String, + /// 链上的步骤(`AuthError::Response` 才有)。 + pub step: Option, + /// 粗分流码([`AuthError::code`])。 + pub code: String, + /// 细分码([`AuthError::detail_code`])。 + pub detail_code: String, + /// HTTP 状态码(链上拒绝才有)。 + pub status: Option, + /// `Content-Type`(链上拒绝才有)。 + pub content_type: Option, + /// 正文形状(链上拒绝才有)。 + pub body_kind: Option, + /// 服务端文案(已脱敏)。 + pub server_message: Option, + /// 正文摘要(已脱敏,≤200 字符)。 + pub digest: Option, + /// 是否可重试([`AuthError::is_transient`])。 + pub transient: bool, + /// 人读一句话(已脱敏,不含凭据)。 + pub message: String, +} + +impl AuthFailureEvent { + /// 从一条错误构造(`scope` 说明是哪个入口失败的)。 + #[must_use] + pub fn of(scope: &str, error: &AuthError) -> Self { + let detail = match error { + AuthError::Response(detail) => Some(detail.as_ref()), + _ => None, + }; + let scrub = |text: &str| Redactor::new().scrub(text).into_owned(); + Self { + ts_ms: crate::auth::microsoft::now_ms(), + scope: scope.to_string(), + step: detail.map(|detail| detail.step.clone()), + code: error.code().to_string(), + detail_code: error.detail_code(), + status: detail.map(|detail| detail.status), + content_type: detail.and_then(|detail| detail.content_type.clone()), + body_kind: detail.map(|detail| detail.kind), + server_message: detail + .and_then(|detail| detail.server_message.as_deref()) + .map(scrub), + digest: detail.map(|detail| scrub(&detail.digest)), + transient: error.is_transient(), + message: scrub(&error.message()), + } + } + + /// JSON 形状(字段名稳定,给验收/排障工具读)。 + #[must_use] + pub fn to_json(&self) -> Value { + json!({ + "ts_ms": self.ts_ms, + "level": "error", + "event": "auth_failure", + "scope": self.scope, + "step": self.step, + "code": self.code, + "detail_code": self.detail_code, + "status": self.status, + "content_type": self.content_type, + "body_kind": self.body_kind.map(BodyKind::as_str), + "server_message": self.server_message, + "digest": self.digest, + "transient": self.transient, + "message": self.message, + }) + } +} + +/// 认证失败日志(JSONL,一行一条)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthJournal { + path: PathBuf, +} + +impl AuthJournal { + /// 落在某个 `logs/` 分区下(`/auth.jsonl`)。 + #[must_use] + pub fn in_logs_dir(logs: impl Into) -> Self { + Self { + path: logs.into().join(AUTH_LOG_FILE), + } + } + + /// 直接指定文件路径。 + #[must_use] + pub fn at(path: impl Into) -> Self { + Self { path: path.into() } + } + + /// 便携模式默认位置(exe 同目录 `.ULL/logs/auth.jsonl`,跟随 `redirect.json`)。 + /// + /// 拿不到 exe 目录(极少数嵌入场景)时返回 `None`——没有可写的日志根就不写。 + #[must_use] + pub fn portable_default() -> Option { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf))?; + let resolver = Resolver::new(Platform::current(), Some(exe_dir)); + let resolved = resolver.resolve_default(&DataDirRequest::default()).ok()?; + Some(Self::in_logs_dir( + LauncherLayout::new(resolved.launcher_home).logs(), + )) + } + + /// 日志文件路径。 + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } + + /// 追加一条事件(父目录不存在时先建)。 + /// + /// # Errors + /// + /// 建目录或追加失败;调用方按「尽力而为」处理即可。 + pub fn record(&self, event: &AuthFailureEvent) -> std::io::Result<()> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut line = serde_json::to_string(&event.to_json()).unwrap_or_default(); + line.push('\n'); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?; + file.write_all(line.as_bytes())?; + file.flush() + } +} + +#[cfg(test)] +mod tests { + use super::{AuthFailureEvent, AuthJournal}; + use crate::auth::body::{BodyKind, ResponseDetail}; + use crate::auth::error::AuthError; + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-auth-journal-{tag}-{}-{:?}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|since| since.as_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建临时目录"); + dir + } + + fn blocked_error() -> AuthError { + AuthError::Response(Box::new(ResponseDetail { + step: "login_with_xbox".to_string(), + what: "Minecraft 登录被拒".to_string(), + status: 403, + content_type: Some("text/html".to_string()), + kind: BodyKind::Html, + server_message: None, + digest: "blocked".to_string(), + })) + } + + #[test] + fn event_carries_structured_diagnostics() { + let event = AuthFailureEvent::of("device_code_login", &blocked_error()); + assert_eq!(event.scope, "device_code_login"); + assert_eq!(event.step.as_deref(), Some("login_with_xbox")); + assert_eq!(event.code, "denied"); + assert_eq!(event.detail_code, "login_with_xbox_non_json"); + assert_eq!(event.status, Some(403)); + assert_eq!(event.content_type.as_deref(), Some("text/html")); + assert_eq!(event.body_kind, Some(BodyKind::Html)); + assert!(event.transient); + let value = event.to_json(); + assert_eq!(value["event"], "auth_failure"); + assert_eq!(value["level"], "error"); + assert_eq!(value["body_kind"], "html"); + assert!(value["message"] + .as_str() + .is_some_and(|text| !text.is_empty())); + } + + #[test] + fn non_chain_errors_still_record() { + let event = AuthFailureEvent::of("refresh", &AuthError::Canceled); + assert_eq!(event.detail_code, "canceled"); + assert_eq!(event.step, None); + assert_eq!(event.status, None); + assert!(!event.transient); + } + + #[test] + fn record_initializes_the_logs_dir_and_appends_one_line() { + let root = temp_dir("init"); + let logs = root.join("logs"); + let journal = AuthJournal::in_logs_dir(&logs); + assert!(!logs.exists(), "初始不该存在(模拟未初始化的数据根)"); + journal + .record(&AuthFailureEvent::of("device_code_login", &blocked_error())) + .expect("写日志"); + journal + .record(&AuthFailureEvent::of("refresh", &AuthError::Canceled)) + .expect("写日志"); + let text = std::fs::read_to_string(journal.path()).expect("读日志"); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 2, "一行一条:{text}"); + for line in lines { + let value: serde_json::Value = serde_json::from_str(line).expect("每行都是合法 JSON"); + assert_eq!(value["event"], "auth_failure"); + } + // 日志里不该出现任何凭据字段名。 + for forbidden in ["access_token", "refresh_token", "password", "token"] { + assert!(!text.contains(forbidden), "日志出现了 {forbidden}:{text}"); + } + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn journal_paths_are_layout_based() { + let journal = AuthJournal::in_logs_dir("/tmp/ull-logs"); + assert!(journal.path().ends_with("auth.jsonl")); + let explicit = AuthJournal::at("/tmp/x/custom.jsonl"); + assert_eq!(explicit.path(), std::path::Path::new("/tmp/x/custom.jsonl")); + } +} diff --git a/crates/core/src/auth/microsoft/chain.rs b/crates/core/src/auth/microsoft/chain.rs new file mode 100644 index 0000000..841ab82 --- /dev/null +++ b/crates/core/src/auth/microsoft/chain.rs @@ -0,0 +1,377 @@ +//! 微软五步链:Live token → **XBL** → **XSTS** → **login_with_xbox** → **entitlements** → **profile**。 +//! +//! 逐条承 `analyse/04` §5.2(报文级别),四处容易抄错的地方都在注释里点名: +//! 1. XBL 的 `RpsTicket` 必须带 **`d=` 前缀**(MSA 消费版票据标记); +//! 2. `identityToken` 是 **`XBL3.0 x=;`**(分号分隔,`x` 后紧跟 `=`); +//! 3. XSTS 在「无 Xbox 档案/儿童账号/地区不支持」时回 **401**(不是 200+JSON), +//! 必须读 body 里的 `XErr` 才能给出准确原因; +//! 4. `/minecraft/profile` 的 **404 二义**:没买游戏 vs 买了没建档案, +//! 要用 `entitlements/license` 复判一次才分得清。 +//! +//! **XSTS/MC 令牌不缓存**:刷新就是整链重跑(`analyse/04` §5.1),所以本模块只做无状态串联。 + +use reqwest::Client; +use serde_json::json; + +use crate::auth::body; +use crate::auth::error::AuthError; +use crate::auth::secret::{redactor_for, Secret}; + +use super::endpoints::MicrosoftEndpoints; +use super::flow::OAuthTokens; +use super::session::MicrosoftSession; +use crate::auth::http::{get_json, post_json, Reply}; + +/// Xbox Live 用户票据 + 用户哈希。 +#[derive(Debug, Clone)] +pub(crate) struct XboxTicket { + /// JWT 票据(凭据形状)。 + pub token: Secret, + /// `uhs`(`XBL3.0 x=;...` 的 userhash;本身不是凭据但只在本链内部用)。 + pub uhs: String, +} + +/// MC 侧换到的令牌。 +#[derive(Debug, Clone)] +pub(crate) struct MinecraftToken { + /// MC access token(落盘)。 + pub access_token: Secret, + /// 有效期(秒)。 + pub expires_in: u64, +} + +/// Minecraft 档案(不可变 id + 可变 name)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct GameProfile { + /// 32 位无连字符 UUID。 + pub id: String, + /// 玩家名。 + pub name: String, +} + +/// 第 1 步:Xbox Live User Authenticate。 +pub(crate) async fn xbl_authenticate( + client: &Client, + endpoints: &MicrosoftEndpoints, + live_access_token: &Secret, +) -> Result { + let body = json!({ + "Properties": { + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + "RpsTicket": format!("d={}", live_access_token.expose()), + }, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT", + }); + let reply = post_json(client, &endpoints.xbl_authenticate, &body, None).await?; + if let Some(code) = xerr(&reply) { + return Err(AuthError::Xbox { code }); + } + if !reply.is_success() { + // M38:XErr 优先(业务码才是真原因),没有 XErr 时把正文诊断补进文案。 + return Err(body::response( + "xbl", + &reply, + "Xbox Live 用户认证被拒", + &redactor_for([live_access_token]), + )); + } + let token = reply + .text("Token") + .ok_or_else(|| AuthError::Malformed("XBL 响应缺 Token".to_string()))?; + let uhs = uhs_of(&reply) + .ok_or_else(|| AuthError::Malformed("XBL 响应缺 DisplayClaims.xui[0].uhs".to_string()))?; + Ok(XboxTicket { + token: Secret::new(token), + uhs, + }) +} + +/// 第 2 步:XSTS Authorize(401 也读 body,`XErr` 才是真原因)。 +pub(crate) async fn xsts_authorize( + client: &Client, + endpoints: &MicrosoftEndpoints, + xbl: &XboxTicket, +) -> Result { + let body = json!({ + "Properties": { "SandboxId": "RETAIL", "UserTokens": [xbl.token.expose()] }, + "RelyingParty": "rp://api.minecraftservices.com/", + "TokenType": "JWT", + }); + let reply = post_json(client, &endpoints.xsts_authorize, &body, None).await?; + if let Some(code) = xerr(&reply) { + return Err(AuthError::Xbox { code }); + } + if !reply.is_success() { + return Err(body::response( + "xsts", + &reply, + "XSTS 授权被拒", + &redactor_for([&xbl.token]), + )); + } + let token = reply + .text("Token") + .ok_or_else(|| AuthError::Malformed("XSTS 响应缺 Token".to_string()))?; + let uhs = uhs_of(&reply).ok_or_else(|| AuthError::Malformed("XSTS 响应缺 uhs".to_string()))?; + // 一致性断言:同一账号在 XBL 与 XSTS 两次返回的 userhash 必须相同。 + if uhs != xbl.uhs { + return Err(AuthError::Malformed( + "XBL 与 XSTS 返回的 userhash 不一致".to_string(), + )); + } + Ok(XboxTicket { + token: Secret::new(token), + uhs, + }) +} + +/// 第 3 步:`login_with_xbox` 换 Minecraft 令牌。 +pub(crate) async fn login_with_xbox( + client: &Client, + endpoints: &MicrosoftEndpoints, + xsts: &XboxTicket, +) -> Result { + let body = json!({ + "identityToken": format!("XBL3.0 x={};{}", xsts.uhs, xsts.token.expose()), + }); + let reply = post_json(client, &endpoints.login_with_xbox, &body, None).await?; + if !reply.is_success() { + // M38 的核心一行:403 到底是「拦截页」还是「服务端拒绝」,文案要分开引导。 + return Err(body::response( + "login_with_xbox", + &reply, + "Minecraft 登录被拒", + &redactor_for([&xsts.token]), + )); + } + let access_token = reply + .text("access_token") + .ok_or_else(|| AuthError::Malformed("login_with_xbox 缺 access_token".to_string()))?; + Ok(MinecraftToken { + access_token: Secret::new(access_token), + expires_in: reply.number("expires_in").unwrap_or(0), + }) +} + +/// 第 4 步:拥有权校验(必须 200,否则后面启动时也会被服务端踢)。 +pub(crate) async fn check_entitlements( + client: &Client, + endpoints: &MicrosoftEndpoints, + mc_token: &Secret, +) -> Result<(), AuthError> { + let reply = get_json(client, &endpoints.entitlements_mcstore, mc_token).await?; + if reply.is_success() { + return Ok(()); + } + let detail = body::rejection( + "entitlements", + &reply, + "拥有权校验未通过", + &redactor_for([mc_token]), + ); + // 拦截页(HTML/纯文本)与非 404 的空体先当网络问题报,别让用户去查许可; + // JSON 拒绝与 404 沿用 M31 的 `no_entitlement`(稳定码不改)。 + let interception = detail.kind.is_interception() + || (detail.kind == body::BodyKind::Empty && reply.status != 404); + if interception { + return Err(AuthError::Response(Box::new(detail))); + } + let what = if reply.status == 404 { + "该账号没有 Minecraft Java 版拥有权" + } else { + detail.what.as_str() + }; + Err(AuthError::Denied { + code: "no_entitlement".to_string(), + description: format!( + "{what}(entitlements/mcstore 返回 HTTP {}){}", + reply.status, + detail.note() + ), + }) +} + +/// 第 5 步:拉档案(404 用 license 复判二义)。 +pub(crate) async fn fetch_profile( + client: &Client, + endpoints: &MicrosoftEndpoints, + mc_token: &Secret, +) -> Result { + let reply = get_json(client, &endpoints.profile, mc_token).await?; + // 只有「像服务端应答」的 404 才走二义复判:HTML/纯文本的 404 是网关页, + // 拿它去复判 license 会得出错误的「没买游戏」结论。 + if reply.status == 404 && !body::classify(&reply).is_interception() { + return Err(profile_missing(client, endpoints, mc_token).await); + } + if !reply.is_success() { + return Err(body::response( + "profile", + &reply, + "拉取 Minecraft 档案被拒", + &redactor_for([mc_token]), + )); + } + let id = reply + .text("id") + .ok_or_else(|| AuthError::Malformed("档案响应缺 id".to_string()))?; + let name = reply + .text("name") + .ok_or_else(|| AuthError::Malformed("档案响应缺 name".to_string()))?; + Ok(GameProfile { + id: id.to_string(), + name: name.to_string(), + }) +} + +/// 404 之后的二义消解:没买游戏 vs 买了没建档案。 +async fn profile_missing( + client: &Client, + endpoints: &MicrosoftEndpoints, + mc_token: &Secret, +) -> AuthError { + let reply = match get_json(client, &endpoints.entitlements_license, mc_token).await { + Ok(reply) => reply, + // 复判本身失败时,退回「档案不存在」这个更保守的说法。 + Err(_) => { + return AuthError::Denied { + code: "no_profile".to_string(), + description: "该账号没有 Minecraft 档案(复判 license 也失败)".to_string(), + } + } + }; + if has_minecraft_license(&reply) { + AuthError::Denied { + code: "no_profile".to_string(), + description: "账号已拥有 Minecraft,但还没有创建档案(去 minecraft.net 起个名字)" + .to_string(), + } + } else { + AuthError::Denied { + code: "no_license".to_string(), + description: "该账号没有购买 Minecraft Java 版".to_string(), + } + } +} + +/// `entitlements/license` 响应里是否有 `game_minecraft`。 +fn has_minecraft_license(reply: &Reply) -> bool { + reply + .body + .as_ref() + .and_then(|body| body.get("items")) + .and_then(|items| items.as_array()) + .is_some_and(|items| { + items.iter().any(|item| { + item.get("name") + .and_then(|name| name.as_str()) + .is_some_and(|name| name == "game_minecraft") + }) + }) +} + +/// 整链:Live token → [`MicrosoftSession`](刷新与新登录共用这一段)。 +pub(crate) async fn complete_chain( + client: &Client, + endpoints: &MicrosoftEndpoints, + tokens: OAuthTokens, + now_ms: u64, +) -> Result { + let xbl = xbl_authenticate(client, endpoints, &tokens.access_token).await?; + let xsts = xsts_authorize(client, endpoints, &xbl).await?; + let mc = login_with_xbox(client, endpoints, &xsts).await?; + check_entitlements(client, endpoints, &mc.access_token).await?; + let profile = fetch_profile(client, endpoints, &mc.access_token).await?; + Ok(MicrosoftSession::new( + profile.id, + profile.name, + mc.access_token, + tokens.refresh_token, + mc.expires_in, + now_ms, + None, + )) +} + +/// 从响应里取 `XErr`(XSTS 的 401 业务码)。 +fn xerr(reply: &Reply) -> Option { + let body = reply.body.as_ref()?; + for key in ["XErr", "errorCode"] { + if let Some(code) = body.get(key).and_then(|value| value.as_u64()) { + if code != 0 { + return Some(code as u32); + } + } + } + None +} + +fn uhs_of(reply: &Reply) -> Option { + reply + .body + .as_ref()? + .get("DisplayClaims")? + .get("xui")? + .as_array()? + .first()? + .get("uhs")? + .as_str() + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::{has_minecraft_license, uhs_of, xerr, Reply}; + use serde_json::{json, Value}; + + fn reply(status: u16, body: Option) -> Reply { + Reply { + status, + body, + content_type: Some("application/json".to_string()), + raw: None, + } + } + + #[test] + fn xerr_is_read_from_401_body() { + let sample = reply( + 401, + Some(json!({ "Identity": "0", "XErr": 2_148_916_238_u32, "Message": "" })), + ); + assert_eq!(xerr(&sample), Some(2_148_916_238)); + assert_eq!(xerr(&reply(200, Some(json!({ "XErr": 0 })))), None); + assert_eq!(xerr(&reply(200, None)), None); + } + + #[test] + fn uhs_comes_from_first_xui_entry() { + let sample = reply( + 200, + Some(json!({ "DisplayClaims": { "xui": [{ "uhs": "1234" }] } })), + ); + assert_eq!(uhs_of(&sample).as_deref(), Some("1234")); + assert_eq!( + uhs_of(&reply(200, Some(json!({ "DisplayClaims": {} })))), + None + ); + } + + #[test] + fn license_detection() { + let with = reply( + 200, + Some( + json!({ "items": [{ "name": "product_minecraft" }, { "name": "game_minecraft" }] }), + ), + ); + let without = reply( + 200, + Some(json!({ "items": [{ "name": "product_minecraft" }] })), + ); + assert!(has_minecraft_license(&with)); + assert!(!has_minecraft_license(&without)); + assert!(!has_minecraft_license(&reply(500, None))); + } +} diff --git a/crates/core/src/auth/microsoft/endpoints.rs b/crates/core/src/auth/microsoft/endpoints.rs new file mode 100644 index 0000000..f981a24 --- /dev/null +++ b/crates/core/src/auth/microsoft/endpoints.rs @@ -0,0 +1,118 @@ +//! 微软端点表:**直连**(不走镜像,承 mission Context 与 `analyse/04` §4 的端点族区分)。 +//! +//! 两个端点族不能混用(`analyse/04` §4 的「第一个坑」): +//! - 设备码流:`login.microsoftonline.com/consumers/oauth2/v2.0/*`; +//! - HMCL 的浏览器授权码流走 `login.live.com`(本期不实现,故不在此表)。 +//! +//! 端点集中成一张表是为了**测试可替换**:整表指向一个进程内 mock 服务器即可覆盖全链, +//! 生产默认走 [`MicrosoftEndpoints::consumers`]。 + +/// 微软账户 OAuth 与 Xbox / Minecraft 服务端点。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MicrosoftEndpoints { + /// 设备码申请(`POST`,表单 `client_id` + `scope`)。 + pub device_code: String, + /// 换 token / 刷新 token(`POST`,表单)。 + pub token: String, + /// Xbox Live 用户认证(`POST` JSON)。 + pub xbl_authenticate: String, + /// XSTS 授权(`POST` JSON;无 Xbox 档案时回 **401** 带 `XErr`)。 + pub xsts_authorize: String, + /// `login_with_xbox`(`POST` JSON,`identityToken = "XBL3.0 x=;"`)。 + pub login_with_xbox: String, + /// 拥有权校验 `entitlements/mcstore`(`GET`,必须 200)。 + pub entitlements_mcstore: String, + /// 拥有权细分 `entitlements/license`(`GET`,`/minecraft/profile` 404 时复判用)。 + pub entitlements_license: String, + /// 档案 `minecraft/profile`(`GET`,带 Bearer)。 + pub profile: String, +} + +impl MicrosoftEndpoints { + /// 生产端点(微软消费版账户,直连)。 + #[must_use] + pub fn consumers() -> Self { + Self { + device_code: "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode" + .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(), + login_with_xbox: "https://api.minecraftservices.com/authentication/login_with_xbox" + .to_string(), + entitlements_mcstore: "https://api.minecraftservices.com/entitlements/mcstore" + .to_string(), + entitlements_license: "https://api.minecraftservices.com/entitlements/license" + .to_string(), + profile: "https://api.minecraftservices.com/minecraft/profile".to_string(), + } + } + + /// 把整表指向同一个基址(测试用;各端点用自己的路径后缀区分)。 + #[must_use] + pub fn all_on(base: &str) -> Self { + let join = |path: &str| format!("{}/{path}", base.trim_end_matches('/')); + Self { + device_code: join("devicecode"), + token: join("token"), + xbl_authenticate: join("xbl"), + xsts_authorize: join("xsts"), + login_with_xbox: join("login_with_xbox"), + entitlements_mcstore: join("entitlements/mcstore"), + entitlements_license: join("entitlements/license"), + profile: join("profile"), + } + } + + /// 所有端点(调试/诊断用)。 + #[must_use] + pub fn all(&self) -> [&str; 8] { + [ + &self.device_code, + &self.token, + &self.xbl_authenticate, + &self.xsts_authorize, + &self.login_with_xbox, + &self.entitlements_mcstore, + &self.entitlements_license, + &self.profile, + ] + } +} + +impl Default for MicrosoftEndpoints { + fn default() -> Self { + Self::consumers() + } +} + +#[cfg(test)] +mod tests { + use super::MicrosoftEndpoints; + + #[test] + fn consumers_endpoints_stay_on_two_families() { + let endpoints = MicrosoftEndpoints::consumers(); + assert!(endpoints.device_code.contains("login.microsoftonline.com")); + assert!(endpoints.token.contains("/consumers/oauth2/v2.0/token")); + assert!(endpoints + .xbl_authenticate + .contains("user.auth.xboxlive.com")); + assert!(endpoints.xsts_authorize.contains("xsts.auth.xboxlive.com")); + // 授权码流的 login.live.com 不在这张表里(本期不实现)。 + assert!(endpoints + .all() + .iter() + .all(|url| !url.contains("login.live.com"))); + } + + #[test] + 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.entitlements_license, + "http://127.0.0.1:1234/entitlements/license" + ); + } +} diff --git a/crates/core/src/auth/microsoft/flow.rs b/crates/core/src/auth/microsoft/flow.rs new file mode 100644 index 0000000..4c1d21d --- /dev/null +++ b/crates/core/src/auth/microsoft/flow.rs @@ -0,0 +1,313 @@ +//! device-code 流:发起(拿 `user_code` + `verification_uri`)→ 轮询换 token →(刷新同构)。 +//! +//! 逐条承 `analyse/04` §4.3 / §4.4 / §4.5: +//! - **硬超时**:只等 `min(expires_in, 900s)`,防止后台任务永久挂起; +//! - **`slow_down` 是流程内状态**,不是失败:轮询间隔 +5 秒继续; +//! - **每次轮询前先 sleep**(别密集打服务端); +//! - **refresh 同时返回新 refresh_token**,调用方必须整体覆盖(轮换); +//! - 收紧 HMCL 的观察项 1:**只要 `error` 非空就失败**,不再「描述为空就静默放行」。 +//! +//! 打印 `user_code`/URL 不在这里:core 不做 UI,宿主拿 [`DeviceCodePrompt`] 自己决定 +//! (CLI 打到 stderr,GUI 弹对话框 + 二维码)。 + +use std::time::{Duration, Instant}; + +use reqwest::Client; + +use crate::auth::error::{AuthError, AuthResult}; +use crate::auth::secret::Secret; + +use super::endpoints::MicrosoftEndpoints; +use crate::auth::http::{post_form, Reply}; + +/// 申请的 scope(`XboxLive.signin` + 长期刷新)。 +pub const SCOPE: &str = "XboxLive.signin offline_access"; +/// 设备码流程的硬上限:无论服务端给多长,最多等这么久。 +pub const DEVICE_CODE_MAX_WAIT: Duration = Duration::from_secs(900); +/// 默认轮询间隔下限(服务端 `interval` 更小时按下限走)。 +pub const DEFAULT_MIN_INTERVAL: Duration = Duration::from_secs(1); +/// `slow_down` 的退避步长(承 HMCL:+5 秒)。 +pub const DEFAULT_SLOW_DOWN_STEP: Duration = Duration::from_secs(5); +/// device_code grant 类型。 +pub const DEVICE_CODE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code"; + +/// 轮询策略(生产用 [`PollPolicy::default`],测试用小间隔跑同一套状态机)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PollPolicy { + /// 轮询间隔下限。 + pub min_interval: Duration, + /// `slow_down` 的退避步长。 + pub slow_down_step: Duration, + /// 整个流程最多等多久(还会与 `expires_in` 取小)。 + pub max_wait: Duration, +} + +impl Default for PollPolicy { + fn default() -> Self { + Self { + min_interval: DEFAULT_MIN_INTERVAL, + slow_down_step: DEFAULT_SLOW_DOWN_STEP, + max_wait: DEVICE_CODE_MAX_WAIT, + } + } +} + +/// 要展示给用户的信息(URL + 用户码)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceCodePrompt { + /// 用户在验证页输入的短码(如 `ABCD1234`)。**不是凭据**,可打印。 + pub user_code: String, + /// 验证地址(`https://www.microsoft.com/link` 等)。 + pub verification_uri: String, + /// 服务端给的有效期。 + pub expires_in: Duration, + /// 服务端建议的轮询间隔。 + pub interval: Duration, +} + +impl DeviceCodePrompt { + /// 人读两行(CLI 打到 stderr,GUI 直接展示)。 + #[must_use] + pub fn lines(&self) -> (String, String) { + ( + format!("请在任意设备打开 {} 并输入代码:", self.verification_uri), + format!( + "用户码 {}({} 分钟内有效)", + self.user_code, + self.expires_in.as_secs() / 60 + ), + ) + } +} + +/// 设备码申请的中间态(`device_code` 是凭据形状,不进日志)。 +#[derive(Debug, Clone)] +pub(crate) struct DeviceCodeGrant { + /// 展示信息。 + pub prompt: DeviceCodePrompt, + /// 轮询用的长码(脱敏 Debug,永不打印)。 + pub device_code: Secret, +} + +/// OAuth 换到的 token 对。 +#[derive(Debug, Clone)] +pub(crate) struct OAuthTokens { + /// Live access token(只在链内用,不落盘)。 + pub access_token: Secret, + /// Live refresh token(落盘、轮换)。 + pub refresh_token: Secret, +} + +/// 申请设备码。 +pub(crate) async fn request_device_code( + client: &Client, + endpoints: &MicrosoftEndpoints, + client_id: &str, +) -> AuthResult { + let reply = post_form( + client, + &endpoints.device_code, + vec![ + ("client_id", client_id.to_string()), + ("scope", SCOPE.to_string()), + ], + ) + .await?; + if !reply.is_success() { + return Err(oauth_error(&reply, &endpoints.device_code)); + } + let user_code = reply + .text("user_code") + .ok_or_else(|| AuthError::Malformed("设备码响应缺 user_code".to_string()))?; + let device_code = reply + .text("device_code") + .ok_or_else(|| AuthError::Malformed("设备码响应缺 device_code".to_string()))?; + let verification_uri = reply + .text("verification_uri") + .or_else(|| reply.text("verification_url")) + .ok_or_else(|| AuthError::Malformed("设备码响应缺 verification_uri".to_string()))?; + let expires_in = reply.number("expires_in").unwrap_or(900).max(1); + // 服务端间隔原样收下,下限交给 [`PollPolicy::min_interval`](生产 1 秒,测试可压到毫秒)。 + let interval = reply.number("interval").unwrap_or(5); + Ok(DeviceCodeGrant { + prompt: DeviceCodePrompt { + user_code: user_code.to_string(), + verification_uri: verification_uri.to_string(), + expires_in: Duration::from_secs(expires_in), + interval: Duration::from_secs(interval), + }, + device_code: Secret::new(device_code), + }) +} + +/// 轮询直到用户完成授权(或超时/被拒/取消)。 +pub(crate) async fn poll_device_token( + client: &Client, + endpoints: &MicrosoftEndpoints, + client_id: &str, + grant: &DeviceCodeGrant, + policy: PollPolicy, +) -> AuthResult { + let limit = grant.prompt.expires_in.min(policy.max_wait); + let mut interval = grant.prompt.interval.max(policy.min_interval); + let started = Instant::now(); + loop { + if started.elapsed() >= limit { + return Err(AuthError::Timeout { + what: format!("等待设备码授权(上限 {} 秒)", limit.as_secs()), + }); + } + tokio::time::sleep(interval).await; + let reply = post_form( + client, + &endpoints.token, + vec![ + ("client_id", client_id.to_string()), + ("grant_type", DEVICE_CODE_GRANT.to_string()), + ("code", grant.device_code.expose().to_string()), + ], + ) + .await?; + + if let Some(error) = reply.text("error") { + match error { + "authorization_pending" => continue, + // 服务端压力反馈:不是失败,退避后继续(承 HMCL)。 + "slow_down" => { + interval += policy.slow_down_step; + continue; + } + "expired_token" => { + return Err(AuthError::Timeout { + what: "设备码已过期(需重新发起登录)".to_string(), + }) + } + "access_denied" | "authorization_declined" => return Err(AuthError::Canceled), + _ => return Err(oauth_error(&reply, &endpoints.token)), + } + } + return tokens_from(&reply, &endpoints.token); + } +} + +/// 用 refresh_token 换新的 token 对(**刷新也会返回新 refresh_token**)。 +pub(crate) async fn refresh_tokens( + client: &Client, + endpoints: &MicrosoftEndpoints, + client_id: &str, + refresh_token: &Secret, +) -> AuthResult { + let reply = post_form( + client, + &endpoints.token, + vec![ + ("client_id", client_id.to_string()), + ("grant_type", "refresh_token".to_string()), + ("refresh_token", refresh_token.expose().to_string()), + ], + ) + .await?; + if let Some(error) = reply.text("error") { + // `invalid_grant` + `AADSTS70000` = 授权被撤销/过期 → 唯一该重新登录的信号。 + if error == "invalid_grant" { + let description = reply.text("error_description").unwrap_or(""); + if description.contains("AADSTS70000") { + return Err(AuthError::CredentialExpired); + } + } + return Err(oauth_error(&reply, &endpoints.token)); + } + if !reply.is_success() { + return Err(oauth_error(&reply, &endpoints.token)); + } + tokens_from(&reply, &endpoints.token) +} + +fn tokens_from(reply: &Reply, url: &str) -> AuthResult { + let access_token = reply + .text("access_token") + .ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 access_token")))?; + let refresh_token = reply + .text("refresh_token") + .ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 refresh_token")))?; + Ok(OAuthTokens { + access_token: Secret::new(access_token), + refresh_token: Secret::new(refresh_token), + }) +} + +/// OAuth 错误归一:**`error` 非空即失败**(HMCL 只在两个字段都非空时处理,我们收紧)。 +fn oauth_error(reply: &Reply, url: &str) -> AuthError { + let Some(code) = reply.text("error") else { + return AuthError::network( + url.split('?').next().unwrap_or(url), + format!("HTTP {}(响应不是 OAuth 错误体)", reply.status), + ); + }; + let description = reply + .text("error_description") + .unwrap_or("服务端未给描述") + .to_string(); + if code == "invalid_grant" && description.contains("AADSTS70000") { + return AuthError::CredentialExpired; + } + AuthError::Denied { + code: code.to_string(), + description, + } +} + +#[cfg(test)] +mod tests { + use super::{oauth_error, PollPolicy, DEFAULT_MIN_INTERVAL, DEVICE_CODE_MAX_WAIT}; + use crate::auth::error::AuthError; + use crate::auth::http::Reply; + use serde_json::json; + + #[test] + fn default_policy_matches_hmcl_bounds() { + let policy = PollPolicy::default(); + assert_eq!(policy.min_interval, DEFAULT_MIN_INTERVAL); + assert_eq!(policy.max_wait, DEVICE_CODE_MAX_WAIT); + assert_eq!(policy.max_wait.as_secs(), 900); + } + + #[test] + fn error_without_description_still_fails() { + let reply = reply(400, Some(json!({ "error": "invalid_request" }))); + match oauth_error(&reply, "https://example.invalid/token") { + AuthError::Denied { code, .. } => assert_eq!(code, "invalid_request"), + other => panic!("应是 Denied,实际 {other:?}"), + } + } + + #[test] + fn aadsts70000_becomes_credential_expired() { + let reply = reply( + 400, + Some(json!({ + "error": "invalid_grant", + "error_description": "AADSTS70000: The grant is expired or revoked" + })), + ); + assert!(oauth_error(&reply, "https://example.invalid/token").is_credential_expired()); + } + + #[test] + fn non_oauth_body_is_network_error() { + let reply = reply(500, None); + assert_eq!( + oauth_error(&reply, "https://example.invalid/token").code(), + "network" + ); + } + + fn reply(status: u16, body: Option) -> Reply { + Reply { + status, + body, + content_type: Some("application/json".to_string()), + raw: None, + } + } +} diff --git a/crates/core/src/auth/microsoft/mod.rs b/crates/core/src/auth/microsoft/mod.rs new file mode 100644 index 0000000..ace4aee --- /dev/null +++ b/crates/core/src/auth/microsoft/mod.rs @@ -0,0 +1,332 @@ +//! 微软轨:device-code 全流程 + 会话/刷新语义(`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5)。 +//! +//! 分四块,各管一件事: +//! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测); +//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock); +//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层); +//! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。 +//! +//! # client id 从哪来 +//! +//! device-code 流必须带一个**已在 Azure 注册的公共客户端 id**。HMCL 把它烘进 jar 的 +//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由 +//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时 +//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`] 并说清要配什么—— +//! 这比编一个假 id 让用户看到 `invalid_client` 诚实。 + +pub mod chain; +pub mod endpoints; +pub mod flow; + +pub mod session; + +pub use endpoints::MicrosoftEndpoints; +pub use flow::{DeviceCodePrompt, PollPolicy}; +pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA}; + +use std::time::{SystemTime, UNIX_EPOCH}; + +use reqwest::Client; + +use crate::auth::error::{AuthError, AuthResult}; +use crate::auth::journal::{AuthFailureEvent, AuthJournal}; +use crate::download::config::EngineConfig; +use crate::download::http::build_client; + +/// 运行期环境变量名(配 client id)。 +pub const CLIENT_ID_ENV: &str = "ULL_MSA_CLIENT_ID"; +/// 构建期可注入的 client id(`ULL_MSA_CLIENT_ID=... cargo build`)。 +pub const CLIENT_ID_BUILD: Option<&str> = option_env!("ULL_MSA_CLIENT_ID"); + +/// 微软账户登录/刷新。 +#[derive(Debug, Clone)] +pub struct MicrosoftAuth { + client: Client, + client_id: String, + endpoints: MicrosoftEndpoints, + poll: PollPolicy, + journal: Option, +} + +impl MicrosoftAuth { + /// 按显式 client id 构造(HTTP 基座复用 `core::download` 的客户端构造)。 + /// + /// # Errors + /// + /// HTTP 客户端建不起来时返回 [`AuthError::Network`]。 + pub fn new(client_id: impl Into) -> AuthResult { + let client = build_client(&EngineConfig::default()) + .map_err(|error| AuthError::network("http-client", error.to_string()))?; + Ok(Self { + client, + client_id: client_id.into(), + endpoints: MicrosoftEndpoints::consumers(), + poll: PollPolicy::default(), + journal: None, + }) + } + + /// 从环境/构建期取 client id(CLI 与 GUI 的默认入口)。 + /// + /// 与 [`Self::new`] 的唯一差别:这条**生产入口**默认挂上便携数据根的失败日志 + /// (`.ULL/logs/auth.jsonl`,见 [`AuthJournal::portable_default`]), + /// 交互式命令失败时 403 的状态码/正文摘要不会再随终端滚走。 + /// + /// # Errors + /// + /// 两处都没配时返回 [`AuthError::Unsupported`]。 + pub fn from_env() -> AuthResult { + let client_id = std::env::var(CLIENT_ID_ENV) + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(|| CLIENT_ID_BUILD.map(str::to_string)) + .ok_or_else(|| { + AuthError::Unsupported(format!( + "微软登录需要 Azure 公共客户端 id:设置环境变量 {CLIENT_ID_ENV}(或构建时注入)" + )) + })?; + let mut auth = Self::new(client_id)?; + auth.journal = AuthJournal::portable_default(); + Ok(auth) + } + + /// 换失败日志落点(`None` = 不落盘;CI/单测用)。 + #[must_use] + pub fn with_journal(mut self, journal: Option) -> Self { + self.journal = journal; + self + } + + /// 关掉失败日志。 + #[must_use] + pub fn without_journal(mut self) -> Self { + self.journal = None; + self + } + + /// 失败日志落点(测试断言/宿主展示用)。 + #[must_use] + pub fn journal(&self) -> Option<&AuthJournal> { + self.journal.as_ref() + } + + /// 换 HTTP 客户端(测试注入 / 复用宿主连接池)。 + #[must_use] + pub fn with_client(mut self, client: Client) -> Self { + self.client = client; + self + } + + /// 换端点表(测试整表指向 mock 服务器)。 + #[must_use] + pub fn with_endpoints(mut self, endpoints: MicrosoftEndpoints) -> Self { + self.endpoints = endpoints; + self + } + + /// 换轮询策略(测试用小间隔)。 + #[must_use] + pub fn with_poll_policy(mut self, poll: PollPolicy) -> Self { + self.poll = poll; + self + } + + /// 当前端点表。 + #[must_use] + pub fn endpoints(&self) -> &MicrosoftEndpoints { + &self.endpoints + } + + /// device-code 登录全流程。 + /// + /// `present` 在拿到用户码后**立刻**被调用(CLI 打到 stderr、GUI 弹框/二维码), + /// 然后才开始轮询——用户有整整 `expires_in` 的时间去另一台设备上输码。 + /// + /// 失败时除返回错误外,还会往失败日志(若配了)追加一条结构化记录。 + /// + /// # Errors + /// + /// 网络/超时/取消/被拒/无拥有权/无档案,见 [`AuthError`]。 + pub async fn device_code_login(&self, mut present: F) -> AuthResult + where + F: FnMut(&DeviceCodePrompt), + { + let result = self.login_and_chain(&mut present).await; + if let Err(error) = &result { + self.record_failure("device_code_login", error); + } + result + } + + /// 登录主体(`device_code_login` 的失败日志由外层统一记)。 + async fn login_and_chain( + &self, + present: &mut impl FnMut(&DeviceCodePrompt), + ) -> AuthResult { + let grant = + flow::request_device_code(&self.client, &self.endpoints, &self.client_id).await?; + present(&grant.prompt); + let tokens = flow::poll_device_token( + &self.client, + &self.endpoints, + &self.client_id, + &grant, + self.poll, + ) + .await?; + chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await + } + + /// 刷新:Live refresh → 整链重跑(**原样覆盖 refresh_token**)。 + /// + /// # Errors + /// + /// 凭据被撤销时返回 [`AuthError::CredentialExpired`](唯一该重新交互的信号)。 + pub async fn refresh(&self, session: &MicrosoftSession) -> AuthResult { + let result = self.refresh_and_chain(session).await; + if let Err(error) = &result { + self.record_failure("refresh", error); + } + result + } + + /// 刷新主体(失败日志由外层统一记)。 + async fn refresh_and_chain(&self, session: &MicrosoftSession) -> AuthResult { + let tokens = flow::refresh_tokens( + &self.client, + &self.endpoints, + &self.client_id, + &session.refresh_token, + ) + .await?; + let refreshed = + chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await?; + if !session.same_profile(&refreshed) { + // 防「刷出来是另一个账号」(承 HMCL WrongAccountException)。 + return Err(AuthError::Malformed(format!( + "刷新后档案变了({} → {})", + session.profile_id, refreshed.profile_id + ))); + } + Ok(refreshed) + } + + /// 尽力而为地记一条失败事件(写日志失败绝不影响登录本身的结论)。 + fn record_failure(&self, scope: &str, error: &AuthError) { + if let Some(journal) = &self.journal { + let _ = journal.record(&AuthFailureEvent::of(scope, error)); + } + } + + /// 网络侧校验:`GET /minecraft/profile` 是否还认这个 token。 + /// + /// 404 的二义在这里**不吞**:档案被删是真实错误,不该被当成「token 失效」静默刷新 + /// (`analyse/04` §11 观察项 3 那个隐式约定,我们写明)。 + /// + /// # Errors + /// + /// 网络失败、无拥有权、无档案等。 + pub async fn validate(&self, session: &MicrosoftSession) -> AuthResult { + if session.is_expired(now_ms()) { + return Ok(false); + } + match chain::fetch_profile(&self.client, &self.endpoints, &session.access_token).await { + Ok(_) => Ok(true), + // 「档案被删 / 没买游戏」是真实错误,穿透上抛(别静默当成 token 失效去刷新)。 + Err(error @ AuthError::Denied { .. }) => { + let fatal = matches!( + &error, + AuthError::Denied { code, .. } if code == "no_profile" || code == "no_license" + ); + if fatal { + Err(error) + } else { + Ok(false) + } + } + // 链上非 2xx:拦截页(HTML/纯文本)说明网络有问题,别伪装成「token 失效」; + // 服务端的 JSON 拒绝 / 空体仍按「token 不被接受」处理(与 M31 语义一致)。 + Err(error @ AuthError::Response(_)) => { + let interception = matches!( + &error, + AuthError::Response(detail) if detail.is_interception() + ); + if interception { + Err(error) + } else { + Ok(false) + } + } + Err(other) => Err(other), + } + } + + /// 需要就刷新(本地判定,不打网络),返回 `(会话, 是否刷新过)`。 + /// + /// 这是 GUI/CLI 的「静默续期」入口:`Reuse` 时零网络,`Refresh` 时整链重跑。 + /// + /// # Errors + /// + /// 刷新失败时原样上抛(含 [`AuthError::CredentialExpired`])。 + pub async fn ensure_fresh( + &self, + session: &MicrosoftSession, + ) -> AuthResult<(MicrosoftSession, Option)> { + match session.refresh_decision(now_ms()) { + RefreshDecision::Reuse => Ok((session.clone(), None)), + RefreshDecision::Refresh(reason) => { + let refreshed = self.refresh(session).await?; + Ok((refreshed, Some(reason))) + } + } + } +} + +/// 当前 Unix 毫秒。 +#[must_use] +pub fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::{now_ms, MicrosoftAuth, CLIENT_ID_ENV}; + use crate::auth::error::AuthError; + + #[test] + fn explicit_client_id_builds() { + let auth = MicrosoftAuth::new("0000-test-client").expect("构造"); + assert!(auth + .endpoints() + .profile + .contains("api.minecraftservices.com")); + } + + #[test] + fn missing_client_id_is_explained() { + // 测试进程里也可能被外部配了环境变量,先摘掉再验。 + let saved = std::env::var(CLIENT_ID_ENV).ok(); + std::env::remove_var(CLIENT_ID_ENV); + let result = MicrosoftAuth::from_env(); + if let Some(value) = saved { + std::env::set_var(CLIENT_ID_ENV, value); + } + match result { + Err(AuthError::Unsupported(message)) => assert!(message.contains(CLIENT_ID_ENV)), + // 构建期注入过 client id 时唯一合法,跳过断言。 + Ok(_) => assert!(super::CLIENT_ID_BUILD.is_some()), + Err(other) => panic!("应是 Unsupported,实际 {other:?}"), + } + } + + #[test] + fn now_ms_is_sane() { + // 2020-01-01 之后、2100 之前(防止本地时钟/单位抄错)。 + let now = now_ms(); + assert!(now > 1_577_836_800_000); + assert!(now < 4_102_444_800_000); + } +} diff --git a/crates/core/src/auth/microsoft/session.rs b/crates/core/src/auth/microsoft/session.rs new file mode 100644 index 0000000..b6df49b --- /dev/null +++ b/crates/core/src/auth/microsoft/session.rs @@ -0,0 +1,231 @@ +//! 微软会话与刷新语义:**HMCL 三层判定的类型化版本**(`analyse/04` §5.3 / §3.5 / §8.4)。 +//! +//! 三层判定原样平移: +//! 1. `has_profile_name()`:没有玩家名就没法启动(`--username` 不能为空); +//! 2. `not_after`:本地时间戳过期即视为未登录(省一次网络); +//! 3. 进程内 `authenticated` 标记归宿主(GUI/CLI)持有,不在本类型里。 +//! +//! 关键语义(照 HMCL,别自己发明): +//! - 存下来的 `refresh_token` 是 **Live(MSA)** 的,`access_token` 是 **Minecraft** 的; +//! - 刷新 = 整条链重跑(Live refresh → XBL → XSTS → login_with_xbox → entitlements → profile), +//! 不存在「只刷 MC token」的捷径; +//! - `profile_name` 属于**可变的私有缓存**(2022 年后允许改名),元数据里只留不可变的 profileID。 + +use serde::{Deserialize, Serialize}; + +use crate::auth::error::{AuthError, AuthResult}; +use crate::auth::secret::Secret; +use crate::launch::request::AuthPlaceholders; + +/// 微软账户的 launch 用户类型(`AuthInfo.USER_TYPE_MSA`)。 +pub const USER_TYPE_MSA: &str = "msa"; + +/// 一次成功登录后的会话(= 该账户的凭据载荷)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MicrosoftSession { + /// Minecraft 档案 UUID(32 位无连字符)。**不可变**。 + pub profile_id: String, + /// 玩家名(可空串:磁盘恢复允许「有档案但名字未知」,登录时补齐)。 + #[serde(default)] + pub profile_name: String, + /// MC token 类型(`Bearer`)。 + pub token_type: String, + /// Minecraft access token。 + pub access_token: Secret, + /// Live refresh token(轮换:每次刷新都会被新值整体覆盖)。 + pub refresh_token: Secret, + /// MC token 过期时刻(Unix 毫秒;0 = 未知 → 强制刷新)。 + pub not_after_ms: u64, + /// Xbox user id(`XUID`,诊断/排障用,非凭据)。 + pub user_id: Option, +} + +/// 过期判定的结论。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefreshDecision { + /// 本地认为可用(有玩家名且未过期):直接复用,不打网络。 + Reuse, + /// 需要整链重跑。 + Refresh(RefreshReason), +} + +/// 为什么要刷新。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefreshReason { + /// 玩家名缺失(改名或历史档案)——启动参数拿不到 `--username`。 + MissingProfileName, + /// 本地时间戳已过期。 + Expired, + /// 名字缺失**且**过期(两条理由都成立)。 + MissingProfileNameAndExpired, +} + +impl RefreshReason { + /// 人读一行。 + #[must_use] + pub const fn message(self) -> &'static str { + match self { + Self::MissingProfileName => "会话缺少玩家名,需要刷新", + Self::Expired => "Minecraft 令牌已过期,需要刷新", + Self::MissingProfileNameAndExpired => "会话缺少玩家名且令牌已过期,需要刷新", + } + } +} + +impl MicrosoftSession { + /// 构造(`expires_in` 是秒,`now_ms` 是当前 Unix 毫秒)。 + #[must_use] + pub fn new( + profile_id: impl Into, + profile_name: impl Into, + access_token: Secret, + refresh_token: Secret, + expires_in_secs: u64, + now_ms: u64, + user_id: Option, + ) -> Self { + Self { + profile_id: profile_id.into(), + profile_name: profile_name.into(), + token_type: "Bearer".to_string(), + access_token, + refresh_token, + not_after_ms: now_ms.saturating_add(expires_in_secs.saturating_mul(1000)), + user_id, + } + } + + /// 有没有玩家名(空串/全空白都算没有)。 + #[must_use] + pub fn has_profile_name(&self) -> bool { + !self.profile_name.trim().is_empty() + } + + /// 本地是否过期(`not_after_ms == 0` 视为过期,承 HMCL「坏数据强制刷新」)。 + #[must_use] + pub fn is_expired(&self, now_ms: u64) -> bool { + self.not_after_ms == 0 || now_ms >= self.not_after_ms + } + + /// 刷新判定(纯函数,可单测;不打网络)。 + #[must_use] + pub fn refresh_decision(&self, now_ms: u64) -> RefreshDecision { + match (self.has_profile_name(), self.is_expired(now_ms)) { + (true, false) => RefreshDecision::Reuse, + (false, false) => RefreshDecision::Refresh(RefreshReason::MissingProfileName), + (true, true) => RefreshDecision::Refresh(RefreshReason::Expired), + (false, true) => RefreshDecision::Refresh(RefreshReason::MissingProfileNameAndExpired), + } + } + + /// 该会话所有凭据值(武装脱敏器用)。 + #[must_use] + pub fn secrets(&self) -> Vec<&Secret> { + vec![&self.access_token, &self.refresh_token] + } + + /// 转成 launch 的占位符集合(`user_properties` 为 `{}`,承 HMCL)。 + /// + /// # Errors + /// + /// 会话缺少玩家名/UUID 时返回 [`AuthError::CredentialExpired`]——此时必须刷新, + /// 不能拿空名字去拼命令行(`--username` 空串会导致游戏直接退出)。 + pub fn to_placeholders(&self) -> AuthResult { + if !self.has_profile_name() { + return Err(AuthError::CredentialExpired); + } + if self.profile_id.is_empty() { + return Err(AuthError::Malformed("会话缺少 profileID".to_string())); + } + Ok(AuthPlaceholders { + player_name: self.profile_name.clone(), + uuid: self.profile_id.clone(), + access_token: self.access_token.expose().to_string(), + user_type: USER_TYPE_MSA.to_string(), + user_properties: "{}".to_string(), + }) + } + + /// 刷新后守住两条不变量(`analyse/04` §5.3 的 `WrongAccountException` / `hasProfileName`)。 + /// + /// 返回 `false` 表示「刷出来的不是同一个档案」,调用方必须当错误处理。 + #[must_use] + pub fn same_profile(&self, other: &Self) -> bool { + self.profile_id == other.profile_id + } +} + +#[cfg(test)] +mod tests { + use super::{MicrosoftSession, RefreshDecision, RefreshReason}; + use crate::auth::secret::Secret; + + fn session(name: &str, not_after_ms: u64) -> MicrosoftSession { + MicrosoftSession { + profile_id: "0f8fad5bd9cb469fa16570867728950e".to_string(), + profile_name: name.to_string(), + token_type: "Bearer".to_string(), + access_token: Secret::new("mc-token-value"), + refresh_token: Secret::new("live-refresh-value"), + not_after_ms, + user_id: None, + } + } + + #[test] + fn decision_matrix() { + let now = 1_000_000; + assert_eq!( + session("Steve", now + 1).refresh_decision(now), + RefreshDecision::Reuse + ); + assert_eq!( + session("", now + 1).refresh_decision(now), + RefreshDecision::Refresh(RefreshReason::MissingProfileName) + ); + assert_eq!( + session("Steve", now - 1).refresh_decision(now), + RefreshDecision::Refresh(RefreshReason::Expired) + ); + assert_eq!( + session(" ", now).refresh_decision(now), + RefreshDecision::Refresh(RefreshReason::MissingProfileNameAndExpired) + ); + } + + #[test] + fn zero_not_after_forces_refresh() { + assert_eq!( + session("Steve", 0).refresh_decision(5), + RefreshDecision::Refresh(RefreshReason::Expired) + ); + } + + #[test] + fn new_computes_not_after_from_expires_in() { + let built = MicrosoftSession::new( + "uuid", + "Steve", + Secret::new("a"), + Secret::new("r"), + 86_400, + 1_000, + None, + ); + assert_eq!(built.not_after_ms, 1_000 + 86_400_000); + assert_eq!(built.token_type, "Bearer"); + } + + #[test] + fn placeholders_need_a_name() { + assert!(session("", 10).to_placeholders().is_err()); + let placeholders = session("Steve", 10).to_placeholders().expect("占位符"); + assert_eq!(placeholders.player_name, "Steve"); + assert_eq!(placeholders.user_type, "msa"); + assert_eq!(placeholders.user_properties, "{}"); + // token 进了占位符(启动需要),但它自己的 Debug 是脱敏的。 + assert_eq!(placeholders.access_token, "mc-token-value"); + assert!(!format!("{:?}", session("Steve", 10)).contains("mc-token-value")); + } +} diff --git a/crates/core/src/auth/mod.rs b/crates/core/src/auth/mod.rs new file mode 100644 index 0000000..329030c --- /dev/null +++ b/crates/core/src/auth/mod.rs @@ -0,0 +1,52 @@ +//! 账户体系:三轨(离线 UUID / 微软 device-code / authlib-injector)、凭据存储边界。 +//! +//! 设计定稿:`DevDocs/PlanDoc/07-accounts.md`(决策 D9/Q21),协议细节来自 +//! `DevDocs/analyse/04-authentication.md`(HMCL v3.17 拆解,逐报文对照)。 +//! +//! # 三条铁律 +//! +//! 1. **凭据永不外泄**:token/密码不落日志、不进事件流、不打印(`AGENTS.md` §6、D4)。 +//! 类型层保证:[`Secret`] 的 `Debug`/`Display` 恒为 ``,取明文只能 `.expose()`; +//! 对外展示一律走元数据([`AccountMeta`],不含凭据)。 +//! 2. **账户操作永不 YOLO**:本模块不向 AI 暴露任何账户读写面(07 §3); +//! CLI 也只开 `list / add offline / add msa / remove` 四条(authlib 密码登录走 GUI 输入框, +//! 避免密码进 argv)。 +//! 3. **元数据 / 凭据分文件**:`data/auth/accounts.json` + `data/auth/credentials.dat`, +//! 先写凭据后写元数据,原子写,单条坏记录只跳过不整体失败。 +//! +//! # 公共面 +//! +//! - [`account`]:`AccountId` / `AccountKind` / `AccountMeta` / `Credentials`(三轨统一抽象); +//! - [`store`]:`AccountStore`(加载/增删/落盘,`data/auth/`); +//! - [`offline`]:HMCL 兼容离线 UUID + 启动占位; +//! - [`microsoft`]:device-code 全链(XBL→XSTS→login_with_xbox→entitlements→profile)+ 刷新判定; +//! - [`authlib`]:标准 Yggdrasil 协议(authenticate/refresh/validate/invalidate,账户侧); +//! - [`error`]:错误分流(取消 / 超时 / 被拒 / 凭据过期); +//! - [`body`]:非 2xx 应答的正文诊断(分类 JSON/HTML/空体 + 脱敏摘要,M38); +//! - [`journal`]:认证失败事件落盘(`.ULL/logs/auth.jsonl`,M38); +//! - [`secret`]:凭据值包装与脱敏。 +//! +//! # 本期不做(后续 mission) +//! +//! OS keychain / AES-256-GCM 凭据加密(07 §2 分级 1–2)、authlib-injector 的 +//! `-javaagent` 三件套与 jar 下载、皮肤与多角色选择、离线 UUID 的「官方风格」双模式 +//! (07 §1 升级 2,口径待定案)。接口约定见 `DevDocs/SubAgentsTalk/auth-接口约定.md`。 + +pub mod account; +pub mod authlib; +pub mod body; +pub mod error; +pub mod http; +pub mod journal; +pub mod microsoft; +pub mod offline; +pub mod secret; +pub mod store; + +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 secret::Secret; +pub use store::{AccountStore, AuthPaths}; diff --git a/crates/core/src/auth/offline.rs b/crates/core/src/auth/offline.rs new file mode 100644 index 0000000..3ab321f --- /dev/null +++ b/crates/core/src/auth/offline.rs @@ -0,0 +1,197 @@ +//! 离线轨:**HMCL / Minecraft 服务端同款离线 UUID** + 启动占位字段。 +//! +//! UUID 算法(`analyse/04` §7.1):`UUID.nameUUIDFromBytes("OfflinePlayer:" + name)` +//! = 对 UTF-8 字节做 MD5,再按 RFC 4122 改写成 **v3** 版本位与 **IETF 变体**位。 +//! 必须逐字节一致:离线服务器的 `ops.json` / 白名单 / 领地数据全按这个 UUID 存, +//! 算错就对不上(皮肤、权限、存档全乱)。因此有与 JDK `UUID.nameUUIDFromBytes` +//! 对拍的已知向量测试(见 `crates/core/tests/auth/offline_uuid.rs`)。 +//! +//! 启动占位(`analyse/04` §7.2,承 `OfflineAccount.logInWithoutSkin`): +//! - `accessToken` = **每次登录随机生成的 32 位十六进制**(游戏客户端不接受空 token); +//! - `userType` = `msa`(HMCL 现版本如此;历史注释提过 `legacy`,但那是旧结论); +//! - `userProperties` = `{}`; +//! - `uuid` 用**无连字符** 32 位形式(`--uuid` 参数要求)。 + +use md5::{Digest, Md5}; + +use crate::auth::account::{AccountKind, AccountMeta}; +use crate::auth::error::{AuthError, AuthResult}; +use crate::auth::secret::{random_hex32, Secret}; +use crate::launch::request::AuthPlaceholders; + +/// 离线 UUID 的固定前缀(改一个字节就会与所有服务端对不上)。 +pub const OFFLINE_PREFIX: &str = "OfflinePlayer:"; +/// 离线账户的 launch 用户类型(`AuthInfo.USER_TYPE_MSA`)。 +pub const OFFLINE_USER_TYPE: &str = "msa"; +/// 玩家名长度上限(MC 客户端 `--username` 的实际上限)。 +pub const MAX_NAME_CHARS: usize = 16; + +/// 算离线 UUID(**32 位无连字符小写十六进制**)。 +#[must_use] +pub fn offline_uuid(name: &str) -> String { + let digest = Md5::digest(format!("{OFFLINE_PREFIX}{name}").as_bytes()); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest); + + // RFC 4122 §4.3:版本位 3(MD5 based)、变体位 IETF(10xx)。 + bytes[6] = (bytes[6] & 0x0F) | 0x30; + bytes[8] = (bytes[8] & 0x3F) | 0x80; + + let mut hex = String::with_capacity(32); + for byte in bytes { + hex.push_str(&format!("{byte:02x}")); + } + hex +} + +/// 带连字符的离线 UUID(展示/诊断用;启动参数要的是无连字符版)。 +#[must_use] +pub fn offline_uuid_dashed(name: &str) -> String { + let raw = offline_uuid(name); + format!( + "{}-{}-{}-{}-{}", + &raw[0..8], + &raw[8..12], + &raw[12..16], + &raw[16..20], + &raw[20..32] + ) +} + +/// 校验并规范化离线玩家名。 +/// +/// 规则:非空、去掉首尾空白后 ≤16 字符、不含控制字符。 +/// **允许非 ASCII**(中文名在离线服里很常见,UUID 按 UTF-8 字节算即可)。 +/// +/// # Errors +/// +/// 空名/过长/含控制字符时返回 [`AuthError::Malformed`]。 +pub fn normalize_name(name: &str) -> AuthResult { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err(AuthError::Malformed("玩家名不能为空".to_string())); + } + if trimmed.chars().count() > MAX_NAME_CHARS { + return Err(AuthError::Malformed(format!( + "玩家名最长 {MAX_NAME_CHARS} 个字符,收到 {} 个", + trimmed.chars().count() + ))); + } + if trimmed.chars().any(char::is_control) { + return Err(AuthError::Malformed("玩家名不能含控制字符".to_string())); + } + Ok(trimmed.to_string()) +} + +/// 建一条离线账户元数据(名字已规范化)。 +/// +/// # Errors +/// +/// 名字不合法时返回 [`AuthError::Malformed`]。 +pub fn offline_meta(name: &str, created_at_ms: u64) -> AuthResult { + let name = normalize_name(name)?; + Ok(AccountMeta::new( + AccountKind::Offline, + offline_uuid(&name), + name, + created_at_ms, + )) +} + +/// 生成一次「登录」的启动占位(不联网、不落盘)。 +/// +/// # Errors +/// +/// 元数据不是离线轨或名字为空时返回 [`AuthError::Malformed`] / [`AuthError::CredentialExpired`]。 +pub fn offline_login(meta: &AccountMeta) -> AuthResult { + if meta.kind != AccountKind::Offline { + return Err(AuthError::Malformed(format!( + "账户 {} 不是离线轨({})", + meta.id, + meta.kind.as_str() + ))); + } + if meta.profile_name.trim().is_empty() { + return Err(AuthError::CredentialExpired); + } + Ok(AuthPlaceholders { + player_name: meta.profile_name.clone(), + uuid: meta.profile_id.clone(), + // 随机占位:每次登录都变(与 HMCL 一致),不落盘、不进日志。 + access_token: Secret::new(random_hex32()).expose().to_string(), + user_type: OFFLINE_USER_TYPE.to_string(), + user_properties: "{}".to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::{ + normalize_name, offline_login, offline_meta, offline_uuid, offline_uuid_dashed, + OFFLINE_USER_TYPE, + }; + use crate::auth::account::AccountKind; + + #[test] + fn uuid_matches_jdk_name_uuid_from_bytes() { + // 向量由 JDK 25 的 UUID.nameUUIDFromBytes 生成(见 tests/auth/offline_uuid.rs 的说明)。 + assert_eq!( + offline_uuid("Notch"), + "b50ad385829d3141a2167e7d7539ba7f".to_string() + ); + assert_eq!( + offline_uuid_dashed("Notch"), + "b50ad385-829d-3141-a216-7e7d7539ba7f" + ); + } + + #[test] + fn uuid_has_v3_and_ietf_bits() { + let raw = offline_uuid("AnyPlayer"); + let version = u8::from_str_radix(&raw[12..13], 16).expect("版本位"); + assert_eq!(version, 3, "必须标记为 v3(MD5)"); + let variant = u8::from_str_radix(&raw[16..17], 16).expect("变体位"); + assert!( + (0x8..=0xb).contains(&variant), + "变体位必须是 IETF(10xx),实际 {variant:x}" + ); + } + + #[test] + fn uuid_is_stable_and_case_sensitive() { + assert_eq!(offline_uuid("Steve"), offline_uuid("Steve")); + assert_ne!(offline_uuid("Steve"), offline_uuid("steve")); + } + + #[test] + fn name_validation() { + assert_eq!(normalize_name(" Steve ").expect("合法"), "Steve"); + assert_eq!(normalize_name("小明").expect("中文合法"), "小明"); + assert!(normalize_name(" ").is_err()); + assert!(normalize_name("0123456789abcdefg").is_err()); + assert!(normalize_name("bad\u{7}name").is_err()); + } + + #[test] + fn offline_meta_and_login_shape() { + let meta = offline_meta("小明", 1_000).expect("建元数据"); + assert_eq!(meta.kind, AccountKind::Offline); + assert_eq!(meta.profile_id, offline_uuid("小明")); + let placeholders = offline_login(&meta).expect("占位符"); + assert_eq!(placeholders.player_name, "小明"); + assert_eq!(placeholders.uuid, offline_uuid("小明")); + assert_eq!(placeholders.user_type, OFFLINE_USER_TYPE); + assert_eq!(placeholders.user_properties, "{}"); + assert_eq!(placeholders.access_token.len(), 32); + // 每次登录的占位 token 都不同(HMCL 行为)。 + let again = offline_login(&meta).expect("占位符"); + assert_ne!(placeholders.access_token, again.access_token); + } + + #[test] + fn offline_login_rejects_other_tracks() { + let mut meta = offline_meta("Steve", 1).expect("建元数据"); + meta.kind = AccountKind::Microsoft; + assert!(offline_login(&meta).is_err()); + } +} diff --git a/crates/core/src/auth/secret.rs b/crates/core/src/auth/secret.rs new file mode 100644 index 0000000..8296db4 --- /dev/null +++ b/crates/core/src/auth/secret.rs @@ -0,0 +1,182 @@ +//! 凭据值包装:**打印出来永远是 ``**。 +//! +//! 依据 `DevDocs/PlanDoc/07-accounts.md` §3 与 `DevDocs/AGENTS.md` §6 的凭据红线: +//! token/密码永不进日志、事件流、stdout、测试快照。HMCL 靠「往日志过滤器注册 token」 +//! (`analyse/04` §11 观察项 9:绕开 Logger 就失效),我们在**类型层**堵: +//! 只要某值被包成 [`Secret`],`Debug`/`Display`/`{:?}`/`{}` 都拿不到明文, +//! 取明文必须显式写 `.expose()`(一眼可搜、CR 可审)。 +//! +//! 落盘仍然需要明文(凭据文件),因此保留 serde 透明序列化——序列化不是打印。 + +use std::borrow::Cow; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::launch::redact::{Redactor, TOKEN_PLACEHOLDER}; + +/// `Debug`/`Display` 与脱敏替换的统一标记。 +pub const REDACTED: &str = ""; + +/// 一个凭据值(access token / refresh token / client token / 密码)。 +/// +/// 相等性按明文比较(刷新后判断「token 有没有变」要用到),哈希同理。 +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Secret(String); + +impl Secret { + /// 包住一个明文值。 + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// 取明文。**唯一取明文入口**:出现处都应能说清「这是发请求,不是打印」。 + #[must_use] + pub fn expose(&self) -> &str { + &self.0 + } + + /// 是否为空(空 token 等价于「没有凭据」)。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// 明文字节长度(判空/判占位长度用,不泄露内容)。 + #[must_use] + pub fn len(&self) -> usize { + self.0.len() + } +} + +impl fmt::Debug for Secret { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(REDACTED) + } +} + +impl fmt::Display for Secret { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(REDACTED) + } +} + +impl From for Secret { + fn from(value: String) -> Self { + Self(value) + } +} + +impl From<&str> for Secret { + fn from(value: &str) -> Self { + Self(value.to_string()) + } +} + +/// 生成 32 个十六进制字符的随机串(离线 accessToken 占位、条目 id)。 +/// +/// **不是密码学随机**(同 `game::ProfileId::generate` 的取舍):混合纳秒时间戳、进程 id、 +/// 进程内计数器后过 SplitMix64。这些值不参与任何服务端校验(HMCL 同样用 +/// `UUID.randomUUID().toString()` 的紧凑形式当占位),唯一性够用即可。 +#[must_use] +pub fn random_hex32() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos() as u64); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let mut seed = nanos ^ u64::from(std::process::id()).rotate_left(17) ^ seq.rotate_left(41); + + let mut out = String::with_capacity(32); + let mut state = seed; + while out.len() < 32 { + // 两个 u64 混合出 16 个 hex 字符一轮。 + state = split_mix64(state); + seed ^= split_mix64(seed.rotate_left(29) ^ state); + out.push_str(&format!("{:016x}", seed)); + } + out.truncate(32); + out +} + +fn split_mix64(seed: u64) -> u64 { + let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +/// 用一组凭据武装一个日志脱敏器(`launch::redact::Redactor` 的唯一构造入口)。 +/// +/// 太短的值(< 6 字节)会被 `Redactor` 自动忽略,避免把正常文本搅乱。 +#[must_use] +pub fn redactor_for<'a>(secrets: impl IntoIterator) -> Redactor { + let mut redactor = Redactor::new(); + for secret in secrets { + if !secret.is_empty() { + redactor = redactor.with_secret(secret.expose().to_string()); + } + } + redactor +} + +/// 对一段文本做凭据脱敏(走 `launch::redact` 的三条规则:已登记凭据、长度 ≥64 不透明串、 +/// 带点 token/JWT)。 +#[must_use] +pub fn scrub<'a>(text: &'a str, secrets: &[&Secret]) -> Cow<'a, str> { + let owned: Vec = secrets.iter().map(|secret| (*secret).clone()).collect(); + redactor_for(owned.iter()).scrub(text) +} + +/// 占位常量转出(给调用方断言 / 拼提示用)。 +#[must_use] +pub const fn token_placeholder() -> &'static str { + TOKEN_PLACEHOLDER +} + +#[cfg(test)] +mod tests { + use super::{random_hex32, scrub, Secret, REDACTED, TOKEN_PLACEHOLDER}; + + #[test] + fn debug_and_display_never_leak() { + let secret = Secret::new("SUPER-SECRET-TOKEN-VALUE"); + assert_eq!(format!("{secret:?}"), REDACTED); + assert_eq!(format!("{secret}"), REDACTED); + assert!(!format!("{secret:?}").contains("SUPER")); + let bundle = format!("{:?}", vec![secret.clone()]); + assert!(!bundle.contains("SECRET"), "{bundle}"); + assert_eq!(secret.expose(), "SUPER-SECRET-TOKEN-VALUE"); + } + + #[test] + fn serde_roundtrip_keeps_plaintext() { + let secret = Secret::new("abc123"); + let text = serde_json::to_string(&secret).expect("序列化"); + assert_eq!(text, "\"abc123\""); + let back: Secret = serde_json::from_str(&text).expect("反序列化"); + assert_eq!(back, secret); + } + + #[test] + fn scrub_replaces_registered_secret() { + let secret = Secret::new("0f8fad5bd9cb469fa16570867728950e"); + let text = format!("--accessToken {} --uuid abc", secret.expose()); + let scrubbed = scrub(&text, &[&secret]).into_owned(); + assert!(!scrubbed.contains("0f8fad")); + assert!(scrubbed.contains(TOKEN_PLACEHOLDER)); + } + + #[test] + fn random_hex32_is_32_hex_and_unique() { + let first = random_hex32(); + let second = random_hex32(); + assert_eq!(first.len(), 32); + assert!(first.chars().all(|ch| ch.is_ascii_hexdigit())); + assert_ne!(first, second); + } +} diff --git a/crates/core/src/auth/store.rs b/crates/core/src/auth/store.rs new file mode 100644 index 0000000..0021b24 --- /dev/null +++ b/crates/core/src/auth/store.rs @@ -0,0 +1,251 @@ +//! 账户存储:`data/auth/` 下的**元数据 / 凭据分文件**落盘(内存视图与增删)。 +//! +//! 契约(`PlanDoc/07-accounts.md` §2、`PlanDoc/04-data-and-config.md`): +//! 1. **分文件**:`accounts.json`(可公开)+ `credentials.dat`(凭据)。列账户不碰凭据文件; +//! 2. **先写凭据、再写元数据**(HMCL 安全顺序)——元数据永不指向不存在的凭据; +//! 3. **原子写**:走 `platform::atomic::write_atomic`(tmp → fsync → rename); +//! 4. **容错**:单条坏记录只跳过该条并留 warning,绝不整体加载失败; +//! 5. **孤儿清理**:`save()` 只写「元数据里还存在」的凭据。 +//! +//! 落盘细节(payload 形状、权限收紧)在同目录的 [`payload`] 子模块,本文件只管模型与不变量。 +//! +//! 保护级别:本期 `plain-v1`(分文件 + 权限 + 全链脱敏)。OS keychain / AES-256-GCM +//! 分级是 07 §2 的第 1–2 条,需要 keyring/aes-gcm/argon2 的依赖决策,属后续 mission。 + +mod payload; + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use crate::auth::account::{AccountId, AccountMeta, Credentials}; +use crate::auth::error::{AuthError, AuthResult}; + +/// 元数据文件名(`data/auth/accounts.json`)。 +pub const ACCOUNTS_FILE: &str = "accounts.json"; +/// 凭据文件名(`data/auth/credentials.dat`)。 +pub const CREDENTIALS_FILE: &str = "credentials.dat"; +/// 元数据 schema 版本。 +pub const AUTH_SCHEMA_VERSION: u32 = 1; +/// 本期凭据保护级别(明文 + 分文件 + 权限 + 脱敏)。 +pub const PROTECTION_PLAIN_V1: &str = "plain-v1"; + +/// `data/auth/` 的两个文件位置。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthPaths { + root: PathBuf, +} + +impl AuthPaths { + /// 以数据分区为根(`LauncherLayout::auth_data()`)。 + #[must_use] + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + /// 根目录。 + #[must_use] + pub fn root(&self) -> &Path { + &self.root + } + + /// 元数据文件。 + #[must_use] + pub fn accounts_file(&self) -> PathBuf { + self.root.join(ACCOUNTS_FILE) + } + + /// 凭据文件。 + #[must_use] + pub fn credentials_file(&self) -> PathBuf { + self.root.join(CREDENTIALS_FILE) + } +} + +/// 账户库(内存视图 + 落盘)。 +#[derive(Debug, Clone)] +pub struct AccountStore { + paths: AuthPaths, + accounts: Vec, + credentials: BTreeMap, + warnings: Vec, +} + +impl AccountStore { + /// 打开(不存在就是空库;坏记录进 warnings)。 + #[must_use] + pub fn open(root: impl Into) -> Self { + Self::open_paths(AuthPaths::new(root)) + } + + /// 打开指定文件位置。 + #[must_use] + pub fn open_paths(paths: AuthPaths) -> Self { + let mut warnings = Vec::new(); + let mut accounts = payload::read_accounts(&paths.accounts_file(), &mut warnings); + accounts.sort_by_key(|meta| meta.created_at_ms); + let known: Vec = accounts + .iter() + .map(|meta| meta.id.as_str().to_string()) + .collect(); + let credentials = + payload::read_credentials(&paths.credentials_file(), &known, &mut warnings); + Self { + paths, + accounts, + credentials, + warnings, + } + } + + /// 文件位置。 + #[must_use] + pub fn paths(&self) -> &AuthPaths { + &self.paths + } + + /// 账户列表(按创建时间排序,展示顺序稳定)。 + #[must_use] + pub fn accounts(&self) -> &[AccountMeta] { + &self.accounts + } + + /// 加载过程中的告警(坏记录、孤儿凭据等)。 + #[must_use] + pub fn warnings(&self) -> &[String] { + &self.warnings + } + + /// 是否没有任何账户。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.accounts.is_empty() + } + + /// 按名字 / 登录名 / 条目 id 查找(大小写不敏感)。 + #[must_use] + pub fn find(&self, key: &str) -> Option<&AccountMeta> { + self.accounts.iter().find(|meta| meta.matches(key)) + } + + /// 取某条目的凭据。 + #[must_use] + pub fn credentials(&self, id: &AccountId) -> Option<&Credentials> { + self.credentials.get(id.as_str()) + } + + /// 取某条目的「元数据 + 凭据」(找不到凭据时报错)。 + /// + /// # Errors + /// + /// 条目不存在 → [`AuthError::NotFound`];条目在但凭据缺失 → [`AuthError::Malformed`] + /// (说明文件被手工删过,别静默当成「没登录」)。 + pub fn entry(&self, id: &AccountId) -> AuthResult<(&AccountMeta, &Credentials)> { + let meta = self + .accounts + .iter() + .find(|meta| meta.id == *id) + .ok_or_else(|| AuthError::NotFound { + account: id.to_string(), + })?; + let credentials = self + .credentials + .get(id.as_str()) + .ok_or_else(|| AuthError::Malformed(format!("账户 {id} 的凭据缺失")))?; + Ok((meta, credentials)) + } + + /// 新增账户(内存)。同名同轨冲突时报错——`--account <名字>` 才有唯一解。 + /// + /// # Errors + /// + /// 重复、条目 id 重复、轨道与凭据不符都返回错误。 + pub fn insert(&mut self, meta: AccountMeta, credentials: Credentials) -> AuthResult<()> { + meta.check_credentials(&credentials)?; + if self.accounts.iter().any(|existing| existing.id == meta.id) { + return Err(AuthError::Conflict { + account: meta.id.to_string(), + }); + } + let duplicate = self + .accounts + .iter() + .any(|existing| existing.kind == meta.kind && existing.matches(meta.display_name())); + if duplicate { + return Err(AuthError::Conflict { + account: meta.display_name().to_string(), + }); + } + self.credentials + .insert(meta.id.as_str().to_string(), credentials); + self.accounts.push(meta); + self.accounts.sort_by_key(|meta| meta.created_at_ms); + Ok(()) + } + + /// 删除账户(连同凭据)。 + /// + /// # Errors + /// + /// 找不到时返回 [`AuthError::NotFound`]。 + pub fn remove(&mut self, key: &str) -> AuthResult { + let index = self + .accounts + .iter() + .position(|meta| meta.matches(key)) + .ok_or_else(|| AuthError::NotFound { + account: key.to_string(), + })?; + let meta = self.accounts.remove(index); + self.credentials.remove(meta.id.as_str()); + Ok(meta) + } + + /// 更新某条目的凭据(刷新后轮换 token 用)。 + /// + /// # Errors + /// + /// 条目不存在或轨道不符时返回错误。 + pub fn update_credentials( + &mut self, + id: &AccountId, + credentials: Credentials, + ) -> AuthResult<()> { + let meta = self + .accounts + .iter() + .find(|meta| meta.id == *id) + .ok_or_else(|| AuthError::NotFound { + account: id.to_string(), + })?; + meta.check_credentials(&credentials)?; + self.credentials + .insert(id.as_str().to_string(), credentials); + Ok(()) + } + + /// 同步某条目的元数据字段(刷新补齐了玩家名之后写回)。 + /// + /// # Errors + /// + /// 条目不存在时返回 [`AuthError::NotFound`]。 + pub fn update_meta(&mut self, meta: AccountMeta) -> AuthResult<()> { + let slot = self + .accounts + .iter_mut() + .find(|existing| existing.id == meta.id) + .ok_or_else(|| AuthError::NotFound { + account: meta.id.to_string(), + })?; + *slot = meta; + Ok(()) + } + + /// 落盘:**先凭据、后元数据**。 + /// + /// # Errors + /// + /// 两个文件任一写失败都返回 [`AuthError::Store`]。 + pub fn save(&self) -> AuthResult<()> { + payload::write(&self.paths, &self.accounts, &self.credentials) + } +} diff --git a/crates/core/src/auth/store/payload.rs b/crates/core/src/auth/store/payload.rs new file mode 100644 index 0000000..b070278 --- /dev/null +++ b/crates/core/src/auth/store/payload.rs @@ -0,0 +1,247 @@ +//! 落盘细节:payload 形状、宽容解析、凭据文件权限。 +//! +//! 两个文件的形状(都带 `schemaVersion`,便于以后演进): +//! +//! ```json +//! // accounts.json —— 可公开 +//! { "schemaVersion": 1, "accounts": [ { "id": "account:…", "kind": "offline", +//! "profileId": "…", "profileName": "Steve", "createdAtMs": 1700000000000 } ] } +//! +//! // credentials.dat —— 凭据 +//! { "schemaVersion": 1, "protection": "plain-v1", +//! "entries": [ { "accountId": "account:…", "data": { "kind": "microsoft", … } } ] } +//! ``` +//! +//! `entries[].data` 里是 [`Credentials`] 的序列化结果(由 serde 自己定义形状), +//! 本模块只负责「哪条属于哪个账户」。 + +use std::collections::BTreeMap; +use std::path::Path; + +use serde_json::{json, Value}; + +use crate::auth::account::{AccountMeta, Credentials}; +use crate::auth::error::{AuthError, AuthResult}; +use crate::platform::atomic::write_atomic; + +use super::{AuthPaths, AUTH_SCHEMA_VERSION, PROTECTION_PLAIN_V1}; + +/// 写两处文件:**先凭据、后元数据**。 +pub(super) fn write( + paths: &AuthPaths, + accounts: &[AccountMeta], + credentials: &BTreeMap, +) -> AuthResult<()> { + let credentials_payload = credentials_payload(accounts, credentials); + let path = paths.credentials_file(); + write_atomic(&path, credentials_payload.as_bytes()) + .map_err(|error| AuthError::store(path.display(), error.to_string()))?; + restrict(&path)?; + + let accounts_payload = accounts_payload(accounts); + let path = paths.accounts_file(); + write_atomic(&path, accounts_payload.as_bytes()) + .map_err(|error| AuthError::store(path.display(), error.to_string()))?; + Ok(()) +} + +fn credentials_payload( + accounts: &[AccountMeta], + credentials: &BTreeMap, +) -> String { + let mut entries = Vec::new(); + for meta in accounts { + // 孤儿凭据(元数据已删)不写回。 + let Some(data) = credentials.get(meta.id.as_str()) else { + continue; + }; + entries.push(json!({ + "accountId": meta.id.as_str(), + "data": data, + })); + } + let payload = json!({ + "schemaVersion": AUTH_SCHEMA_VERSION, + "protection": PROTECTION_PLAIN_V1, + "entries": entries, + }); + serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string()) +} + +fn accounts_payload(accounts: &[AccountMeta]) -> String { + let payload = json!({ + "schemaVersion": AUTH_SCHEMA_VERSION, + "accounts": accounts, + }); + serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string()) +} + +/// 读元数据(坏记录跳过并记 warning)。 +pub(super) fn read_accounts(path: &Path, warnings: &mut Vec) -> Vec { + let mut accounts = Vec::new(); + let Some(text) = read_to_string(path, warnings) else { + return accounts; + }; + let Ok(value) = serde_json::from_str::(&text) else { + warnings.push(format!("{} 不是合法 JSON,按空账户库处理", path.display())); + return accounts; + }; + let Some(items) = value.get("accounts").and_then(Value::as_array) else { + warnings.push(format!("{} 缺 accounts 数组", path.display())); + return accounts; + }; + for item in items { + match AccountMeta::from_value(item) { + Ok(meta) => accounts.push(meta), + Err(error) => warnings.push(format!("跳过一条账户元数据:{}", error.message())), + } + } + accounts +} + +/// 读凭据(只保留 `known` 里存在的账户,孤儿丢弃并记 warning)。 +pub(super) fn read_credentials( + path: &Path, + known: &[String], + warnings: &mut Vec, +) -> BTreeMap { + let mut credentials = BTreeMap::new(); + let Some(text) = read_to_string(path, warnings) else { + return credentials; + }; + let Ok(value) = serde_json::from_str::(&text) else { + warnings.push(format!( + "{} 不是合法 JSON,按「无凭据」处理(账户列表仍可用)", + path.display() + )); + return credentials; + }; + if let Some(protection) = value.get("protection").and_then(Value::as_str) { + if protection != PROTECTION_PLAIN_V1 { + warnings.push(format!( + "{} 的保护级别是 `{protection}`,本期只认 `{PROTECTION_PLAIN_V1}`", + path.display() + )); + } + } + let Some(entries) = value.get("entries").and_then(Value::as_array) else { + return credentials; + }; + for entry in entries { + let Some(account_id) = entry.get("accountId").and_then(Value::as_str) else { + continue; + }; + let account_id = account_id.to_ascii_lowercase(); + if !known.contains(&account_id) { + warnings.push(format!("凭据 {account_id} 没有对应账户,已忽略")); + continue; + } + let Some(data) = entry.get("data") else { + continue; + }; + match serde_json::from_value::(data.clone()) { + Ok(parsed) => { + credentials.insert(account_id, parsed); + } + Err(error) => warnings.push(format!("跳过一条凭据({account_id}):{error}")), + } + } + credentials +} + +fn read_to_string(path: &Path, warnings: &mut Vec) -> Option { + match std::fs::read_to_string(path) { + Ok(text) => Some(text), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + warnings.push(format!("读 {} 失败:{error}", path.display())); + None + } + } +} + +/// 把凭据文件收紧到「仅本人可读写」。 +/// +/// POSIX 侧显式 `0600`;Windows 侧 `set_permissions` 只能表达只读位,凭据保护依赖 +/// 用户目录默认 ACL(`data/auth/` 位于用户数据根下)。**这是已知缺口**: +/// 补一个 `platform::atomic::write_atomic_private` 比在 auth 里散写 cfg 更对, +/// 已作为 finding 报给 tower(见完成报告)。 +fn restrict(path: &Path) -> AuthResult<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let permissions = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(path, permissions) + .map_err(|error| AuthError::store(path.display(), error.to_string()))?; + } + #[cfg(not(unix))] + { + let _ = path; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::{accounts_payload, credentials_payload}; + use crate::auth::account::{AccountKind, AccountMeta, Credentials}; + use crate::auth::microsoft::MicrosoftSession; + use crate::auth::secret::Secret; + + fn microsoft_meta() -> (AccountMeta, Credentials) { + let meta = AccountMeta::new( + AccountKind::Microsoft, + "0f8fad5bd9cb469fa16570867728950e", + "Steve", + 7, + ); + let session = MicrosoftSession::new( + "0f8fad5bd9cb469fa16570867728950e", + "Steve", + Secret::new("mc-access-token"), + Secret::new("live-refresh-token"), + 3600, + 1_000, + None, + ); + (meta, Credentials::Microsoft(session)) + } + + #[test] + fn metadata_payload_never_contains_tokens() { + let (meta, creds) = microsoft_meta(); + let payload = accounts_payload(std::slice::from_ref(&meta)); + assert!(payload.contains("0f8fad5bd9cb469fa16570867728950e")); + assert!(payload.contains("\"kind\": \"microsoft\"")); + assert!(!payload.contains("mc-access-token")); + assert!(!payload.contains("live-refresh-token")); + // 凭据对象的 Debug 也是脱敏的,别把 token 带进 payload。 + assert!(!format!("{creds:?}").contains("mc-access-token")); + } + + #[test] + fn credentials_payload_carries_protection_and_entries() { + let (meta, creds) = microsoft_meta(); + let mut map = BTreeMap::new(); + map.insert(meta.id.as_str().to_string(), creds); + let payload = credentials_payload(std::slice::from_ref(&meta), &map); + assert!(payload.contains("\"protection\": \"plain-v1\"")); + assert!(payload.contains(meta.id.as_str())); + assert!(payload.contains("mc-access-token")); + } + + #[test] + fn orphan_credentials_are_not_written() { + let (meta, creds) = microsoft_meta(); + let mut map = BTreeMap::new(); + map.insert( + "account:deadbeefdeadbeefdeadbeefdeadbeef".to_string(), + creds, + ); + let payload = credentials_payload(std::slice::from_ref(&meta), &map); + assert!(!payload.contains("mc-access-token")); + assert!(payload.contains("\"entries\": []")); + } +} diff --git a/crates/core/src/diagnose/collectors.rs b/crates/core/src/diagnose/collectors.rs new file mode 100644 index 0000000..d418031 --- /dev/null +++ b/crates/core/src/diagnose/collectors.rs @@ -0,0 +1,267 @@ +//! 五类信号采集接口:退出码 / 日志环形缓冲 / crash-reports / hs_err / 环境快照(+ mod 清单)。 +//! +//! 分工(与 `launch-接口约定.md` 对齐): +//! - **推送类**([`ExitCollector`] / [`EnvironmentCollector`]):launch 已经算出事实,直接塞进 [`SignalBundle`]; +//! - **磁盘类**([`CrashReportCollector`] / [`HsErrCollector`] / [`LatestLogCollector`]):本模块自己按 +//! mtime 取最新一份,并可用 [`CollectionContext::started_at`] 过滤历史崩溃; +//! - 日志行由 launch 逐行喂 [`LogBuffer::push`],diagnose 不解析会话 JSONL(两边零类型耦合)。 +//! +//! 采集是**尽力而为**:[`collect_all`] 把单个来源的失败收集成 [`CollectError`] 而不是中断整体—— +//! 崩溃现场本来就残缺,少一个来源不该失去全部结论。 + +use std::fmt; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +#[path = "environment.rs"] +pub mod environment; +#[path = "signals.rs"] +pub mod signals; +#[path = "sources.rs"] +pub mod sources; + +pub use environment::{ComponentVersion, EnvironmentSnapshot, ModRecord}; +pub use signals::{ + ExitSignal, ExitType, LogBuffer, LogLine, SignalOrigin, CRASH_KEYWORDS, EXIT_ACCESS_VIOLATION, + EXIT_DLL_NOT_FOUND, EXIT_SIGKILL, EXIT_SIGTERM, EXIT_STACK_BUFFER_OVERRUN, JVM_ERROR_KEYWORDS, +}; +pub use sources::{ + CrashReportCollector, EnvironmentCollector, ExitCollector, HsErrCollector, LatestLogCollector, +}; + +use super::engine::DiagnosisInput; +use super::hs_err::HsErrReport; + +/// 信号种类(可观测性 / 采集计划编排用)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SignalKind { + /// 退出码 + 退出类型。 + Exit, + /// stdout/stderr 环形缓冲。 + Log, + /// `crash-reports/crash-*.txt`。 + CrashReport, + /// `hs_err_pid*.log`。 + HsErr, + /// 环境快照。 + Environment, + /// mod 清单。 + Mods, +} + +impl SignalKind { + /// 稳定字符串。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Exit => "exit", + Self::Log => "log", + Self::CrashReport => "crash_report", + Self::HsErr => "hs_err", + Self::Environment => "environment", + Self::Mods => "mods", + } + } +} + +/// 采集上下文。 +#[derive(Debug, Clone, Default)] +pub struct CollectionContext { + /// 游戏工作目录(`crash-reports/`、`hs_err_pid*.log`、`logs/latest.log` 都在它下面)。 + pub run_dir: PathBuf, + /// 进程启动时刻:只采集修改时间 ≥ 它的文件,避免把上次崩溃算进本次。 + pub started_at: Option, +} + +impl CollectionContext { + /// 以工作目录构造。 + #[must_use] + pub fn new(run_dir: impl Into) -> Self { + Self { + run_dir: run_dir.into(), + started_at: None, + } + } + + /// 带「只认本次会话文件」的时间下界。 + #[must_use] + pub fn since(mut self, started_at: SystemTime) -> Self { + self.started_at = Some(started_at); + self + } +} + +/// 采集失败(尽力而为:单源失败不影响其它来源)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CollectError { + /// 出错的信号种类。 + pub kind: SignalKind, + /// 相关路径(可能为空,例如纯逻辑错误)。 + pub path: Option, + /// 人类可读原因。 + pub message: String, +} + +impl CollectError { + fn io(kind: SignalKind, path: &Path, error: &io::Error) -> Self { + Self { + kind, + path: Some(path.to_path_buf()), + message: error.to_string(), + } + } +} + +impl fmt::Display for CollectError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let path = self + .path + .as_deref() + .map_or_else(String::new, |path| format!("({})", path.display())); + write!(f, "{} 采集失败{path}:{}", self.kind.as_str(), self.message) + } +} + +impl std::error::Error for CollectError {} + +/// 采到的源文件列表(诊断包导出 / 可观测性)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CollectedSources { + /// 文件路径。 + pub paths: Vec, +} + +impl CollectedSources { + /// 空。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 记一个源文件。 + pub fn push(&mut self, path: impl Into) { + self.paths.push(path.into()); + } + + /// 是否什么都没采到。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.paths.is_empty() + } +} + +/// 一个来源的采到的文本(含来源文件与时间)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapturedText { + /// 来源文件。 + pub path: PathBuf, + /// 文本。 + pub text: String, + /// 修改时间。 + pub modified: Option, +} + +/// 采集到的全部信号。 +#[derive(Debug, Clone, Default)] +pub struct SignalBundle { + /// 退出信号。 + pub exit: Option, + /// 日志环形缓冲(launch 逐行喂;`latest.log` 兜底也进这里)。 + pub log: LogBuffer, + /// 崩溃报告全文。 + pub crash_report: Option, + /// hs_err 解析结果。 + pub hs_err: Option, + /// 环境快照。 + pub environment: Option, + /// mod 清单。 + pub mods: Vec, + /// 本次采到的源文件。 + pub sources: Vec, +} + +impl SignalBundle { + /// 空包(日志缓冲默认 2000 行)。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 是否没有任何信号。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.exit.is_none() + && self.log.is_empty() + && self.crash_report.is_none() + && self.hs_err.is_none() + && self.environment.is_none() + && self.mods.is_empty() + } + + /// 转成规则引擎的输入。 + /// + /// 日志按来源分组成多份待匹配文本(stdout/stderr 分开),这样证据里能保留来源; + /// crash-report 与 hs_err 各占一份,取证优先级由 `SignalOrigin::evidence_rank` 决定。 + #[must_use] + pub fn diagnosis_input(&self) -> DiagnosisInput { + let mut input = DiagnosisInput::new(); + input.exit = self.exit.clone(); + for (origin, text) in grouped_log_text(&self.log) { + input.push_text(origin, text); + } + if let Some(crash_report) = &self.crash_report { + input.push_text(SignalOrigin::CrashReport, crash_report.text.clone()); + } + if let Some(report) = &self.hs_err { + input.push_hs_err(report.clone()); + } + input + } +} + +fn grouped_log_text(log: &LogBuffer) -> Vec<(SignalOrigin, String)> { + let mut groups: Vec<(SignalOrigin, String)> = Vec::new(); + for line in log.lines() { + match groups.iter_mut().find(|(origin, _)| *origin == line.origin) { + Some((_, text)) => { + text.push_str(&line.text); + text.push('\n'); + } + None => groups.push((line.origin, format!("{}\n", line.text))), + } + } + groups +} + +/// 信号采集器。 +pub trait SignalCollector { + /// 采哪种信号。 + fn kind(&self) -> SignalKind; + + /// 采集并写入 `out`;返回本次采到的源文件。 + /// + /// # Errors + /// + /// 读盘失败 / 解析失败时返回 [`CollectError`];调用方([`collect_all`])会收集错误继续。 + fn collect( + &self, + ctx: &CollectionContext, + out: &mut SignalBundle, + ) -> Result; +} + +/// 按顺序跑一批采集器,单源失败只记录不中断。 +pub fn collect_all( + collectors: &[&dyn SignalCollector], + ctx: &CollectionContext, +) -> (SignalBundle, Vec) { + let mut bundle = SignalBundle::new(); + let mut errors = Vec::new(); + for collector in collectors { + if let Err(error) = collector.collect(ctx, &mut bundle) { + errors.push(error); + } + } + (bundle, errors) +} diff --git a/crates/core/src/diagnose/engine.rs b/crates/core/src/diagnose/engine.rs new file mode 100644 index 0000000..5549de9 --- /dev/null +++ b/crates/core/src/diagnose/engine.rs @@ -0,0 +1,321 @@ +//! 规则引擎:全量匹配 → 去重 → 抑制关系 → 置信度排序 → 结构化报告。 +//! +//! 与 HMCL `CrashReportAnalyzer.analyze` + `GameCrashWindow.merge` 的差异(analyse/02 §7.6 §7.10): +//! 1. 同一条规则只留**最优证据来源**的那次命中(崩溃报告 > hs_err > 日志 > stdout > stderr); +//! 2. 结论带 `priority`/`confidence`,主因在 `findings[0]`,被抑制的次生结论进 `secondary`; +//! 3. 输出是纯数据 [`DiagnosisRecord`](可序列化、可跨线程),不再泄漏匹配器状态; +//! 4. 退出码 / hs_err 这类**结构化信号**由内置结论补位(见 `synthetic.rs`)。 +//! +//! 性能取舍:`scope: line` 逐行扫(59 条 × 行数,弱机可接受);`scope: file` 的跨行规则 +//! 只在 ≤1 MiB 文本上跑,超长日志取首尾各 512 KiB。 + +use std::collections::BTreeMap; + +use crate::diagnose::collectors::{ExitSignal, SignalOrigin}; +use crate::diagnose::hs_err::HsErrReport; +use crate::diagnose::rules::{CompiledRule, MatchScope}; + +#[path = "finding.rs"] +mod finding; +#[path = "synthetic.rs"] +mod synthetic; + +pub use finding::{ + Candidate, DiagnosisRecord, DiagnosisReport, Evidence, ExitRecord, Finding, + CONCLUSIVE_CONFIDENCE, +}; +pub use synthetic::synthetic_findings; + +/// 超过这个字节数就不再整份扫描 `scope: file` 规则(取首尾窗口)。 +const FILE_SCOPE_LIMIT: usize = 1024 * 1024; +/// 超长文本的扫描窗口(首尾各一份)。 +const FILE_SCOPE_WINDOW: usize = 512 * 1024; +/// 证据行截断长度(避免把整份栈塞进诊断输出)。 +const EVIDENCE_MAX_CHARS: usize = 300; + +/// 待匹配的一段文本(一个信号来源一份)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Haystack { + /// 来源(决定证据前缀与取证优先级)。 + pub origin: SignalOrigin, + /// 文本。 + pub text: String, +} + +impl Haystack { + /// 构造。 + #[must_use] + pub fn new(origin: SignalOrigin, text: impl Into) -> Self { + Self { + origin, + text: text.into(), + } + } +} + +/// 引擎输入:退出信号 + 若干待匹配文本 +(可选)解析好的 hs_err。 +#[derive(Debug, Clone, Default)] +pub struct DiagnosisInput { + /// 退出信号。 + pub exit: Option, + /// 待匹配文本(按 `SignalOrigin::evidence_rank` 优先取证据)。 + pub haystacks: Vec, + /// 解析好的 `hs_err_pid*.log`(产生内置结论,其摘要也已进 `haystacks`)。 + pub hs_err: Option, +} + +impl DiagnosisInput { + /// 空输入。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 带退出信号。 + #[must_use] + pub fn with_exit(mut self, exit: ExitSignal) -> Self { + self.exit = Some(exit); + self + } + + /// 追加一段待匹配文本。 + pub fn push_text(&mut self, origin: SignalOrigin, text: impl Into) { + self.haystacks.push(Haystack::new(origin, text)); + } + + /// 追加解析好的 hs_err:摘要进待匹配文本,结构留给内置结论。 + pub fn push_hs_err(&mut self, report: HsErrReport) { + self.haystacks + .push(Haystack::new(SignalOrigin::HsErr, report.digest())); + self.hs_err = Some(report); + } + + /// 是否什么信号都没有。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.exit.is_none() && self.haystacks.is_empty() + } +} + +/// 规则引擎(规则只编译一次,可跨线程共享)。 +#[derive(Debug, Clone, Default)] +pub struct Diagnoser { + rules: Vec, +} + +impl Diagnoser { + /// 用一组已编译的规则构造。 + #[must_use] + pub fn new(rules: Vec) -> Self { + Self { rules } + } + + /// 规则表。 + #[must_use] + pub fn rules(&self) -> &[CompiledRule] { + &self.rules + } + + /// 跑一遍诊断。 + #[must_use] + pub fn analyze(&self, input: &DiagnosisInput) -> DiagnosisReport { + let mut ordered = input.haystacks.iter().collect::>(); + ordered.sort_by_key(|haystack| haystack.origin.evidence_rank()); + + let mut findings = Vec::new(); + for rule in &self.rules { + for haystack in &ordered { + if let Some(found) = scan(rule, haystack) { + findings.push(build_finding(rule, haystack, &found)); + break; + } + } + } + findings.extend(synthetic_findings(input)); + + let (mut findings, mut secondary) = split_suppressed(findings); + findings.sort_by(Finding::rank_cmp); + secondary.sort_by(Finding::rank_cmp); + + DiagnosisReport { + findings, + secondary, + exit: input.exit.clone(), + rules_evaluated: self.rules.len(), + hs_err: input.hs_err.clone(), + } + } +} + +/// 一次正则命中(绝对偏移 + 命名捕获组)。 +#[derive(Debug)] +struct RawMatch { + groups: BTreeMap, + offset: usize, +} + +fn scan(rule: &CompiledRule, haystack: &Haystack) -> Option { + match rule.scope() { + MatchScope::Line => scan_lines(rule, &haystack.text), + MatchScope::Block => scan_blocks(rule, &haystack.text), + MatchScope::File => scan_file(rule, &haystack.text), + } +} + +/// 逐行匹配(返回命中行的起始偏移,行号即证据行号)。 +fn scan_lines(rule: &CompiledRule, text: &str) -> Option { + let mut offset = 0; + for chunk in text.split_inclusive('\n') { + let line = chunk.trim_end_matches(['\r', '\n']); + if let Some(caps) = rule.regex().captures(line) { + return Some(capture_groups(rule, &caps, offset)); + } + offset += chunk.len(); + } + None +} + +/// 按空行切块匹配(一段异常 + 其上下文)。 +/// +/// 分隔符是**空行**而不是字面 `"\n\n"`:日志文件可能是 CRLF(Windows 上 `latest.log` 常见), +/// 按字节找 `\n\n` 会把整份日志当成一个块,`scope: block` 的规则就退化成整份扫描。 +fn scan_blocks(rule: &CompiledRule, text: &str) -> Option { + let mut block_start = 0; + let mut offset = 0; + for chunk in text.split_inclusive('\n') { + offset += chunk.len(); + if chunk.trim().is_empty() { + if let Some(caps) = rule.regex().captures(&text[block_start..offset]) { + return Some(capture_groups(rule, &caps, block_start)); + } + block_start = offset; + } + } + if block_start < text.len() { + let caps = rule.regex().captures(&text[block_start..])?; + return Some(capture_groups(rule, &caps, block_start)); + } + None +} + +/// 整份匹配;超长文本只看首尾窗口(跨行贪婪规则的性能边界)。 +fn scan_file(rule: &CompiledRule, text: &str) -> Option { + if text.len() <= FILE_SCOPE_LIMIT { + let caps = rule.regex().captures(text)?; + let offset = caps.get(0).map_or(0, |found| found.start()); + return Some(capture_groups(rule, &caps, offset)); + } + let head_end = ceil_boundary(text, FILE_SCOPE_WINDOW); + if let Some(caps) = rule.regex().captures(&text[..head_end]) { + let offset = caps.get(0).map_or(0, |found| found.start()); + return Some(capture_groups(rule, &caps, offset)); + } + let tail_start = floor_boundary(text, text.len() - FILE_SCOPE_WINDOW); + let caps = rule.regex().captures(&text[tail_start..])?; + let offset = tail_start + caps.get(0).map_or(0, |found| found.start()); + Some(capture_groups(rule, &caps, offset)) +} + +fn capture_groups(rule: &CompiledRule, caps: ®ex::Captures<'_>, offset: usize) -> RawMatch { + let mut groups = BTreeMap::new(); + for name in rule.regex().capture_names().flatten() { + if let Some(found) = caps.name(name) { + groups.insert(name.to_owned(), found.as_str().trim().to_owned()); + } + } + RawMatch { groups, offset } +} + +fn build_finding(compiled: &CompiledRule, haystack: &Haystack, found: &RawMatch) -> Finding { + let rule = compiled.rule(); + let (message, detail) = rule.cause.render(&found.groups); + Finding { + rule_id: rule.id.clone(), + category: compiled.category(), + severity: rule.severity, + confidence: rule.confidence.clamp(0.0, 1.0), + priority: rule.priority, + message_key: rule.cause.key.clone(), + message, + detail, + evidence: vec![Evidence::new( + haystack.origin, + line_of(&haystack.text, found.offset), + snippet(&haystack.text, found.offset), + )], + fix_actions: rule.fix_action.clone(), + groups: found.groups.clone(), + suppress: rule.suppress.clone(), + suppressed_by: None, + } +} + +/// 把被 suppress 的结论拆到次生列表(不丢弃,UI 折叠展示)。 +fn split_suppressed(findings: Vec) -> (Vec, Vec) { + let suppressers = findings + .iter() + .map(|finding| { + ( + finding.rule_id.clone(), + finding.priority, + finding.suppress.clone(), + ) + }) + .collect::>(); + let mut kept = Vec::new(); + let mut suppressed = Vec::new(); + for mut finding in findings { + let mut best: Option<(&str, u8)> = None; + for (id, priority, suppress) in &suppressers { + if *id == finding.rule_id || !suppress.iter().any(|item| item == &finding.rule_id) { + continue; + } + if best.is_none_or(|(_, best_priority)| *priority > best_priority) { + best = Some((id.as_str(), *priority)); + } + } + match best { + Some((id, _)) => { + finding.suppressed_by = Some(id.to_owned()); + suppressed.push(finding); + } + None => kept.push(finding), + } + } + (kept, suppressed) +} + +fn line_of(text: &str, offset: usize) -> usize { + let offset = offset.min(text.len()); + 1 + text[..offset].matches('\n').count() +} + +fn snippet(text: &str, offset: usize) -> String { + let offset = offset.min(text.len()); + let start = text[..offset].rfind('\n').map_or(0, |index| index + 1); + let end = text[start..] + .find('\n') + .map_or(text.len(), |index| start + index); + let line = text[start..end].trim(); + let mut out = line.chars().take(EVIDENCE_MAX_CHARS).collect::(); + if line.chars().count() > EVIDENCE_MAX_CHARS { + out.push('…'); + } + out +} + +fn ceil_boundary(text: &str, mut index: usize) -> usize { + index = index.min(text.len()); + while index < text.len() && !text.is_char_boundary(index) { + index += 1; + } + index +} + +fn floor_boundary(text: &str, mut index: usize) -> usize { + index = index.min(text.len()); + while index > 0 && !text.is_char_boundary(index) { + index -= 1; + } + index +} diff --git a/crates/core/src/diagnose/environment.rs b/crates/core/src/diagnose/environment.rs new file mode 100644 index 0000000..a40854d --- /dev/null +++ b/crates/core/src/diagnose/environment.rs @@ -0,0 +1,155 @@ +//! 环境快照与 mod 清单(analyse/02 §7.1 信号 E)。 +//! +//! HMCL 只在崩溃窗口里散着展示这些信息;我们做成结构化数据, +//! 既进诊断输出(`environment.json` 一样的机器可读上下文),也让规则能按版本号做前置判断。 +//! 由 `collectors.rs` 用 `#[path]` 声明并统一导出。 + +use std::path::PathBuf; + +/// 一个已装组件的版本(Forge/Fabric/OptiFine…)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ComponentVersion { + /// 组件名(`forge`/`fabric`/`optifine`/`minecraft`…)。 + pub name: String, + /// 版本串。 + pub version: String, +} + +impl ComponentVersion { + /// 构造。 + #[must_use] + pub fn new(name: impl Into, version: impl Into) -> Self { + Self { + name: name.into(), + version: version.into(), + } + } +} + +/// 一个已装 mod(供交叉验证「冲突/mixin 失败」类结论,analyse/02 §8.4-10)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ModRecord { + /// modid(解析不出时为空)。 + pub id: String, + /// 展示名。 + pub name: Option, + /// 版本。 + pub version: Option, + /// jar 文件名(诊断包里给人看)。 + pub file: Option, + /// 是否启用(`.disabled` 视为未启用)。 + pub enabled: bool, +} + +impl ModRecord { + /// 从文件名构造(不解 jar,P0 足够:冲突类结论要的是「装了哪些文件」)。 + #[must_use] + pub fn from_file_name(file: &str) -> Self { + let enabled = !file.ends_with(".disabled"); + let stem = file + .trim_end_matches(".disabled") + .trim_end_matches(".jar") + .to_owned(); + let (name, version) = stem + .rsplit_once('-') + .filter(|(_, version)| version.chars().next().is_some_and(|ch| ch.is_ascii_digit())) + .map_or((stem.clone(), None), |(name, version)| { + (name.to_owned(), Some(version.to_owned())) + }); + Self { + id: name.to_lowercase(), + name: Some(name), + version, + file: Some(file.to_owned()), + enabled, + } + } +} + +/// 启动环境快照(Java/内存/OS/组件/mod 清单)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct EnvironmentSnapshot { + /// 启动器版本(`ull 0.1.0`)。 + pub launcher: Option, + /// 实例 id。 + pub instance_id: Option, + /// 游戏版本。 + pub game_version: Option, + /// 操作系统名(`Windows 10.0.19045`)。 + pub os_name: Option, + /// 架构(`x86_64` / `aarch64`)。 + pub os_arch: Option, + /// 物理内存(MB)。 + pub physical_memory_mb: Option, + /// Java 可执行文件。 + pub java_path: Option, + /// Java 大版本。 + pub java_major: Option, + /// Java 位数(32 / 64)。 + pub java_bits: Option, + /// `-Xmx`(MB)。 + pub xmx_mb: Option, + /// `-Xms`(MB)。 + pub xms_mb: Option, + /// 工作目录(跑游戏的 cwd)。 + pub run_dir: Option, + /// 已装组件版本。 + pub components: Vec, + /// 已装 mod。 + pub mods: Vec, +} + +impl EnvironmentSnapshot { + /// 一行人类可读摘要(证据行 / UI 头部)。 + #[must_use] + pub fn summary(&self) -> String { + let os = format!( + "{} {}", + self.os_name.as_deref().unwrap_or("未知系统"), + self.os_arch.as_deref().unwrap_or("") + ); + let java = self.java_major.map_or_else( + || "Java 未知".to_owned(), + |major| match self.java_bits { + Some(bits) => format!("Java {major}({bits} 位)"), + None => format!("Java {major}"), + }, + ); + let memory = match (self.xmx_mb, self.physical_memory_mb) { + (Some(xmx), Some(physical)) => format!("-Xmx {xmx} MB / 物理 {physical} MB"), + (Some(xmx), None) => format!("-Xmx {xmx} MB"), + (None, Some(physical)) => format!("物理 {physical} MB"), + (None, None) => "内存未知".to_owned(), + }; + let mods = self.mods.len(); + format!("{} / {} / {} / {} 个 mod", os.trim(), java, memory, mods) + } + + /// 是不是 32 位 Java(`-Xmx` 超过 ~1.5 GB 必崩的直接证据)。 + #[must_use] + pub fn is_java_32bit(&self) -> bool { + self.java_bits == Some(32) + } + + /// 组件版本(按名字查,`optifine` 之类的冲突规则用)。 + #[must_use] + pub fn component(&self, name: &str) -> Option<&str> { + self.components + .iter() + .find(|component| component.name.eq_ignore_ascii_case(name)) + .map(|component| component.version.as_str()) + } + + /// 是否装了某个 mod(按 id 或文件名模糊匹配)。 + #[must_use] + pub fn has_mod(&self, id: &str) -> bool { + let needle = id.to_ascii_lowercase(); + self.mods.iter().any(|record| { + record.id.contains(&needle) + || record + .file + .as_deref() + .is_some_and(|file| file.to_ascii_lowercase().contains(&needle)) + }) + } +} diff --git a/crates/core/src/diagnose/finding.rs b/crates/core/src/diagnose/finding.rs new file mode 100644 index 0000000..0b5388f --- /dev/null +++ b/crates/core/src/diagnose/finding.rs @@ -0,0 +1,256 @@ +//! 结论与报告的数据结构(纯数据,可序列化,不持有匹配器状态)。 +//! +//! HMCL 的 `CrashReportAnalyzer.Result` 直接把 `Matcher` 传出去(不可序列化、不能跨线程), +//! 这里改成「证据 + 已渲染文案 + 物化动作」的结构化记录(analyse/02 §7.10-6)。 +//! +//! 由 `engine.rs` 用 `#[path]` 声明并统一导出;拆分只为守单文件 400 行上限。 + +use std::collections::BTreeMap; + +use serde::Serialize; + +use crate::diagnose::collectors::{ExitSignal, SignalOrigin}; +use crate::diagnose::hs_err::HsErrReport; +use crate::diagnose::rules::{FixAction, MaterializedFixAction, RuleCategory, Severity}; + +/// 「结论足够确定」的可信度门槛:低于它就该交给 AI 深化(PlanDoc/06 §2.3)。 +pub const CONCLUSIVE_CONFIDENCE: f32 = 0.8; + +/// 一条证据(哪个来源的第几行的哪段文本)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Evidence { + /// 来源。 + pub origin: SignalOrigin, + /// 行号(1 起;结构化信号为 0)。 + pub line: usize, + /// 文本片段(≤300 字符)。 + pub text: String, +} + +impl Evidence { + /// 构造。 + #[must_use] + pub fn new(origin: SignalOrigin, line: usize, text: impl Into) -> Self { + Self { + origin, + line, + text: text.into(), + } + } + + /// 诊断输出里的字符串形式:`": "`。 + #[must_use] + pub fn render(&self) -> String { + format!("{}:{} {}", self.origin, self.line, self.text) + } +} + +/// 一条结论。 +#[derive(Debug, Clone)] +pub struct Finding { + /// 规则 id(内置结论用 `signal.*` / `hs_err.*`)。 + pub rule_id: String, + /// 分类。 + pub category: RuleCategory, + /// 严重度。 + pub severity: Severity, + /// 可信度。 + pub confidence: f32, + /// 主因权重。 + pub priority: u8, + /// i18n / AI 引用 id。 + pub message_key: String, + /// 渲染后的中文结论(M16 `diagnose_crash` 直接给模型看)。 + pub message: String, + /// 细化说明 / 处理建议。 + pub detail: Option, + /// 证据。 + pub evidence: Vec, + /// 六维修复动作。 + pub fix_actions: Vec, + /// 规则捕获组(供动作模板替换)。 + pub groups: BTreeMap, + /// 本结论会抑制哪些规则 id。 + pub suppress: Vec, + /// 被哪条结论抑制(`Some` 即次生结论)。 + pub suppressed_by: Option, +} + +impl Finding { + /// 一条只有骨架的结论(内置结论的构造起点,其余字段由调用方填)。 + #[must_use] + pub fn skeleton( + rule_id: &str, + category: RuleCategory, + severity: Severity, + confidence: f32, + priority: u8, + message_key: &str, + message: &str, + ) -> Self { + Self { + rule_id: rule_id.to_owned(), + category, + severity, + confidence, + priority, + message_key: message_key.to_owned(), + message: message.to_owned(), + detail: None, + evidence: Vec::new(), + fix_actions: Vec::new(), + groups: BTreeMap::new(), + suppress: Vec::new(), + suppressed_by: None, + } + } + + /// 物化后的修复动作(模板已替换)。 + #[must_use] + pub fn materialized_actions(&self) -> Vec { + self.fix_actions + .iter() + .map(|action| action.materialize(&self.groups)) + .collect() + } + + /// 转成诊断输出里的候选。 + #[must_use] + pub fn to_candidate(&self) -> Candidate { + Candidate { + id: self.rule_id.clone(), + severity: self.severity.as_str(), + category: self.category.as_str(), + confidence: self.confidence, + message_key: self.message_key.clone(), + message: self.message.clone(), + detail: self.detail.clone(), + evidence: self.evidence.iter().map(Evidence::render).collect(), + fix_actions: self.materialized_actions(), + } + } + + /// 排序:priority 高 → confidence 高 → id 字典序(保证确定性)。 + #[must_use] + pub fn rank_cmp(&self, other: &Self) -> std::cmp::Ordering { + other + .priority + .cmp(&self.priority) + .then_with(|| other.confidence.total_cmp(&self.confidence)) + .then_with(|| self.rule_id.cmp(&other.rule_id)) + } +} + +/// 诊断输出里的一条候选结论(字段名与 `launch-接口约定.md` §3 `diagnosis` 对齐)。 +#[derive(Debug, Clone, Serialize)] +pub struct Candidate { + /// 规则 id。 + pub id: String, + /// 严重度。 + pub severity: &'static str, + /// 分类。 + pub category: &'static str, + /// 可信度。 + pub confidence: f32, + /// i18n / AI 引用 id。 + pub message_key: String, + /// 渲染后的中文结论。 + pub message: String, + /// 细化说明。 + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + /// 证据字符串。 + pub evidence: Vec, + /// 六维修复动作(M16/UI 一键修复入口)。 + pub fix_actions: Vec, +} + +/// 退出信号摘要(诊断输出用)。 +#[derive(Debug, Clone, Serialize)] +pub struct ExitRecord { + /// 退出码。 + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + /// 退出类型(与 launch 约定同值)。 + pub exit_type: &'static str, + /// 疑似静默失败。 + pub silent_failure: bool, + /// 存活毫秒数。 + #[serde(skip_serializing_if = "Option::is_none")] + pub uptime_ms: Option, +} + +/// 交给 launch 写入 `diagnosis` 事件的结构化结果。 +#[derive(Debug, Clone, Serialize)] +pub struct DiagnosisRecord { + /// 结论来源(`rules` / `ai`)。 + pub source: &'static str, + /// 规则没把握、需要 AI 或用户补充信息。 + pub needs_more_info: bool, + /// 结论(`candidates[0]` 是主因)。 + pub candidates: Vec, + /// 被抑制的次生结论。 + pub secondary: Vec, + /// 本次判定用的退出信号摘要。 + #[serde(skip_serializing_if = "Option::is_none")] + pub exit: Option, +} + +/// 一次诊断的完整结果。 +#[derive(Debug, Clone, Default)] +pub struct DiagnosisReport { + /// 结论(已排序,主因在前)。 + pub findings: Vec, + /// 被抑制的次生结论。 + pub secondary: Vec, + /// 本次判定用的退出信号。 + pub exit: Option, + /// 评估了多少条规则(可观测性)。 + pub rules_evaluated: usize, + /// 解析出的 hs_err(若有)。 + pub hs_err: Option, +} + +impl DiagnosisReport { + /// 主因(可能没有)。 + #[must_use] + pub fn primary(&self) -> Option<&Finding> { + self.findings.first() + } + + /// 规则层有没有把握:没有任何结论,或主因可信度 < [`CONCLUSIVE_CONFIDENCE`]。 + #[must_use] + pub fn needs_more_info(&self) -> bool { + self.primary() + .is_none_or(|finding| finding.confidence < CONCLUSIVE_CONFIDENCE) + } + + /// 去重后的修复动作(主因优先,供 UI 一键修复 / agent 直接执行)。 + #[must_use] + pub fn fix_actions(&self) -> Vec { + let mut out: Vec = Vec::new(); + for action in self.findings.iter().flat_map(Finding::materialized_actions) { + if !out.contains(&action) { + out.push(action); + } + } + out + } + + /// 转成可序列化的诊断记录。 + #[must_use] + pub fn to_record(&self) -> DiagnosisRecord { + DiagnosisRecord { + source: "rules", + needs_more_info: self.needs_more_info(), + candidates: self.findings.iter().map(Finding::to_candidate).collect(), + secondary: self.secondary.iter().map(Finding::to_candidate).collect(), + exit: self.exit.as_ref().map(|exit| ExitRecord { + code: exit.code, + exit_type: exit.exit_type.as_str(), + silent_failure: exit.silent_failure, + uptime_ms: exit.uptime_ms, + }), + } + } +} diff --git a/crates/core/src/diagnose/fix_action.rs b/crates/core/src/diagnose/fix_action.rs new file mode 100644 index 0000000..2be9b00 --- /dev/null +++ b/crates/core/src/diagnose/fix_action.rs @@ -0,0 +1,213 @@ +//! 六维修复动作本体:枚举、维度、以及「代入捕获组 → agent 工具参数」的物化。 +//! +//! 六个维度与 analyse/02 §7.7 的动作分类一一对应,也是 M16 `ToolSpec` 的参数来源 +//! (契约见同目录 `CONTRACT.md` §3)。 + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::fix_params::{AdjustMemory, ChangeGraphics, ChangeJava, Reinstall, RemoveMod, SeekHelp}; +use super::substitute; + +/// 六维修复动作(YAML 里 `kind:` 选维度)。 +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum FixAction { + /// 换 Java。 + ChangeJava(ChangeJava), + /// 调内存。 + AdjustMemory(AdjustMemory), + /// 改图形设置。 + ChangeGraphics(ChangeGraphics), + /// 删/禁/换 mod。 + RemoveMod(RemoveMod), + /// 重装/修组件。 + Reinstall(Reinstall), + /// 求助上报。 + SeekHelp(SeekHelp), +} + +/// 六维动作的维度枚举(不含参数,便于判等/去重/映射工具)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FixActionKind { + /// 换 Java。 + ChangeJava, + /// 调内存。 + AdjustMemory, + /// 改图形设置。 + ChangeGraphics, + /// 删/禁/换 mod。 + RemoveMod, + /// 重装/修组件。 + Reinstall, + /// 求助上报。 + SeekHelp, +} + +impl FixActionKind { + /// 稳定字符串(诊断输出的 `kind` 字段,与 YAML 的 `kind:` 一致)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ChangeJava => "change_java", + Self::AdjustMemory => "adjust_memory", + Self::ChangeGraphics => "change_graphics", + Self::RemoveMod => "remove_mod", + Self::Reinstall => "reinstall", + Self::SeekHelp => "seek_help", + } + } + + /// 建议的 agent 工具 id。 + /// + /// M16 的 P0 工具只有 5 个(`get_config`/`set_config`/`list_game_sessions`/`read_game_log`/ + /// `diagnose_crash`),因此它按 `kind` 桥接;这里的 id 是 P1 落地细分工具时的直接映射。 + #[must_use] + pub const fn agent_tool(self) -> &'static str { + match self { + Self::ChangeJava => "java.set", + Self::AdjustMemory => "memory.set", + Self::ChangeGraphics => "graphics.set", + Self::RemoveMod => "mod.remove", + Self::Reinstall => "component.reinstall", + Self::SeekHelp => "diagnostics.report", + } + } + + /// 全部六维(测试与 UI 遍历用)。 + #[must_use] + pub const fn all() -> [Self; 6] { + [ + Self::ChangeJava, + Self::AdjustMemory, + Self::ChangeGraphics, + Self::RemoveMod, + Self::Reinstall, + Self::SeekHelp, + ] + } +} + +/// 已做模板替换/单位换算的动作,可直接当 agent 工具参数。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MaterializedFixAction { + /// 维度。 + pub kind: FixActionKind, + /// agent 工具 id(见 [`FixActionKind::agent_tool`])。 + pub tool: &'static str, + /// 扁平字符串参数表。 + pub args: BTreeMap, +} + +impl FixAction { + /// 维度。 + #[must_use] + pub const fn kind(&self) -> FixActionKind { + match self { + Self::ChangeJava(_) => FixActionKind::ChangeJava, + Self::AdjustMemory(_) => FixActionKind::AdjustMemory, + Self::ChangeGraphics(_) => FixActionKind::ChangeGraphics, + Self::RemoveMod(_) => FixActionKind::RemoveMod, + Self::Reinstall(_) => FixActionKind::Reinstall, + Self::SeekHelp(_) => FixActionKind::SeekHelp, + } + } + + /// 代入规则命名捕获组,产出 agent 工具可直接用的参数表。 + #[must_use] + pub fn materialize(&self, groups: &BTreeMap) -> MaterializedFixAction { + let kind = self.kind(); + let mut args = BTreeMap::new(); + match self { + Self::ChangeJava(p) => { + let derived = p + .class_file_version_group + .as_ref() + .and_then(|group| groups.get(group)) + .and_then(|raw| raw.trim().parse::().ok()) + .and_then(class_file_to_java_major); + put_opt(&mut args, "required_major", p.required_major.or(derived)); + put_opt(&mut args, "max_major", p.max_major); + put_str(&mut args, "vendor", p.vendor.as_str()); + put_opt_str(&mut args, "note", render_opt(&p.note, groups)); + } + Self::AdjustMemory(p) => { + put_opt(&mut args, "suggested_xmx_mb", p.suggested_xmx_mb); + put_opt(&mut args, "min_xmx_mb", p.min_xmx_mb); + put_bool(&mut args, "need_64bit_java", p.need_64bit_java); + put_bool(&mut args, "increase_system_swap", p.increase_system_swap); + put_opt_str(&mut args, "note", render_opt(&p.note, groups)); + } + Self::ChangeGraphics(p) => { + put_str(&mut args, "setting", p.setting.as_str()); + put_bool(&mut args, "update_driver", p.update_driver); + put_opt_str(&mut args, "suggested_value", p.suggested_value.clone()); + put_opt_str(&mut args, "note", render_opt(&p.note, groups)); + } + Self::RemoveMod(p) => { + let ids = p + .mod_ids + .iter() + .map(|id| substitute(id, groups)) + .collect::>(); + if !ids.is_empty() { + args.insert("mod_ids".to_owned(), ids.join(",")); + } + put_str(&mut args, "action", p.action.as_str()); + put_opt_str(&mut args, "note", render_opt(&p.note, groups)); + } + Self::Reinstall(p) => { + put_str(&mut args, "component", p.component.as_str()); + put_opt_str(&mut args, "path_hint", render_opt(&p.path_hint, groups)); + put_opt_str(&mut args, "note", render_opt(&p.note, groups)); + } + Self::SeekHelp(p) => { + put_bool(&mut args, "export_diagnostics", p.export_diagnostics); + if !p.needs_more_info.is_empty() { + args.insert("needs_more_info".to_owned(), p.needs_more_info.join("; ")); + } + put_opt_str(&mut args, "note", render_opt(&p.note, groups)); + } + } + MaterializedFixAction { + kind, + tool: kind.agent_tool(), + args, + } + } +} + +/// 可选的模板字段:`Some` 时做 `{group}` 替换,`None` 保持 `None`。 +fn render_opt(value: &Option, groups: &BTreeMap) -> Option { + value.as_ref().map(|text| substitute(text, groups)) +} + +fn put_str(args: &mut BTreeMap, key: &str, value: &str) { + args.insert(key.to_owned(), value.to_owned()); +} + +fn put_opt_str(args: &mut BTreeMap, key: &str, value: Option) { + if let Some(value) = value { + args.insert(key.to_owned(), value); + } +} + +fn put_opt(args: &mut BTreeMap, key: &str, value: Option) { + if let Some(value) = value { + args.insert(key.to_owned(), value.to_string()); + } +} + +fn put_bool(args: &mut BTreeMap, key: &str, value: bool) { + args.insert(key.to_owned(), value.to_string()); +} + +/// `class file version 52` → `Java 8`(HMCL `getJavaVersionFromMajorVersion` 同式)。 +#[must_use] +pub fn class_file_to_java_major(class_file_version: u32) -> Option { + class_file_version + .checked_sub(44) + .filter(|major| *major > 0) +} diff --git a/crates/core/src/diagnose/fix_params.rs b/crates/core/src/diagnose/fix_params.rs new file mode 100644 index 0000000..3b4b891 --- /dev/null +++ b/crates/core/src/diagnose/fix_params.rs @@ -0,0 +1,252 @@ +//! 六维修复动作的参数类型(`fix_action.rs` 的载荷 + 几个小枚举)。 +//! +//! 由 `rules.rs` 用 `#[path]` 声明并统一导出;文件拆分只为守 400 行上限。 + +use serde::Deserialize; + +/// 建议的 JVM 实现。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JavaVendor { + /// 不限定(HotSpot 优先)。 + #[default] + Any, + /// 必须 HotSpot(OpenJ9 等不兼容实现的反面)。 + Hotspot, +} + +impl JavaVendor { + /// 稳定字符串。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Any => "any", + Self::Hotspot => "hotspot", + } + } +} + +/// 图形侧要修的那一类设置。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GraphicsSetting { + /// 不指定具体项,只表达「图形相关」。 + #[default] + Any, + /// 渲染器(OpenGL 核心/兼容、ANGLE、软渲染)。 + Renderer, + /// 显卡驱动。 + Driver, + /// 图形 API(MC 26.2+ 的「默认 / 优先 Vulkan / 优先 OpenGL」三选一)。 + GraphicsApi, + /// 分辨率 / 资源包分辨率。 + Resolution, + /// 覆盖层类软件(RTSS、Discord 等)。 + Overlay, + /// 软件渲染兜底。 + SoftwareRenderer, +} + +impl GraphicsSetting { + /// 稳定字符串。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Any => "any", + Self::Renderer => "renderer", + Self::Driver => "driver", + Self::GraphicsApi => "graphics_api", + Self::Resolution => "resolution", + Self::Overlay => "overlay", + Self::SoftwareRenderer => "software_renderer", + } + } +} + +/// 对 mod 的处理方式。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ModAction { + /// 移出 mods 目录。 + #[default] + Remove, + /// 暂时禁用(挪到 `.disabled`)。 + Disable, + /// 换一个版本 / 换替代品。 + Replace, + /// 升级到兼容版本。 + Upgrade, +} + +impl ModAction { + /// 稳定字符串。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Remove => "remove", + Self::Disable => "disable", + Self::Replace => "replace", + Self::Upgrade => "upgrade", + } + } +} + +/// 要重装/修复的组件。粒度对齐 download/game 侧的组件概念,便于 agent 直接调用。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComponentKind { + /// Java 运行时本身。 + Java, + /// 游戏主 jar。 + GameJar, + /// 版本清单(version JSON)。 + GameJson, + /// 未指定具体 loader。 + Loader, + /// Fabric loader。 + Fabric, + /// Forge。 + Forge, + /// NeoForge。 + #[serde(rename = "neoforge")] + NeoForge, + /// OptiFine。 + #[serde(rename = "optifine")] + OptiFine, + /// natives 原生库。 + Natives, + /// mods 目录(含「jar 被解压成目录」的修复)。 + Mods, + /// libraries 库文件。 + Libraries, + /// assets 资源。 + Assets, + /// MixinBootstrap 等前置。 + MixinBootstrap, +} + +impl ComponentKind { + /// 稳定字符串。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Java => "java", + Self::GameJar => "game_jar", + Self::GameJson => "game_json", + Self::Loader => "loader", + Self::Fabric => "fabric", + Self::Forge => "forge", + Self::NeoForge => "neoforge", + Self::OptiFine => "optifine", + Self::Natives => "natives", + Self::Mods => "mods", + Self::Libraries => "libraries", + Self::Assets => "assets", + Self::MixinBootstrap => "mixin_bootstrap", + } + } +} + +/// 换 Java 的参数。 +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ChangeJava { + /// 建议的大版本(17 / 21 …)。 + #[serde(default)] + pub required_major: Option, + /// 允许的最高大版本(老 Forge 只能用 Java 8 时给 `8`)。 + #[serde(default)] + pub max_major: Option, + /// 从该命名捕获组取 `class file version N`,按 `N - 44` 换算成 Java 大版本。 + #[serde(default)] + pub class_file_version_group: Option, + /// JVM 实现偏好。 + #[serde(default)] + pub vendor: JavaVendor, + /// 给用户看的补充说明。 + #[serde(default)] + pub note: Option, +} + +/// 调内存的参数。 +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdjustMemory { + /// 建议的 `-Xmx`(MB);规则不确定时留空,由 UI 按物理内存算。 + #[serde(default)] + pub suggested_xmx_mb: Option, + /// `-Xmx` 的下限(MB)。 + #[serde(default)] + pub min_xmx_mb: Option, + /// 需要 32 位 → 64 位 Java(「Could not reserve enough space」类)。 + #[serde(default)] + pub need_64bit_java: bool, + /// 需要用户扩大系统虚拟内存 / 关掉占内存的程序。 + #[serde(default)] + pub increase_system_swap: bool, + /// 补充说明。 + #[serde(default)] + pub note: Option, +} + +/// 改图形设置的参数。 +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ChangeGraphics { + /// 要改的设置类别。 + #[serde(default)] + pub setting: GraphicsSetting, + /// 建议更新显卡驱动。 + #[serde(default)] + pub update_driver: bool, + /// 建议的目标值(如 `854x480`、`OpenGL 兼容模式`)。 + #[serde(default)] + pub suggested_value: Option, + /// 补充说明。 + #[serde(default)] + pub note: Option, +} + +/// 删/禁/换 mod 的参数。 +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RemoveMod { + /// modid 列表,允许 `{group}` 模板(由规则命名捕获组代入)。 + #[serde(default)] + pub mod_ids: Vec, + /// 处理方式。 + #[serde(default)] + pub action: ModAction, + /// 补充说明。 + #[serde(default)] + pub note: Option, +} + +/// 重装/修组件的参数。 +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Reinstall { + /// 要修的组件。 + pub component: ComponentKind, + /// 出问题的路径提示(来自规则捕获组或原文)。 + #[serde(default)] + pub path_hint: Option, + /// 补充说明。 + #[serde(default)] + pub note: Option, +} + +/// 求助/上报的参数。 +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SeekHelp { + /// 建议导出诊断包(日志 + 崩溃报告 + 清单 + 环境)。 + #[serde(default)] + pub export_diagnostics: bool, + /// 还需要用户补充什么(「把日志上限调到 20000 行再复现」)。 + #[serde(default)] + pub needs_more_info: Vec, + /// 补充说明。 + #[serde(default)] + pub note: Option, +} diff --git a/crates/core/src/diagnose/hs_err.rs b/crates/core/src/diagnose/hs_err.rs new file mode 100644 index 0000000..24e4ae5 --- /dev/null +++ b/crates/core/src/diagnose/hs_err.rs @@ -0,0 +1,298 @@ +//! `hs_err_pid*.log`(JVM 致命错误日志)解析器。 +//! +//! HMCL 完全不解析它(analyse/02 §7.10-8),而 `EXCEPTION_ACCESS_VIOLATION` 这类问题 +//! 只有这份文件能给出权威证据(失败帧落在显卡驱动 DLL 里就是驱动问题)。 +//! 格式稳定:**标题行 + 线程段 + 内存段**,本模块只做容错抽取,字段全部 `Option`,永不失败。 +//! +//! 用法(launch 对接见 `CONTRACT.md` §5): +//! - `parse(&text)`:已有全文时用(首选,避免二次截断); +//! - `parse_file(&path)`:给路径时用; +//! - [`HsErrReport::digest`] 产出的摘要既是规则匹配的文本,也是给它人看的证据。 + +use std::fs; +use std::io; +use std::path::Path; + +#[path = "hs_err_report.rs"] +mod report; + +pub use report::{FailedFrame, FrameKind, HsErrKind, HsErrReport, MemorySummary}; + +/// 解析文本(永不失败;无法识别的行直接跳过)。 +#[must_use] +pub fn parse(text: &str) -> HsErrReport { + let mut report = HsErrReport::default(); + let lines = text.lines().collect::>(); + + for (index, raw) in lines.iter().enumerate() { + let line = raw.trim_end_matches('\r'); + let body = line.trim_start_matches('#').trim(); + if report.header.is_none() && is_header(body) { + report.header = Some(body.to_owned()); + } + if report.exception.is_none() { + report.exception = extract_exception(body); + } + if report.siginfo.is_none() && body.starts_with("siginfo:") { + report.siginfo = Some(body.to_owned()); + } + if report.signal.is_none() { + report.signal = body + .contains("si_signo:") + .then(|| body.to_owned()) + .or_else(|| extract_signal(body)); + } + if report.jre_version.is_none() && body.contains("JRE version:") { + report.jre_version = Some(body.to_owned()); + } + if report.java_vm.is_none() && body.contains("Java VM:") { + report.java_vm = Some(body.to_owned()); + } + if report.os.is_none() && body.starts_with("OS:") { + report.os = Some(body.to_owned()); + } + if report.command_line.is_none() && body.starts_with("java_command:") { + report.command_line = Some(body.to_owned()); + } + if report.current_thread.is_none() && body.starts_with("Current thread (") { + report.current_thread = Some(body.to_owned()); + } + if report.memory.is_none() && body.starts_with("Memory:") { + report.memory = parse_memory(body); + } + if report.failed_frame.is_none() && body.eq_ignore_ascii_case("Problematic frame:") { + report.failed_frame = lines + .iter() + .skip(index + 1) + .map(|next| next.trim_end_matches('\r')) + .find(|next| !next.trim().is_empty() && next.trim() != "#") + .map(|next| parse_frame(next.trim_start_matches('#').trim())); + } + if report.pc.is_none() && body.contains("pc=") { + report.pc = field(body, "pc="); + } + if report.pid.is_none() && body.contains("pid=") { + report.pid = field(body, "pid=").and_then(|raw| raw.parse::().ok()); + } + if report.tid.is_none() && body.contains("tid=") { + report.tid = field(body, "tid="); + } + if body.eq_ignore_ascii_case("VM Arguments:") { + // 真实 hs_err 里 jvm_args/java_command 是顶格写的,不缩进 + report.vm_arguments = collect_unindented(&lines, index + 1, 32); + } + if body.starts_with("Heap:") { + // "Heap:" 之后的缩进行才是内容(garbage-first heap total … used …) + report.heap = collect_block(&lines, index + 1, 6); + } + } + + report.header_lines = collect_header_block(&lines); + report.pc = report + .exception + .as_deref() + .and_then(|line| field(line, "pc=")) + .or(report.pc); + // pid/tid 在「异常行」和「Out of Memory Error (os_*.cpp:NNN), pid=…」两种标题里都出现, + // 所以两种形态都认,先到先得。 + if report.pid.is_none() { + report.pid = report + .exception + .as_deref() + .and_then(|line| field(line, "pid=")) + .and_then(|raw| raw.parse::().ok()); + } + if report.tid.is_none() { + report.tid = report + .exception + .as_deref() + .and_then(|line| field(line, "tid=")); + } + report.kind = classify(&report); + report +} + +/// 读文件并解析(非 UTF-8 字节按 U+FFFD 替换,不报错)。 +pub fn parse_file(path: impl AsRef) -> io::Result { + let path = path.as_ref(); + let bytes = fs::read(path)?; + let text = String::from_utf8_lossy(&bytes); + let mut report = parse(&text); + report.path = Some(path.to_path_buf()); + Ok(report) +} + +fn is_header(body: &str) -> bool { + const MARKERS: [&str; 5] = [ + "insufficient memory", + "A fatal error has been detected", + "Out of Memory Error", + "Internal Error", + "SIGSEGV", + ]; + MARKERS.iter().any(|marker| body.contains(marker)) +} + +fn extract_exception(body: &str) -> Option { + let marker = "EXCEPTION_"; + let start = body.find(marker)?; + let rest = &body[start..]; + let end = rest + .find(" at pc=") + .or_else(|| rest.find(", pid=")) + .unwrap_or(rest.len()); + Some(rest[..end].trim().to_owned()) +} + +fn extract_signal(body: &str) -> Option { + let start = body.find("SIG")?; + let rest = &body[start..]; + let end = rest.find(')').map_or(rest.len(), |index| index + 1); + Some(rest[..end].trim().to_owned()) +} + +/// 从 `key=value` 形态里取值(到下一个空格/逗号为止)。 +fn field(line: &str, key: &str) -> Option { + let start = line.find(key)? + key.len(); + let rest = &line[start..]; + let end = rest.find([' ', ',', ']']).unwrap_or(rest.len()); + let value = rest[..end].trim(); + (!value.is_empty()).then(|| value.to_owned()) +} + +fn parse_frame(raw: &str) -> FailedFrame { + let kind = match raw.chars().next() { + Some('C') => FrameKind::Native, + Some('J' | 'j') => FrameKind::Java, + Some('V') => FrameKind::VmStub, + _ => FrameKind::Unknown, + }; + let (module, offset) = raw + .find('[') + .and_then(|start| { + let inner = &raw[start + 1..]; + let inner = &inner[..inner.find(']')?]; + let mut parts = inner.split('+'); + let module = parts.next().map(str::trim).filter(|m| !m.is_empty())?; + Some(( + Some(module.to_owned()), + parts.next().map(|o| o.trim().to_owned()), + )) + }) + .unwrap_or((None, None)); + FailedFrame { + raw: raw.to_owned(), + kind, + module, + offset, + } +} + +fn parse_memory(body: &str) -> Option { + let mut summary = MemorySummary { + raw: body.to_owned(), + ..MemorySummary::default() + }; + for part in body.split(',') { + let part = part.trim(); + if part.contains("page") { + summary.page_size = part + .trim_start_matches("Memory:") + .split_whitespace() + .next() + .map(|value| value.trim_end_matches(',').to_owned()); + } else if part.starts_with("physical") { + let (total, free) = parse_kb_pair(part); + summary.physical_kb = total; + summary.physical_free_kb = free; + } else if part.starts_with("swap") { + let (total, free) = parse_kb_pair(part); + summary.swap_kb = total; + summary.swap_free_kb = free; + } + } + Some(summary) +} + +/// `physical 16741480k(4716856k free)` → (16741480, 4716856)。 +fn parse_kb_pair(part: &str) -> (Option, Option) { + let numbers = part + .split(|ch: char| !ch.is_ascii_digit()) + .filter(|token| !token.is_empty()) + .filter_map(|token| token.parse::().ok()) + .collect::>(); + (numbers.first().copied(), numbers.get(1).copied()) +} + +/// 取从 `start` 开始、到第一个空行为止的连续行(VM Arguments / java_command 段)。 +fn collect_unindented(lines: &[&str], start: usize, limit: usize) -> Vec { + lines + .iter() + .skip(start) + .map(|line| line.trim_end_matches('\r')) + .take_while(|line| !line.trim().is_empty()) + .take(limit) + .map(|line| line.trim().to_owned()) + .collect() +} + +/// 取从 `start` 开始、以空白/制表缩进的连续行(最多 `limit` 行)。 +fn collect_block(lines: &[&str], start: usize, limit: usize) -> Vec { + lines + .iter() + .skip(start) + .map(|line| line.trim_end_matches('\r')) + .skip_while(|line| line.trim().is_empty()) + .take_while(|line| line.starts_with([' ', '\t'])) + .take(limit) + .map(|line| line.trim().to_owned()) + .collect() +} + +/// 文件开头的 `#` 注释块(真实 hs_err 的标题/异常/失败帧都在这里)。 +fn collect_header_block(lines: &[&str]) -> Vec { + let mut out = Vec::new(); + for line in lines { + let line = line.trim_end_matches('\r'); + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + if trimmed.starts_with('#') { + out.push(trimmed.to_owned()); + } + } else { + break; + } + if out.len() >= 64 { + break; + } + } + out +} + +fn classify(report: &HsErrReport) -> HsErrKind { + let haystack = [ + report.header.as_deref(), + report.exception.as_deref(), + report.siginfo.as_deref(), + ] + .into_iter() + .flatten() + .collect::>() + .join("\n"); + if haystack.contains("EXCEPTION_ACCESS_VIOLATION") || haystack.contains("SIGSEGV") { + HsErrKind::AccessViolation + } else if haystack.contains("insufficient memory") + || haystack.contains("Out of Memory") + || haystack.contains("OutOfMemory") + { + HsErrKind::OutOfMemory + } else if haystack.contains("StackOverflow") || haystack.contains("stack overflow") { + HsErrKind::StackOverflow + } else if haystack.contains("Internal Error") + || haystack.contains("A fatal error has been detected") + { + HsErrKind::InternalError + } else { + HsErrKind::Other + } +} diff --git a/crates/core/src/diagnose/hs_err_report.rs b/crates/core/src/diagnose/hs_err_report.rs new file mode 100644 index 0000000..a4bb49b --- /dev/null +++ b/crates/core/src/diagnose/hs_err_report.rs @@ -0,0 +1,210 @@ +//! `hs_err_pid*.log` 解析结果的数据结构(标题行 / 线程段 / 内存段 / 失败帧)。 +//! +//! 由 `hs_err.rs` 用 `#[path]` 声明并重新导出;拆分只为守单文件 400 行上限。 + +use std::fmt; +use std::path::PathBuf; + +/// hs_err 的崩溃类别(标题行/异常名归并成少数几类,供内置结论判定)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum HsErrKind { + /// `EXCEPTION_ACCESS_VIOLATION` / `SIGSEGV`:野指针,显卡驱动/原生库最常见。 + AccessViolation, + /// 内存不足(`insufficient memory` / `Out of Memory Error`)。 + OutOfMemory, + /// 栈溢出。 + StackOverflow, + /// JVM 内部致命错误(`Internal Error` / `A fatal error has been detected`)。 + InternalError, + /// 其它 / 无法归类。 + #[default] + Other, +} + +impl HsErrKind { + /// 稳定字符串。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::AccessViolation => "access_violation", + Self::OutOfMemory => "out_of_memory", + Self::StackOverflow => "stack_overflow", + Self::InternalError => "internal_error", + Self::Other => "other", + } + } +} + +impl fmt::Display for HsErrKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// 失败帧的类型(`# C` 原生码、`# J`/`# j` Java 帧、`# V` VM 生成桩帧)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FrameKind { + /// 原生(C/C++)帧:出现在显卡驱动/OpenGL 里基本可判定驱动问题。 + Native, + /// Java 帧(`# J` / `# j`,二者区别是解释执行还是编译执行,诊断不需要再细分)。 + Java, + /// VM 生成桩帧(`# V`):JIT/解释器生成的运行时代码,不是用户代码。 + VmStub, + /// 未识别。 + #[default] + Unknown, +} + +impl FrameKind { + /// 稳定字符串。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Native => "native", + Self::Java => "java", + Self::VmStub => "vm_stub", + Self::Unknown => "unknown", + } + } +} + +/// `# Problematic frame:` 之后的那一行。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FailedFrame { + /// 原始文本(`C [ig8d64.dll+0x9b3c4d]`)。 + pub raw: String, + /// 帧类型。 + pub kind: FrameKind, + /// 命中的模块/DLL(`ig8d64.dll`)。 + pub module: Option, + /// 模块内偏移(`0x9b3c4d`)。 + pub offset: Option, +} + +/// 内存段摘要(`Memory: 4k page, physical …, swap …`)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct MemorySummary { + /// 页大小(`4k`)。 + pub page_size: Option, + /// 物理内存 kB。 + pub physical_kb: Option, + /// 物理可用 kB。 + pub physical_free_kb: Option, + /// 交换区 kB。 + pub swap_kb: Option, + /// 交换区可用 kB。 + pub swap_free_kb: Option, + /// 原始行。 + pub raw: String, +} + +impl MemorySummary { + /// 一行人类可读摘要(GB 保留一位小数)。 + #[must_use] + pub fn summary(&self) -> String { + let gb = |kb: Option| { + kb.map(|kb| format!("{:.1} GB", kb as f64 / 1024.0 / 1024.0)) + .unwrap_or_else(|| "?".to_owned()) + }; + format!( + "物理内存 {}(可用 {}),交换区 {}(可用 {})", + gb(self.physical_kb), + gb(self.physical_free_kb), + gb(self.swap_kb), + gb(self.swap_free_kb) + ) + } +} + +/// 解析结果。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HsErrReport { + /// 来源文件(`parse` 时为 `None`)。 + pub path: Option, + /// 标题行(`#[derive(Default)]` 之外的语义见 `parse`)。 + pub header: Option, + /// 崩溃类别。 + pub kind: HsErrKind, + /// 异常名(`EXCEPTION_ACCESS_VIOLATION (0xc0000005)`)。 + pub exception: Option, + /// `siginfo:` 行(Unix 侧带 si_signo / si_addr)。 + pub siginfo: Option, + /// 信号名(`SIGSEGV (0xb)`)。 + pub signal: Option, + /// 出错时的指令指针。 + pub pc: Option, + /// 进程 pid。 + pub pid: Option, + /// 线程 tid(保留原始十六进制/十进制串)。 + pub tid: Option, + /// 失败帧。 + pub failed_frame: Option, + /// 当前线程(`JavaThread "Render thread" [_thread_in_native, id=…]`)。 + pub current_thread: Option, + /// JRE 版本行内容。 + pub jre_version: Option, + /// JVM 实现行内容。 + pub java_vm: Option, + /// 操作系统行内容。 + pub os: Option, + /// `java_command` 行。 + pub command_line: Option, + /// `VM Arguments:` 段的参数行。 + pub vm_arguments: Vec, + /// 内存段摘要。 + pub memory: Option, + /// `Heap:` 段的前几行(原样,含 total/used)。 + pub heap: Vec, + /// 标题块原文(逐行保留 `#` 前缀,规则可以直接在上面匹配)。 + pub header_lines: Vec, +} + +impl HsErrReport { + /// 供规则匹配 / 证据展示的摘要文本。 + /// + /// 保留标题块原文(`EXCEPTION_ACCESS_VIOLATION` 与 `# C [ig…dll]` 的相对位置不变, + /// 因此 `graphics.graphics_driver` 这类跨行规则可以直接命中),再补上线程/内存段要点。 + #[must_use] + pub fn digest(&self) -> String { + let mut out = String::new(); + for line in &self.header_lines { + out.push_str(line); + out.push('\n'); + } + for line in [ + self.siginfo.as_deref(), + self.current_thread.as_deref(), + self.jre_version.as_deref(), + self.java_vm.as_deref(), + self.os.as_deref(), + self.command_line.as_deref(), + ] + .into_iter() + .flatten() + { + out.push_str(line); + out.push('\n'); + } + for line in &self.heap { + out.push_str(line); + out.push('\n'); + } + if let Some(memory) = &self.memory { + out.push_str(&memory.raw); + out.push('\n'); + } + out + } + + /// 一句话摘要(诊断输出 / UI 头部)。 + #[must_use] + pub fn summary(&self) -> String { + let kind = self.kind.as_str(); + let frame = self + .failed_frame + .as_ref() + .map_or_else(String::new, |frame| format!(",失败帧 {}", frame.raw)); + let header = self.header.as_deref().unwrap_or("JVM 致命错误"); + format!("{header}({kind}{frame})") + } +} diff --git a/crates/core/src/diagnose/loader.rs b/crates/core/src/diagnose/loader.rs new file mode 100644 index 0000000..7cdb8fa --- /dev/null +++ b/crates/core/src/diagnose/loader.rs @@ -0,0 +1,398 @@ +//! 规则库加载:YAML 分组文件 + 内嵌兜底 + 热重载。 +//! +//! 对应 PlanDoc/06 §2.2「规则文件 YAML 外置可热更」(修 HMCL 编译期枚举不可热更的债): +//! 1. 内嵌一份(`include_str!`):任何情况下都能诊断,不依赖安装目录完整; +//! 2. 用户目录 `data/diagnose/rules.d/*.yaml` 覆盖内嵌的同名文件(缺哪个用内嵌补哪个); +//! 3. 热重载是 **mtime+size 指纹轮询**([`RuleLoader::refresh`]),不引 `notify` 依赖; +//! 解析失败时**保留上一份好规则**并返回错误列表——用户手改错 YAML 不会让诊断能力归零。 + +use std::collections::hash_map::DefaultHasher; +use std::collections::HashSet; +use std::fmt; +use std::fs; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use super::rules::{CompiledRule, Rule, RuleCategory}; + +/// 规则目录(相对数据根,由 platform 模块解析)。 +pub const RULES_DIR: &str = "diagnose/rules.d"; + +/// 规则 schema 版本(文件里 `version: 1`)。 +pub const SCHEMA_VERSION: u32 = 1; + +/// 内嵌规则库:`(文件名, 内容)`。按分类拆文件,每文件守 400 行上限。 +pub const EMBEDDED_RULES: &[(&str, &str)] = &[ + ("jvm.yaml", include_str!("rules.d/jvm.yaml")), + ("memory.yaml", include_str!("rules.d/memory.yaml")), + ("graphics.yaml", include_str!("rules.d/graphics.yaml")), + ("integrity.yaml", include_str!("rules.d/integrity.yaml")), + ("fabric.yaml", include_str!("rules.d/fabric.yaml")), + ("forge.yaml", include_str!("rules.d/forge.yaml")), + ("mod_runtime.yaml", include_str!("rules.d/mod_runtime.yaml")), + ( + "mod_conflict.yaml", + include_str!("rules.d/mod_conflict.yaml"), + ), + ("world.yaml", include_str!("rules.d/world.yaml")), + ("other.yaml", include_str!("rules.d/other.yaml")), +]; + +/// 一个 YAML 文件的 schema。 +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RuleFile { + #[serde(default)] + version: u32, + category: RuleCategory, + rules: Vec, +} + +/// 编译好的规则集(字段私有,只能经 loader 构造,保证「id 唯一 + 正则可编译」)。 +#[derive(Debug, Clone, Default)] +pub struct RuleSet { + rules: Vec, +} + +impl RuleSet { + /// 规则表。 + #[must_use] + pub fn rules(&self) -> &[CompiledRule] { + &self.rules + } + + /// 规则条数。 + #[must_use] + pub fn len(&self) -> usize { + self.rules.len() + } + + /// 是否空规则集。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.rules.is_empty() + } + + /// 按 id 查规则(测试 / 调试用)。 + #[must_use] + pub fn get(&self, id: &str) -> Option<&CompiledRule> { + self.rules.iter().find(|rule| rule.id() == id) + } + + /// 从 `(文件名, 内容)` 列表编译;有任何错误就整体失败(调用方决定是否回退)。 + pub fn from_sources(sources: &[(String, String)]) -> Result> { + let mut errors = Vec::new(); + let mut rules: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for (file, text) in sources { + let parsed = match serde_yaml_ng::from_str::(text) { + Ok(parsed) => parsed, + Err(error) => { + errors.push(LoadError::yaml(file, &error)); + continue; + } + }; + if parsed.version > SCHEMA_VERSION { + errors.push(LoadError { + file: file.clone(), + kind: LoadErrorKind::UnsupportedVersion, + message: format!( + "规则文件 version={} 高于本程序支持的 {}", + parsed.version, SCHEMA_VERSION + ), + }); + continue; + } + for rule in parsed.rules { + if !seen.insert(rule.id.clone()) { + errors.push(LoadError { + file: file.clone(), + kind: LoadErrorKind::DuplicateId, + message: format!("规则 id 重复:{}", rule.id), + }); + continue; + } + match CompiledRule::compile(rule, parsed.category) { + Ok(compiled) => rules.push(compiled), + Err(error) => errors.push(LoadError { + file: file.clone(), + kind: LoadErrorKind::Regex, + message: error.to_string(), + }), + } + } + } + if errors.is_empty() { + Ok(Self { rules }) + } else { + Err(errors) + } + } +} + +/// 加载错误分类。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoadErrorKind { + /// 读盘失败。 + Io, + /// YAML 语法/schema 错误(`serde_yaml_ng` 的报错带行号)。 + Yaml, + /// 正则编译失败。 + Regex, + /// 规则 id 跨文件重复。 + DuplicateId, + /// 规则文件版本高于本程序。 + UnsupportedVersion, +} + +impl LoadErrorKind { + /// 稳定字符串。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Io => "io", + Self::Yaml => "yaml", + Self::Regex => "regex", + Self::DuplicateId => "duplicate_id", + Self::UnsupportedVersion => "unsupported_version", + } + } +} + +/// 一条加载错误(带文件名,用户改 YAML 时能定位)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoadError { + /// 文件名(磁盘目录加载时是完整路径)。 + pub file: String, + /// 分类。 + pub kind: LoadErrorKind, + /// 报错原文(YAML 错误含行号)。 + pub message: String, +} + +impl LoadError { + fn yaml(file: &str, error: &serde_yaml_ng::Error) -> Self { + Self { + file: file.to_owned(), + kind: LoadErrorKind::Yaml, + message: error.to_string(), + } + } + + fn io(file: &Path, error: &std::io::Error) -> Self { + Self { + file: file.display().to_string(), + kind: LoadErrorKind::Io, + message: error.to_string(), + } + } +} + +impl fmt::Display for LoadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "[{}] {}:{}", + self.kind.as_str(), + self.file, + self.message + ) + } +} + +impl std::error::Error for LoadError {} + +/// 热重载结果。 +#[derive(Debug, Clone, PartialEq)] +pub enum ReloadOutcome { + /// 指纹没变,规则未动。 + Unchanged, + /// 重新加载成功。 + Reloaded { + /// 新规则条数。 + rules: usize, + }, + /// 新文件有错:**保留旧规则**,错误交给调用方展示。 + Rejected { + /// 错误列表。 + errors: Vec, + }, +} + +/// 规则库加载器(持有当前生效的规则集 + 目录指纹)。 +#[derive(Debug, Clone)] +pub struct RuleLoader { + dir: Option, + set: RuleSet, + fingerprint: u64, + last_errors: Vec, +} + +impl Default for RuleLoader { + fn default() -> Self { + Self::embedded() + } +} + +impl RuleLoader { + /// 只用内嵌规则(无磁盘目录)。 + #[must_use] + pub fn embedded() -> Self { + let sources = embedded_sources(); + let (set, errors) = compile_sources(&sources); + Self { + dir: None, + set, + fingerprint: 0, + last_errors: errors, + } + } + + /// 以磁盘目录为准、内嵌补齐缺文件。 + /// + /// 磁盘规则有任何错误时**退回内嵌规则**并记录错误(保证永远有可用规则集)。 + #[must_use] + pub fn from_dir(dir: impl Into) -> Self { + let dir = dir.into(); + let (sources, mut errors) = merged_sources(Some(&dir)); + let set = match RuleSet::from_sources(&sources) { + Ok(set) => set, + Err(mut load_errors) => { + errors.append(&mut load_errors); + RuleSet::from_sources(&embedded_sources()).unwrap_or_default() + } + }; + Self { + fingerprint: dir_fingerprint(&dir), + dir: Some(dir), + set, + last_errors: errors, + } + } + + /// 当前生效的规则。 + #[must_use] + pub fn rules(&self) -> &[CompiledRule] { + self.set.rules() + } + + /// 当前生效的规则集。 + #[must_use] + pub fn rule_set(&self) -> &RuleSet { + &self.set + } + + /// 上次加载留下的错误(UI 可提示「你的规则文件有错,已用内置规则」)。 + #[must_use] + pub fn last_errors(&self) -> &[LoadError] { + &self.last_errors + } + + /// 规则目录(内嵌模式为 `None`)。 + #[must_use] + pub fn dir(&self) -> Option<&Path> { + self.dir.as_deref() + } + + /// 目录指纹(测试用:确认 mtime/size 变化真的改变了指纹)。 + #[must_use] + pub fn fingerprint(&self) -> u64 { + self.fingerprint + } + + /// 轮询热重载:指纹变了才重新解析。 + pub fn refresh(&mut self) -> ReloadOutcome { + let Some(dir) = self.dir.clone() else { + return ReloadOutcome::Unchanged; + }; + let fingerprint = dir_fingerprint(&dir); + if fingerprint == self.fingerprint { + return ReloadOutcome::Unchanged; + } + self.fingerprint = fingerprint; + let (sources, mut errors) = merged_sources(Some(&dir)); + match RuleSet::from_sources(&sources) { + Ok(set) => { + self.set = set; + self.last_errors = errors; + ReloadOutcome::Reloaded { + rules: self.set.len(), + } + } + Err(mut load_errors) => { + errors.append(&mut load_errors); + self.last_errors = errors.clone(); + ReloadOutcome::Rejected { errors } + } + } + } +} + +fn embedded_sources() -> Vec<(String, String)> { + EMBEDDED_RULES + .iter() + .map(|(name, text)| ((*name).to_owned(), (*text).to_owned())) + .collect() +} + +/// 磁盘文件覆盖内嵌:磁盘有的用磁盘,没的用内嵌。 +fn merged_sources(dir: Option<&Path>) -> (Vec<(String, String)>, Vec) { + let mut sources = Vec::new(); + let mut errors = Vec::new(); + for (name, embedded) in EMBEDDED_RULES { + let path = dir.map(|dir| dir.join(name)); + match path { + Some(path) if path.is_file() => match fs::read_to_string(&path) { + Ok(text) => sources.push((path.display().to_string(), text)), + Err(error) => { + errors.push(LoadError::io(&path, &error)); + sources.push(((*name).to_owned(), (*embedded).to_owned())); + } + }, + _ => sources.push(((*name).to_owned(), (*embedded).to_owned())), + } + } + (sources, errors) +} + +fn compile_sources(sources: &[(String, String)]) -> (RuleSet, Vec) { + match RuleSet::from_sources(sources) { + Ok(set) => (set, Vec::new()), + Err(errors) => (RuleSet::default(), errors), + } +} + +/// 目录指纹:所有 `*.yaml` 的(文件名, 长度, mtime)之和。 +/// +/// 长度 + mtime 足以发现「保存了但秒级 mtime 未变」之外的一切编辑;漏检时用户点刷新即可。 +fn dir_fingerprint(dir: &Path) -> u64 { + let mut entries = fs::read_dir(dir) + .map(|entries| { + entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml")) + }) + .collect::>() + }) + .unwrap_or_default(); + entries.sort(); + let mut hasher = DefaultHasher::new(); + for path in entries { + path.file_name().unwrap_or_default().hash(&mut hasher); + if let Ok(metadata) = fs::metadata(&path) { + metadata.len().hash(&mut hasher); + metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos()) + .hash(&mut hasher); + } + } + hasher.finish() +} diff --git a/crates/core/src/diagnose/mod.rs b/crates/core/src/diagnose/mod.rs new file mode 100644 index 0000000..d026f43 --- /dev/null +++ b/crates/core/src/diagnose/mod.rs @@ -0,0 +1,17 @@ +//! 崩溃诊断:五类信号采集(含 hs_err_pid 解析)、YAML 规则引擎(59 条规则起步,可热更新)、 +//! exit0 静默失败检测、结构化诊断结论。 +//! +//! 设计定稿:`DevDocs/PlanDoc/06-launch-and-diagnostics.md` §2(采集 / 规则 / 流水线)。 +//! 定位:规则引擎为主、AI 补位;AI 拿到的应是「规则结论 + 相关片段」而非裸日志 +//! (`DevDocs/PlanDoc/02-ai-agent-design.md` §3.2)。 +//! +//! 规划中的公共面(M15):信号采集、规则匹配、诊断报告、规则热加载。 + +// 只有这五个顶层模块;子模块(collectors 下的 signals/sources/environment、engine 下的 +// finding/synthetic、hs_err 下的 hs_err_report、rules 下的 fix_action/fix_params)由各自的 +// 父文件用 #[path] 声明。这里**不要**重复声明,否则同一文件会被编译成两份不同类型。 +pub mod collectors; +pub mod engine; +pub mod hs_err; +pub mod loader; +pub mod rules; diff --git a/crates/core/src/diagnose/rules.d/fabric.yaml b/crates/core/src/diagnose/rules.d/fabric.yaml new file mode 100644 index 0000000..9a9db85 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/fabric.yaml @@ -0,0 +1,141 @@ +# 规则库 · Fabric mod 解析(8 条) +# 移植自 HMCL CrashReportAnalyzer.Rule 的 Fabric 组(analyse/02 §7.3 第 5 组)。 +# Fabric 的 ModResolutionException 家族特征:异常后面跟一段以 " - " 开头的列表, +# 因此这些规则用 file scope(跨行)。 +version: 1 +category: fabric +rules: + - id: fabric.mod_resolution + severity: critical + scope: file + pattern: 'ModResolutionException: (?(.*)[\n\r]*( - (.*)[\n\r]*)+)' + confidence: 0.75 + priority: 70 + cause: + key: diagnose.cause.fabric.mod_resolution + summary: 'Fabric 解析 mod 依赖失败(缺依赖/版本冲突/模组不配套)' + detail: '按下方列出的 mod 名单逐个处理:缺依赖就补装,冲突就删一个' + fix_action: + - kind: remove_mod + action: disable + note: '按 Fabric 报的 mod 名单逐个禁用排查' + sample: samples/fabric/mod_resolution.log + source: 'HMCL CrashReportAnalyzer.Rule.MOD_RESOLUTION' + + - id: fabric.mod_resolution_conflict + severity: critical + pattern: 'ModResolutionException: Found conflicting mods: (?.*) conflicts with (?.*)' + confidence: 0.92 + priority: 86 + suppress: + - integrity.no_class_def_found + - integrity.no_such_method + cause: + key: diagnose.cause.fabric.mod_resolution_conflict + summary: 'Fabric 报告 mod 冲突:{sourcemod} 与 {destmod} 不能共存' + detail: '两个只能留一个(或都升级到互相兼容的版本)' + fix_action: + - kind: remove_mod + mod_ids: + - '{sourcemod}' + - '{destmod}' + action: remove + sample: samples/fabric/mod_resolution_conflict.log + source: 'HMCL CrashReportAnalyzer.Rule.MOD_RESOLUTION_CONFLICT' + + - id: fabric.mod_resolution_missing + severity: critical + pattern: 'ModResolutionException: Could not find required mod: (?.*) requires (?.*)' + confidence: 0.9 + priority: 85 + cause: + key: diagnose.cause.fabric.mod_resolution_missing + summary: '缺少前置 mod:{sourcemod} 需要 {destmod}' + detail: '装上 {destmod} 的对应版本;装不上就删掉 {sourcemod}' + fix_action: + - kind: remove_mod + mod_ids: + - '{sourcemod}' + action: replace + note: '补装前置 {destmod},或换掉 {sourcemod}' + sample: samples/fabric/mod_resolution_missing.log + source: 'HMCL CrashReportAnalyzer.Rule.MOD_RESOLUTION_MISSING' + + - id: fabric.mod_resolution_missing_minecraft + severity: critical + pattern: 'ModResolutionException: Could not find required mod: (?.*) requires \{minecraft @ (?.*)\}' + confidence: 0.9 + priority: 85 + cause: + key: diagnose.cause.fabric.mod_resolution_missing_minecraft + summary: '{mod} 要求 Minecraft {version},与当前实例版本不一致' + fix_action: + - kind: remove_mod + mod_ids: + - '{mod}' + action: replace + note: '换该 mod 的 {version} 版本,或改实例版本' + sample: samples/fabric/mod_resolution_missing_minecraft.log + source: 'HMCL CrashReportAnalyzer.Rule.MOD_RESOLUTION_MISSING_MINECRAFT' + + - id: fabric.mod_resolution_collection + severity: critical + pattern: 'ModResolutionException: Could not resolve valid mod collection \(at: (?.*) requires (?.*)\)' + confidence: 0.88 + priority: 84 + cause: + key: diagnose.cause.fabric.mod_resolution_collection + summary: 'mod 集合无法解析:{sourcemod} 与 {destmod} 的版本组合不成立' + fix_action: + - kind: remove_mod + mod_ids: + - '{sourcemod}' + action: upgrade + sample: samples/fabric/mod_resolution_collection.log + source: 'HMCL CrashReportAnalyzer.Rule.MOD_RESOLUTION_COLLECTION' + + - id: fabric.mod_resolution_details + severity: warning + pattern: '(\tMod File:|-- MOD |\tFailure message:)' + confidence: 0.5 + priority: 45 + cause: + key: diagnose.cause.fabric.mod_resolution_details + summary: 'Fabric 打印了 mod 解析明细(Mod File / Failure message):具体结论见上方规则' + detail: '这条只表示「日志里有解析失败明细」,用于把证据片段指给 AI/用户' + fix_action: + - kind: seek_help + export_diagnostics: true + sample: samples/fabric/mod_resolution_details.log + source: 'HMCL CrashReportAnalyzer.Rule.MOD_RESOLUTION0' + + - id: fabric.loader_0_12 + severity: critical + pattern: 'java\.lang\.NoClassDefFoundError: org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy' + confidence: 0.85 + priority: 80 + cause: + key: diagnose.cause.fabric.loader_0_12 + summary: 'Fabric Loader 0.12 与旧 mixin 类不兼容(loader 删掉了 deprecated 类)' + fix_action: + - kind: reinstall + component: fabric + note: '换匹配的 Fabric Loader 版本' + sample: samples/fabric/loader_0_12.log + source: 'HMCL CrashReportAnalyzer.Rule.FABRIC_VERSION_0_12' + + - id: fabric.warnings + severity: warning + scope: file + pattern: '(Warnings were found!|Incompatible mod set!|Incompatible mods found!)(.*?)[\n\r]+(?[^\[]+)\[' + confidence: 0.7 + priority: 62 + cause: + key: diagnose.cause.fabric.warnings + summary: 'Fabric 报告 mod 集合不兼容:{reason}' + fix_action: + - kind: remove_mod + action: disable + note: '按警告列表逐个禁用' + sample: samples/fabric/warnings.log + source: 'HMCL CrashReportAnalyzer.Rule.FABRIC_WARNINGS' diff --git a/crates/core/src/diagnose/rules.d/forge.yaml b/crates/core/src/diagnose/rules.d/forge.yaml new file mode 100644 index 0000000..71aa1cd --- /dev/null +++ b/crates/core/src/diagnose/rules.d/forge.yaml @@ -0,0 +1,136 @@ +# 规则库 · Forge mod 解析(8 条) +# 移植自 HMCL CrashReportAnalyzer.Rule 的 Forge 组(analyse/02 §7.3 第 6 组)。 +version: 1 +category: forge +rules: + - id: forge.missing_dependencies + severity: critical + scope: file + pattern: 'Missing or unsupported mandatory dependencies:(?(.*)[\n\r]*(\t(.*)[\n\r]*)+)' + confidence: 0.85 + priority: 80 + cause: + key: diagnose.cause.forge.missing_dependencies + summary: 'Forge 报「缺少或不支持强制依赖」:有 mod 的前置没装或版本不对' + detail: '按报错里的 mod 名补齐前置;装不上就删掉依赖它的 mod' + fix_action: + - kind: remove_mod + action: disable + note: '按报错逐条补齐/禁用' + sample: samples/forge/missing_dependencies.log + source: 'HMCL CrashReportAnalyzer.Rule.FORGEMOD_RESOLUTION' + + - id: forge.duplicate_mods + severity: critical + scope: file + pattern: 'Found duplicate mods:(?(.*)(?:\r\n|\n|\r)*(\t(.*)(?:\r\n|\n|\r)*)+)' + confidence: 0.9 + priority: 86 + cause: + key: diagnose.cause.forge.duplicate_mods + summary: '同一个 mod 装了两份(mods 目录里重复的 jar)' + detail: '删掉重复的那一份,只留一个版本' + fix_action: + - kind: remove_mod + action: remove + note: '保留一个版本' + sample: samples/forge/duplicate_mods.log + source: 'HMCL CrashReportAnalyzer.Rule.FORGE_FOUND_DUPLICATE_MODS' + + - id: forge.duplicated_mod + severity: critical + pattern: 'Found a duplicate mod (?.*) at (?.*)' + confidence: 0.92 + priority: 88 + cause: + key: diagnose.cause.forge.duplicated_mod + summary: 'mod 重复:{name} 同时出现在 {path}' + fix_action: + - kind: remove_mod + mod_ids: + - '{name}' + action: remove + note: '删除路径 {path} 那一份' + sample: samples/forge/duplicated_mod.log + source: 'HMCL CrashReportAnalyzer.Rule.DUPLICATED_MOD' + + - id: forge.repeat_installation + severity: critical + pattern: 'MultipleArgumentsForOptionException: Found multiple arguments for option (.*?), but you asked for only one' + confidence: 0.85 + priority: 80 + cause: + key: diagnose.cause.forge.repeat_installation + summary: 'Forge 被装了两次(同一版本出现多个安装参数)' + detail: '删掉重复的 Forge 安装/版本 JSON,再重装一次' + fix_action: + - kind: reinstall + component: forge + sample: samples/forge/repeat_installation.log + source: 'HMCL CrashReportAnalyzer.Rule.FORGE_REPEAT_INSTALLATION' + + - id: forge.optifine_repeat_installation + severity: critical + pattern: 'ResolutionException: Module optifine reads another module named optifine' + confidence: 0.88 + priority: 82 + cause: + key: diagnose.cause.forge.optifine_repeat_installation + summary: 'OptiFine 装了两份(mods 里一个 + 自动安装一个)' + fix_action: + - kind: remove_mod + mod_ids: + - optifine + action: remove + note: '只保留一种安装方式' + sample: samples/forge/optifine_repeat_installation.log + source: 'HMCL CrashReportAnalyzer.Rule.OPTIFINE_REPEAT_INSTALLATION' + + - id: forge.too_many_mods + severity: critical + pattern: 'maximum id range exceeded' + confidence: 0.9 + priority: 80 + cause: + key: diagnose.cause.forge.too_many_mods + summary: '装的 mod 太多,方块/物品 ID 超出上限(1.12 及更早的经典问题)' + detail: '删掉一部分 mod,或换用 1.13+ 的版本(不再有 ID 上限)' + fix_action: + - kind: remove_mod + action: disable + note: '减量或换版本' + sample: samples/forge/too_many_mods.log + source: 'HMCL CrashReportAnalyzer.Rule.TOO_MANY_MODS_LEAD_TO_EXCEEDING_THE_ID_LIMIT' + + - id: forge.module_name_invalid + severity: warning + pattern: >- + Invalid module name: '' is not a Java identifier + confidence: 0.8 + priority: 70 + cause: + key: diagnose.cause.forge.module_name_invalid + summary: '某个 mod 的 jar 文件名不含英文字母/数字,Forge 把它当成非法 JPMS 模块名(如「拔刀剑.jar」)' + detail: '把该 jar 改成英文文件名(保留 .jar 后缀)再启动' + fix_action: + - kind: remove_mod + action: replace + note: '把 jar 改成英文文件名' + sample: samples/forge/module_name_invalid.log + source: 'HMCL CrashReportAnalyzer.Rule.MOD_NAME' + + - id: forge.bootstrap_failed + severity: critical + pattern: 'Failed to create mod instance\. ModID: (?.*?),' + confidence: 0.85 + priority: 80 + cause: + key: diagnose.cause.forge.bootstrap_failed + summary: 'mod {id} 的实例创建失败(初始化就抛异常)' + fix_action: + - kind: remove_mod + mod_ids: + - '{id}' + action: disable + sample: samples/forge/bootstrap_failed.log + source: 'HMCL CrashReportAnalyzer.Rule.BOOTSTRAP_FAILED' diff --git a/crates/core/src/diagnose/rules.d/graphics.yaml b/crates/core/src/diagnose/rules.d/graphics.yaml new file mode 100644 index 0000000..a070e88 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/graphics.yaml @@ -0,0 +1,221 @@ +# 规则库 · 显卡 / 图形(11 条 = HMCL 移植 6 + Vulkan 组 5) +# 移植自 HMCL CrashReportAnalyzer.Rule 的图形组(analyse/02 §7.3 第 3 组); +# Vulkan 组见文件末尾(依据 research/05-mc-vulkan.md §3.4 的 renderpearl 原因码)。 +version: 1 +category: graphics +rules: + - id: graphics.gl_operation_failure + severity: warning + pattern: '(1282: Invalid operation|Maybe try a lower resolution resourcepack\?)' + confidence: 0.7 + priority: 60 + cause: + key: diagnose.cause.graphics.gl_operation_failure + summary: '某个 mod/光影包做了非法的 OpenGL 操作(GL_INVALID_OPERATION)' + detail: '换掉最近装的光影/资源包,或把资源包分辨率降下来' + fix_action: + - kind: change_graphics + setting: renderer + suggested_value: 'OpenGL 兼容模式' + note: '先低分辨率资源包验证' + sample: samples/graphics/gl_operation_failure.log + source: 'HMCL CrashReportAnalyzer.Rule.GL_OPERATION_FAILURE' + + - id: graphics.opengl_not_supported + severity: critical + pattern: 'The driver does not appear to support OpenGL' + confidence: 0.85 + priority: 75 + cause: + key: diagnose.cause.graphics.opengl_not_supported + summary: '显卡驱动不支持 OpenGL(多半是没装显卡驱动,走了软件渲染)' + detail: '装/更新显卡厂商驱动;虚拟机里需要开 3D 加速' + fix_action: + - kind: change_graphics + setting: driver + update_driver: true + sample: samples/graphics/opengl_not_supported.log + source: 'HMCL CrashReportAnalyzer.Rule.OPENGL_NOT_SUPPORTED' + + - id: graphics.graphics_driver + severity: critical + # HMCL 的跨行贪婪模式(EXCEPTION_ACCESS_VIOLATION … # C [ig/nvoglv/atio)→ file scope。 + # 末四段是 SDL 变体:26.3-snapshot-4 起窗口层 GLFW→SDL(lwjgl-glfw 消失、lwjgl-sdl 出现), + # 同类故障的原文会变成 SDL_GetError 的文本;旧 GLFW 模式保留(老版本日志仍需要)。 + scope: file + pattern: '(Pixel format not accelerated|GLX: Failed to create context: GLXBadFBConfig|Couldn''t set pixel format|net\.minecraftforge\.fml\.client\.SplashProgress|org\.lwjgl\.LWJGLException|EXCEPTION_ACCESS_VIOLATION(.|\n|\r)+# C {2}\[(ig|atio|nvoglv)|SDL_CreateWindow failed|Couldn''t find matching render driver|No available video device)' + confidence: 0.8 + priority: 78 + cause: + key: diagnose.cause.graphics.graphics_driver + summary: '显卡驱动侧创建 OpenGL 上下文失败(像素格式/驱动崩溃)' + detail: '更新显卡驱动并重启;老显卡可用软渲染或换 OpenGL 兼容模式' + fix_action: + - kind: change_graphics + setting: driver + update_driver: true + - kind: change_graphics + setting: software_renderer + note: '驱动更新无效时用软渲染兜底(性能会掉)' + sample: samples/graphics/graphics_driver.log + source: 'HMCL CrashReportAnalyzer.Rule.GRAPHICS_DRIVER' + + - id: graphics.macos_display_port + severity: critical + # 原 GLFW 模式保留。后两段是 SDL 变体(26.3 起窗口层换 SDL,无显示器/远程会话在 SDL 侧 + # 报 SDL_Init 失败)——**但它必须和 `cocoa` 同时出现才命中**:SDL 在 macOS 上的视频驱动 + # 就叫 `cocoa`,用它把本规则的 macOS 特异性钉住,免得 Windows/Linux 的 SDL_Init 失败被 + # 误归因成「macOS 显示器问题」。⚠️ SDL 侧原文未在 26.3 实机验证(预测模式),拿到真实 + # 日志后按实际文本收紧;宁可漏诊也不要跨平台误归因。 + pattern: '(java\.lang\.IllegalStateException: GLFW error before init: \[0x10008\]Cocoa: Failed to find service port for display|(?i:cocoa).*SDL_Init.*failed|SDL_Init.*failed.*(?i:cocoa))' + confidence: 0.85 + priority: 78 + cause: + key: diagnose.cause.graphics.macos_display_port + summary: 'macOS 上 GLFW 初始化窗口失败(Cocoa 找不到 display service port)' + detail: '多见于远程桌面/无显示器会话;换成本机登录或升级 macOS 后再试' + fix_action: + - kind: change_graphics + setting: any + note: 'macOS 会话/显示器问题' + sample: samples/graphics/macos_display_port.log + source: 'HMCL CrashReportAnalyzer.Rule.MACOS_FAILED_TO_FIND_SERVICE_PORT_FOR_DISPLAY' + + - id: graphics.resolution_too_high + severity: warning + pattern: 'Maybe try a (lower resolution|lowerresolution) (resourcepack|texturepack)\?' + confidence: 0.8 + priority: 65 + cause: + key: diagnose.cause.graphics.resolution_too_high + summary: '资源包分辨率过高(游戏直接提示换低分辨率资源包)' + fix_action: + - kind: change_graphics + setting: resolution + suggested_value: '低分辨率资源包' + sample: samples/graphics/resolution_too_high.log + source: 'HMCL CrashReportAnalyzer.Rule.RESOLUTION_TOO_HIGH' + + - id: graphics.rtss_sodium + severity: warning + pattern: 'RivaTuner Statistics Server \(RTSS\) is not compatible with Sodium' + confidence: 0.9 + priority: 70 + cause: + key: diagnose.cause.graphics.rtss_sodium + summary: 'RTSS(微星小飞机/帧数显示)与 Sodium 不兼容' + detail: '关掉或卸载 RTSS 的 OSD 覆盖层,或改用 Sodium 兼容的帧数显示' + fix_action: + - kind: change_graphics + setting: overlay + suggested_value: '关闭 RTSS 覆盖层' + sample: samples/graphics/rtss_sodium.log + source: 'HMCL CrashReportAnalyzer.Rule.RTSS_FOREST_SODIUM' + + # ── Vulkan 后端组(MC 26.2+ 的实验性 Vulkan 渲染)──────────────────────────── + # 证据:research/05-mc-vulkan.md §3.4。renderpearl 的 + # `BackendCreationException$Reason` 有五个稳定原因码(四个 vulkan_* + opengl_missing), + # 它们同时出现在 latest.log 的 `Backend failure reason: <码>` 与崩溃报告的 + # `Backend failure reason` 行,是最可靠的落点(比英文正文稳,正文会随版本改)。 + # 注意语义:26.2/26.3 的 Vulkan 首选失败会**自动回落 OpenGL**,所以单条 vulkan_* 命中 + # 通常不是崩溃主因(warning);只有五个后端全挂才是 critical(见最后一条)。 + + - id: graphics.vulkan_loader_missing + severity: warning + pattern: '(Vulkan loader library is missing|vulkan_loader_missing)' + confidence: 0.85 + priority: 80 + cause: + key: diagnose.cause.graphics.vulkan_loader_missing + summary: '机器上没有 Vulkan loader(Vulkan 后端起不来,游戏已回落 OpenGL)' + detail: '装/更新显卡驱动(Windows 需要 vulkan-1.dll,Linux 需要 libvulkan.so.1);装不上就改用 OpenGL 后端' + fix_action: + - kind: change_graphics + setting: graphics_api + suggested_value: 'opengl' + note: 'Vulkan loader 缺失时强制 OpenGL,避免每次启动都白试一遍' + - kind: change_graphics + setting: driver + update_driver: true + sample: samples/graphics/vulkan_loader_missing.log + source: 'research/05-mc-vulkan.md §3.4(renderpearl BackendCreationException.Reason)' + + - id: graphics.vulkan_device_version_too_low + severity: warning + pattern: '(vulkan_device_version_too_low|Vulkan [0-9]\.[0-9].{0,40}at least [0-9]\.[0-9] required)' + confidence: 0.85 + priority: 78 + cause: + key: diagnose.cause.graphics.vulkan_device_version_too_low + summary: '显卡/驱动支持的 Vulkan 版本低于 1.2(Vulkan 后端起不来)' + detail: '更新显卡驱动;驱动停更的老卡(10 年以上)没有 Vulkan 1.2,只能继续用 OpenGL' + fix_action: + - kind: change_graphics + setting: graphics_api + suggested_value: 'opengl' + - kind: change_graphics + setting: driver + update_driver: true + sample: samples/graphics/vulkan_device_version_too_low.log + source: 'research/05-mc-vulkan.md §3.4' + + - id: graphics.vulkan_missing_extension + severity: warning + pattern: '(vulkan_missing_extension|missing: .*VK_KHR_(dynamic_rendering|push_descriptor))' + confidence: 0.85 + priority: 76 + cause: + key: diagnose.cause.graphics.vulkan_missing_extension + summary: '驱动缺 Vulkan 必需扩展(VK_KHR_dynamic_rendering / VK_KHR_push_descriptor)' + detail: 'MC 26.2+ 的 Vulkan 后端要求 VK 1.2 + dynamic_rendering + push_descriptor,三者缺一就跑不了;Mesa Dozen 也不支持 push_descriptor' + fix_action: + - kind: change_graphics + setting: graphics_api + suggested_value: 'opengl' + - kind: change_graphics + setting: driver + update_driver: true + note: '只有在驱动有新版本时才可能补上扩展' + sample: samples/graphics/vulkan_missing_extension.log + source: 'research/05-mc-vulkan.md §3.4 + §4.4(Dozen 反例)' + + - id: graphics.vulkan_missing_feature + severity: warning + pattern: '(vulkan_missing_feature|missing capabilities: .*VULKAN_CORE_1_2)' + confidence: 0.8 + priority: 74 + cause: + key: diagnose.cause.graphics.vulkan_missing_feature + summary: '驱动缺 Vulkan 必需特性集(VULKAN_CORE_1_2)' + detail: '同上,属于驱动能力不足;老核显/虚拟机里常见,用 OpenGL 后端即可' + fix_action: + - kind: change_graphics + setting: graphics_api + suggested_value: 'opengl' + sample: samples/graphics/vulkan_missing_feature.log + source: 'research/05-mc-vulkan.md §3.4' + + - id: graphics.no_graphics_backend + severity: critical + pattern: 'No supported graphics backend was found' + confidence: 0.9 + priority: 82 + suppress: + - graphics.vulkan_loader_missing + - graphics.vulkan_device_version_too_low + - graphics.vulkan_missing_extension + - graphics.vulkan_missing_feature + cause: + key: diagnose.cause.graphics.no_graphics_backend + summary: 'Vulkan 与 OpenGL 两个后端都创建失败(26.2+ 只有这两条路)' + detail: '硬件/驱动两侧都不满足:先更新显卡驱动;虚拟机需要开 3D 加速;实在不行只能换机器' + fix_action: + - kind: change_graphics + setting: graphics_api + suggested_value: 'opengl' + note: '先确认 Vulkan 失败原因再决定是否强推 OpenGL' + - kind: change_graphics + setting: software_renderer + note: '软渲染兜底能起来但基本没法玩,只用于导数据/排查' + sample: samples/graphics/no_graphics_backend.log + source: 'research/05-mc-vulkan.md §1.5(两个后端全失败才崩)' diff --git a/crates/core/src/diagnose/rules.d/integrity.yaml b/crates/core/src/diagnose/rules.d/integrity.yaml new file mode 100644 index 0000000..5eda6ee --- /dev/null +++ b/crates/core/src/diagnose/rules.d/integrity.yaml @@ -0,0 +1,138 @@ +# 规则库 · 文件与安装完整性(8 条) +# 移植自 HMCL CrashReportAnalyzer.Rule 的完整性组(analyse/02 §7.3 第 4 组)。 +# 组内约定:通用异常(NoSuchMethodError / NoClassDefFoundError)优先级低, +# 让「安装不完整 / jar 被解压 / mod 冲突」这类能定位根因的规则通过 suppress 把它们降级为次生结论。 +version: 1 +category: integrity +rules: + - id: integrity.file_changed + severity: critical + pattern: 'java\.lang\.SecurityException: SHA1 digest error for (?.*)|signer information does not match signer information of other classes in the same package' + confidence: 0.85 + priority: 85 + cause: + key: diagnose.cause.integrity.file_changed + summary: '游戏文件被改过(SHA1 校验不一致):jar 内容与清单哈希对不上' + detail: '重新下载/校验该文件;不要手改 jar 里的内容' + fix_action: + - kind: reinstall + component: game_jar + path_hint: '{file}' + sample: samples/integrity/file_changed.log + source: 'HMCL CrashReportAnalyzer.Rule.FILE_CHANGED' + + - id: integrity.no_such_method + severity: warning + pattern: 'java\.lang\.NoSuchMethodError: (?.*?)' + confidence: 0.6 + priority: 40 + cause: + key: diagnose.cause.integrity.no_such_method + summary: 'NoSuchMethodError:加载器/coremod 注入的东西与当前库版本不匹配' + detail: '常见于 mod 与游戏版本/Forge 版本不配套;重装加载器或删掉冲突 mod' + fix_action: + - kind: reinstall + component: loader + note: '先重装加载器;仍失败再逐个禁用 mod' + sample: samples/integrity/no_such_method.log + source: 'HMCL CrashReportAnalyzer.Rule.NO_SUCH_METHOD_ERROR' + + - id: integrity.no_class_def_found + severity: warning + pattern: 'java\.lang\.NoClassDefFoundError: (?.*)' + confidence: 0.6 + priority: 40 + cause: + key: diagnose.cause.integrity.no_class_def_found + summary: 'NoClassDefFoundError:缺少类文件(库没下全 / jar 被解压 / mod 不配套)' + detail: '先检查 libraries 是否完整,再排查 mods 里的解压目录' + fix_action: + - kind: reinstall + component: libraries + sample: samples/integrity/no_class_def_found.log + source: 'HMCL CrashReportAnalyzer.Rule.NO_CLASS_DEF_FOUND_ERROR' + + - id: integrity.illegal_access + severity: warning + pattern: 'java\.lang\.IllegalAccessError: tried to access class (.*?) from class (?.*?)' + confidence: 0.55 + priority: 45 + cause: + key: diagnose.cause.integrity.illegal_access + summary: 'IllegalAccessError:某个 coremod 想反射访问别的类的内部成员(在新 JDK 上被封了)' + fix_action: + - kind: reinstall + component: loader + note: '或换该 coremod/mod 的兼容版本' + sample: samples/integrity/illegal_access.log + source: 'HMCL CrashReportAnalyzer.Rule.ILLEGAL_ACCESS_ERROR' + + - id: integrity.file_already_exists + severity: warning + pattern: 'java\.nio\.file\.FileAlreadyExistsException: (?.*)' + confidence: 0.75 + priority: 60 + cause: + key: diagnose.cause.integrity.file_already_exists + summary: '某个 mod 要求一个「不该存在」的文件/目录,导致写入失败' + detail: '按提示删掉该文件(通常是玩家手建的空目录/unzip 残留)' + fix_action: + - kind: reinstall + component: mods + path_hint: '{file}' + note: '按路径删掉多余文件' + sample: samples/integrity/file_already_exists.log + source: 'HMCL CrashReportAnalyzer.Rule.FILE_ALREADY_EXISTS' + + - id: integrity.mod_files_decompressed + severity: critical + pattern: '(The directories below appear to be extracted jar files\. Fix this before you continue|Extracted mod jars found, loading will NOT continue)' + confidence: 0.95 + priority: 90 + suppress: + - integrity.no_class_def_found + - integrity.no_such_method + cause: + key: diagnose.cause.integrity.mod_files_decompressed + summary: 'mods 目录里有被解压成文件夹的 jar:加载器会直接拒绝启动' + detail: '把解压出来的目录删掉,只保留 .jar 文件(很多压缩软件双击就解压了)' + fix_action: + - kind: reinstall + component: mods + note: '删掉解压出来的目录' + sample: samples/integrity/mod_files_decompressed.log + source: 'HMCL CrashReportAnalyzer.Rule.MOD_FILES_ARE_DECOMPRESSED' + + - id: integrity.incomplete_forge + severity: critical + pattern: '(java\.io\.UncheckedIOException: java\.io\.IOException: Invalid paths argument, contained no existing paths: \[(.*?)(forge-(.*?)-client\.jar|fmlcore-(.*?)\.jar)\]|Failed to find Minecraft resource version (.*?) at (.*?)forge-(.*?)-client\.jar|Cannot find launch target fmlclient, unable to launch|java\.lang\.IllegalStateException: Could not find net/minecraft/client/Minecraft\.class in classloader SecureModuleClassLoader)' + confidence: 0.9 + priority: 88 + suppress: + - integrity.no_class_def_found + - integrity.no_such_method + cause: + key: diagnose.cause.integrity.incomplete_forge + summary: 'Forge 安装不完整(缺 forge-*-client.jar / fmlcore)' + detail: '重装该版本的 Forge,并检查 libraries 是否下全' + fix_action: + - kind: reinstall + component: forge + sample: samples/integrity/incomplete_forge.log + source: 'HMCL CrashReportAnalyzer.Rule.INCOMPLETE_FORGE_INSTALLATION' + + - id: integrity.unsatisfied_link + severity: critical + pattern: 'java\.lang\.UnsatisfiedLinkError: Failed to locate library: (?.*)' + confidence: 0.85 + priority: 82 + cause: + key: diagnose.cause.integrity.unsatisfied_link + summary: '找不到原生库(natives):{name} 没解压出来或被杀软删了' + detail: '重装组件(重新解压 natives);Windows 上检查杀软隔离区' + fix_action: + - kind: reinstall + component: natives + path_hint: '{name}' + sample: samples/integrity/unsatisfied_link.log + source: 'HMCL CrashReportAnalyzer.Rule.UNSATISFIED_LINK_ERROR' diff --git a/crates/core/src/diagnose/rules.d/jvm.yaml b/crates/core/src/diagnose/rules.d/jvm.yaml new file mode 100644 index 0000000..780258c --- /dev/null +++ b/crates/core/src/diagnose/rules.d/jvm.yaml @@ -0,0 +1,142 @@ +# 规则库 · JVM / Java 版本(8 条) +# 移植自 HMCL CrashReportAnalyzer.Rule(analyse/02 §7.3 第 1 组)。 +# schema 见 ../CONTRACT.md §2:pattern(regex) -> {cause, confidence, fix_action[]} +version: 1 +category: jvm +rules: + - id: jvm.openj9 + severity: critical + pattern: '(Open J9 is not supported|OpenJ9 is incompatible|\.J9VMInternals\.)' + confidence: 0.9 + priority: 88 + cause: + key: diagnose.cause.jvm.openj9 + summary: '当前用的是 OpenJ9 虚拟机:Minecraft 与绝大多数 mod 只支持 HotSpot(Oracle/OpenJDK)' + detail: '请换成 HotSpot 的 Java 8/17/21(Adoptium、Zulu、Microsoft OpenJDK 的 hotspot 版本都可以)' + fix_action: + - kind: change_java + vendor: hotspot + note: 'OpenJ9 不兼容,换 HotSpot' + sample: samples/jvm/openj9.log + source: 'HMCL CrashReportAnalyzer.Rule.OPENJ9' + + - id: jvm.need_jdk11 + severity: critical + pattern: '(no such method: sun\.misc\.Unsafe\.defineAnonymousClass\(Class,byte\[\],Object\[\]\)Class/invokeVirtual|java\.lang\.UnsupportedClassVersionError: icyllis/modernui/forge/MixinConnector has been compiled by a more recent version of the Java Runtime \(class file version 55\.0\), this version of the Java Runtime only recognizes class file versions up to 52\.0|java\.lang\.IllegalArgumentException: The requested compatibility level JAVA_11 could not be set\. Level is not supported by the active JRE or ASM version)' + confidence: 0.92 + priority: 90 + cause: + key: diagnose.cause.jvm.need_jdk11 + summary: '模组/加载器需要 Java 11,当前 Java 版本不够' + detail: '把实例的 Java 换到 11 或更高(1.17+ 的游戏通常要 17/21)' + fix_action: + - kind: change_java + required_major: 11 + sample: samples/jvm/need_jdk11.log + source: 'HMCL CrashReportAnalyzer.Rule.NEED_JDK11' + + - id: jvm.too_old_java + severity: critical + pattern: 'java\.lang\.UnsupportedClassVersionError: (.*?) version (?\d+)\.0' + confidence: 0.92 + priority: 88 + cause: + key: diagnose.cause.jvm.too_old_java + summary: 'Java 版本过低:无法加载用 class file version {expected} 编译的类,请换用更高版本的 Java' + detail: '这个 class 版本号减去 44 就是所需的 Java 大版本(52→Java 8、61→Java 17)' + fix_action: + - kind: change_java + class_file_version_group: expected + sample: samples/jvm/too_old_java.log + source: 'HMCL CrashReportAnalyzer.Rule.TOO_OLD_JAVA' + + - id: jvm.32bit + severity: critical + pattern: '(Could not reserve enough space for (.*?)KB object heap|The specified size exceeds the maximum representable size|Invalid maximum heap size)' + scope: line + confidence: 0.9 + priority: 92 + suppress: + - memory.out_of_memory + cause: + key: diagnose.cause.jvm.32bit + summary: '分配堆内存失败:多半是 32 位 Java 或 -Xmx 超过系统可用内存' + detail: '32 位 Java 最多只能用约 1.5 GB;换 64 位 Java 或把 -Xmx 降到 2048 MB 以下' + fix_action: + - kind: adjust_memory + need_64bit_java: true + min_xmx_mb: 1024 + - kind: change_java + required_major: 17 + note: '32 位换 64 位' + sample: samples/jvm/jvm_32bit.log + source: 'HMCL CrashReportAnalyzer.Rule.JVM_32BIT' + + - id: jvm.jdk_9 + severity: critical + pattern: 'java\.lang\.ClassCastException: (java\.base/jdk|class jdk)' + confidence: 0.8 + priority: 80 + cause: + key: diagnose.cause.jvm.jdk_9 + summary: '游戏只能在 Java 8 上跑(Forge/旧核心库不兼容 Java 9+),当前 Java 版本太高' + fix_action: + - kind: change_java + max_major: 8 + sample: samples/jvm/jdk_9.log + source: 'HMCL CrashReportAnalyzer.Rule.JDK_9' + + - id: jvm.version_too_high + severity: critical + pattern: '(Unable to make protected final java\.lang\.Class java\.lang\.ClassLoader\.defineClass|java\.lang\.NoSuchFieldException: ucp|Unsupported class file major version|because module java\.base does not export|java\.lang\.ClassNotFoundException: jdk\.nashorn\.api\.scripting\.NashornScriptEngineFactory|java\.lang\.ClassNotFoundException: java\.lang\.invoke\.LambdaMetafactory|Exception in thread "main" java\.lang\.NullPointerException: Cannot read the array length because "urls" is null)' + confidence: 0.85 + priority: 84 + cause: + key: diagnose.cause.jvm.version_too_high + summary: 'Java 版本过高:老版本 Forge/核心库用了被新 JDK 移除或封装的接口' + detail: '1.16 及更早优先用 Java 8;1.17–1.20.4 用 Java 17;1.20.5+ 用 Java 21' + fix_action: + - kind: change_java + vendor: hotspot + note: '降到该游戏版本要求的 Java' + - kind: reinstall + component: loader + note: '或把 Forge/加载器升级到支持新 Java 的版本' + sample: samples/jvm/java_version_is_too_high.log + source: 'HMCL CrashReportAnalyzer.Rule.JAVA_VERSION_IS_TOO_HIGH' + + - id: jvm.modlauncher_8 + severity: critical + pattern: 'java\.lang\.NoSuchMethodError: (''void sun\.security\.util\.ManifestEntryVerifier\.\(java\.util\.jar\.Manifest\)''|sun\.security\.util\.ManifestEntryVerifier\.\(Ljava/util/jar/Manifest;\)V)' + confidence: 0.9 + priority: 86 + cause: + key: diagnose.cause.jvm.modlauncher_8 + summary: 'ModLauncher 8 与当前 Java 不兼容(JDK-8273826):1.16+ Forge 在新 JDK 上会这样崩' + fix_action: + - kind: change_java + max_major: 8 + - kind: reinstall + component: forge + note: '或升级 Forge 到新版' + sample: samples/jvm/modlauncher_8.log + source: 'HMCL CrashReportAnalyzer.Rule.MODLAUNCHER_8' + + - id: jvm.mac_jdk8u261 + severity: critical + pattern: >- + Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'NSWindow drag regions should only be invalidated on the Main Thread!' + confidence: 0.85 + priority: 82 + cause: + key: diagnose.cause.jvm.mac_jdk8u261 + summary: 'macOS 上 JDK 8u261+ 用新 Xcode 编译导致的 Forge/OptiFine 崩溃(sp614x/optifine#4824)' + detail: '换 Java 8u252 及更早,或升级 Forge/OptiFine' + fix_action: + - kind: change_java + max_major: 8 + note: '优先试 8u252' + - kind: reinstall + component: optifine + sample: samples/jvm/mac_jdk8u261.log + source: 'HMCL CrashReportAnalyzer.Rule.MAC_JDK_8U261' diff --git a/crates/core/src/diagnose/rules.d/memory.yaml b/crates/core/src/diagnose/rules.d/memory.yaml new file mode 100644 index 0000000..44b7385 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/memory.yaml @@ -0,0 +1,39 @@ +# 规则库 · 内存(2 条) +# 移植自 HMCL CrashReportAnalyzer.Rule 的内存组(analyse/02 §7.3 第 2 组)。 +version: 1 +category: memory +rules: + - id: memory.out_of_memory + severity: critical + # 最后一条是跨行组合("Error occurred during initialization of VM" + "Too small maximum heap"), + # 因此整条规则用 file scope;HMCL 的 \R 在 Rust regex 里写成 (?:\r\n|\n|\r)。 + scope: file + pattern: '(java\.lang\.OutOfMemoryError|The system is out of physical RAM or swap space|Out of Memory Error|Error occurred during initialization of VM(?:\r\n|\n|\r)Too small maximum heap)' + confidence: 0.9 + priority: 80 + cause: + key: diagnose.cause.memory.out_of_memory + summary: '游戏内存不足(OutOfMemoryError):-Xmx 太小,或装的 mod 太多/整合包太大' + detail: '把 -Xmx 调到物理内存的一半左右(一般 4096–8192 MB),装光影/大量 mod 时适度加大' + fix_action: + - kind: adjust_memory + min_xmx_mb: 2048 + note: '优先加大 -Xmx;若已经是 32 位 Java,先换 64 位' + sample: samples/memory/out_of_memory.log + source: 'HMCL CrashReportAnalyzer.Rule.OUT_OF_MEMORY' + + - id: memory.memory_exceeded + severity: critical + pattern: 'There is insufficient memory for the Java Runtime Environment to continue' + confidence: 0.88 + priority: 82 + cause: + key: diagnose.cause.memory.memory_exceeded + summary: '系统物理内存/交换区耗尽:JVM 连续分配失败,整机内存不够' + detail: '关掉占内存的程序、降低 -Xmx,或扩大系统虚拟内存(页面文件)' + fix_action: + - kind: adjust_memory + increase_system_swap: true + note: '整机内存不足,不只是 -Xmx 的问题' + sample: samples/memory/memory_exceeded.log + source: 'HMCL CrashReportAnalyzer.Rule.MEMORY_EXCEEDED' diff --git a/crates/core/src/diagnose/rules.d/mod_conflict.yaml b/crates/core/src/diagnose/rules.d/mod_conflict.yaml new file mode 100644 index 0000000..6f0b867 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/mod_conflict.yaml @@ -0,0 +1,152 @@ +# 规则库 · 模组互斥(8 条) +# 移植自 HMCL CrashReportAnalyzer.Rule 的互斥组(analyse/02 §7.3 第 8 组): +# 具体到某个知名 mod 与 OptiFine / Sodium 的成名冲突,修复动作几乎都是「删 OptiFine 或其对手」。 +version: 1 +category: mod_conflict +rules: + - id: conflict.optifine_forge + severity: critical + scope: file + # 移植说明:HMCL 原文里的 \> / \< 在这里改成裸 > / <(正则里 \< \> 是词边界断言,要字面量得写裸字符)。 + # 末尾 alternative 保持 upstream 语义:`\(Reflector\.java)` 的前置 \( 是**字面括号**,尾部裸 ) 才是**闭合外层分组**, + # 因此它是前缀匹配。这里显式写成 `\(Reflector\.java(?::\d+)?\))`:裸 `(Reflector.java)` 与带行号的 + # `(Reflector.java:455)` 两种真实栈帧形态都能命中(P2-1 回归,见 tests/diagnose/engine_rank.rs)。 + pattern: >- + (java\.lang\.NoSuchMethodError: 'java\.lang\.Class sun\.misc\.Unsafe\.defineAnonymousClass\(java\.lang\.Class, byte\[\], java\.lang\.Object\[\]\)'|java\.lang\.NoSuchMethodError: 'void net\.minecraft\.client\.renderer\.texture\.SpriteContents\.\(net\.minecraft\.resources\.ResourceLocation, |java\.lang\.NoSuchMethodError: 'void net\.minecraftforge\.client\.gui\.overlay\.ForgeGui\.renderSelectedItemName\(net\.minecraft\.client\.gui\.GuiGraphics, int\)'|java\.lang\.NoSuchMethodError: 'java\.lang\.String com\.mojang\.blaze3d\.systems\.RenderSystem\.getBackendDescription\(\)'|java\.lang\.NoSuchMethodError: 'net\.minecraft\.network\.chat\.FormattedText net\.minecraft\.client\.gui\.Font\.ellipsize\(net\.minecraft\.network\.chat\.FormattedText, int\)'|java\.lang\.NoSuchMethodError: 'void net\.minecraft\.server\.level\.DistanceManager\.(.*?)\(net\.minecraft\.server\.level\.TicketType, net\.minecraft\.world\.level\.ChunkPos, int, java\.lang\.Object, boolean\)'|java\.lang\.NoSuchMethodError: 'void net\.minecraft\.client\.renderer\.block\.model\.BakedQuad\.\(int\[\], int, net\.minecraft\.core\.Direction, net\.minecraft\.client\.renderer\.texture\.TextureAtlasSprite, boolean, boolean\)'|TRANSFORMER/net\.optifine/net\.optifine\.reflect\.Reflector\.\(Reflector\.java(?::\d+)?\)) + confidence: 0.9 + priority: 87 + suppress: + - integrity.no_such_method + - integrity.no_class_def_found + cause: + key: diagnose.cause.conflict.optifine_forge + summary: 'OptiFine 与 Forge(或某个 Forge mod)不兼容:NoSuchMethodError 指向被 OptiFine 改写的原版方法' + detail: '删掉 OptiFine,改用同类替代(Sodium/Iris、Embeddium、Oculus 等),或换 OptiFine 的匹配版本' + fix_action: + - kind: remove_mod + mod_ids: + - optifine + action: remove + note: 'OptiFine 与 Forge 冲突,删掉其一' + sample: samples/mod_conflict/optifine_forge.log + source: 'HMCL CrashReportAnalyzer.Rule.OPTIFINE_IS_NOT_COMPATIBLE_WITH_FORGE' + + - id: conflict.shaders_mod + severity: critical + pattern: 'java\.lang\.RuntimeException: Shaders Mod detected\. Please remove it, OptiFine has built-in support for shaders\.' + confidence: 0.95 + priority: 85 + cause: + key: diagnose.cause.conflict.shaders_mod + summary: '装了独立的 Shaders Mod,但 OptiFine 自带光影支持,两者不能共存' + fix_action: + - kind: remove_mod + mod_ids: + - shadersmod + action: remove + sample: samples/mod_conflict/shaders_mod.log + source: 'HMCL CrashReportAnalyzer.Rule.SHADERS_MOD' + + - id: conflict.multipart_optifine + severity: critical + pattern: 'Error occurred applying transform of coremod META-INF/asm/multipart\.js function render' + confidence: 0.8 + priority: 80 + cause: + key: diagnose.cause.conflict.multipart_optifine + summary: 'Forge Multipart 的 coremod 转换失败(多为 OptiFine 同时存在导致)' + fix_action: + - kind: remove_mod + mod_ids: + - optifine + action: remove + note: '删 OptiFine 或 ForgeMultipart 之一' + sample: samples/mod_conflict/multipart_optifine.log + source: 'HMCL CrashReportAnalyzer.Rule.MOD_FOREST_OPTIFINE' + + - id: conflict.performant_optifine + severity: critical + pattern: 'org\.spongepowered\.asm\.mixin\.injection\.throwables\.InjectionError: Critical injection failure: Redirector OnisOnLadder\(Lnet/minecraft/block/BlockState;Lnet/minecraft/world/World;Lnet/minecraft/util/math/BlockPos;Lnet/minecraft/entity/LivingEntity;\)Z in performant\.mixins\.json:entity\.LivingEntityMixin failed injection check, \(0/1\) succeeded\. Scanned 1 target\(s\)\. Using refmap performant\.refmap\.json' + confidence: 0.9 + priority: 86 + cause: + key: diagnose.cause.conflict.performant_optifine + summary: 'Performant 与 OptiFine 的 mixin 冲突(LivingEntityMixin 注入失败)' + fix_action: + - kind: remove_mod + mod_ids: + - performant + - optifine + action: remove + note: '两者只能留一个' + sample: samples/mod_conflict/performant_optifine.log + source: 'HMCL CrashReportAnalyzer.Rule.PERFORMANT_FOREST_OPTIFINE' + + - id: conflict.twilight_optifine + severity: warning + pattern: 'java\.lang\.IllegalArgumentException: (.*) outside of image bounds (.*)' + confidence: 0.6 + priority: 70 + cause: + key: diagnose.cause.conflict.twilight_optifine + summary: '贴图越界(outside of image bounds):1.16 上 Twilight Forest 与 OptiFine 的经典冲突' + fix_action: + - kind: remove_mod + mod_ids: + - optifine + action: remove + note: '删 OptiFine 或 Twilight Forest 的兼容版本' + sample: samples/mod_conflict/twilight_optifine.log + source: 'HMCL CrashReportAnalyzer.Rule.TWILIGHT_FOREST_OPTIFINE' + + - id: conflict.jade_optifine + severity: critical + pattern: 'Critical injection failure: LVT in net/minecraft/client/renderer/GameRenderer::m_109093_\(FJZ\)V has incompatible changes at opcode 760 in callback jade\.mixins\.json:GameRendererMixin->@Inject::jade\$runTick\(FJZLorg/spongepowered/asm/mixin/injection/callback/CallbackInfo;IILcom/mojang/blaze3d/platform/Window;Lorg/joml/Matrix4f;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/gui/GuiGraphics;\)V\.' + confidence: 0.9 + priority: 86 + cause: + key: diagnose.cause.conflict.jade_optifine + summary: 'Jade 与 OptiFine 在 1.20+ 冲突(GameRendererMixin 注入失败)' + fix_action: + - kind: remove_mod + mod_ids: + - optifine + - jade + action: remove + note: '两者只能留一个' + sample: samples/mod_conflict/jade_optifine.log + source: 'HMCL CrashReportAnalyzer.Rule.JADE_FOREST_OPTIFINE' + + - id: conflict.neoforge_optifine + severity: critical + pattern: 'cpw\.mods\.modlauncher\.InvalidLauncherSetupException: Invalid Services found OptiFine' + confidence: 0.9 + priority: 86 + cause: + key: diagnose.cause.conflict.neoforge_optifine + summary: 'NeoForge 与 OptiFine 不兼容(launcher setup 阶段就拒绝)' + fix_action: + - kind: remove_mod + mod_ids: + - optifine + action: remove + sample: samples/mod_conflict/neoforge_optifine.log + source: 'HMCL CrashReportAnalyzer.Rule.NEOFORGE_FOREST_OPTIFINE' + + - id: conflict.optifine_world_load + severity: critical + # 移植修正:HMCL 原文写的是 ChunkManager$ProxyTicketManager,Java 正则里 $ 是行尾锚点, + # 那条规则实际上永远命中不了。这里转义成字面量 $,规则才能真正生效。 + pattern: 'java\.lang\.NoSuchMethodError: net\.minecraft\.world\.server\.ChunkManager\$ProxyTicketManager\.shouldForceTicks\(J\)Z' + confidence: 0.85 + priority: 84 + cause: + key: diagnose.cause.conflict.optifine_world_load + summary: 'OptiFine 导致世界无法加载(ChunkManager$ProxyTicketManager 方法被改写)' + fix_action: + - kind: remove_mod + mod_ids: + - optifine + action: remove + sample: samples/mod_conflict/optifine_world_load.log + source: 'HMCL CrashReportAnalyzer.Rule.OPTIFINE_CAUSES_THE_WORLD_TO_FAIL_TO_LOAD' diff --git a/crates/core/src/diagnose/rules.d/mod_runtime.yaml b/crates/core/src/diagnose/rules.d/mod_runtime.yaml new file mode 100644 index 0000000..04f4707 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/mod_runtime.yaml @@ -0,0 +1,136 @@ +# 规则库 · mod 运行期崩(8 条) +# 移植自 HMCL CrashReportAnalyzer.Rule 的运行期组(analyse/02 §7.3 第 7 组)。 +version: 1 +category: mod_runtime +rules: + - id: mod.loading_crashed_forge + severity: critical + pattern: 'LoaderExceptionModCrash: Caught exception from (?.*?) \((?.*)\)' + confidence: 0.9 + priority: 85 + cause: + key: diagnose.cause.mod.loading_crashed_forge + summary: 'Forge 抓到 {name}({id})在加载时抛异常' + detail: '禁用该 mod 或换它的兼容版本;同一条栈里往往还有具体异常类型' + fix_action: + - kind: remove_mod + mod_ids: + - '{id}' + action: disable + sample: samples/mod_runtime/loading_crashed_forge.log + source: 'HMCL CrashReportAnalyzer.Rule.LOADING_CRASHED_FORGE' + + - id: mod.loading_crashed_fabric + severity: critical + pattern: >- + Could not execute entrypoint stage '(.*?)' due to errors, provided by '(?.*)'! + confidence: 0.9 + priority: 85 + cause: + key: diagnose.cause.mod.loading_crashed_fabric + summary: 'Fabric 在入口点阶段执行 mod {id} 时出错' + fix_action: + - kind: remove_mod + mod_ids: + - '{id}' + action: disable + sample: samples/mod_runtime/loading_crashed_fabric.log + source: 'HMCL CrashReportAnalyzer.Rule.LOADING_CRASHED_FABRIC' + + - id: mod.config_failed + severity: warning + pattern: 'Failed loading config file (?.*?) of type (.*?) for modid (?.*)' + confidence: 0.7 + priority: 60 + cause: + key: diagnose.cause.mod.config_failed + summary: 'mod {id} 的配置文件 {file} 解析失败(多半是被手改坏或版本升级后格式变了)' + detail: '删掉该配置文件让 mod 重新生成默认值' + fix_action: + - kind: remove_mod + mod_ids: + - '{id}' + action: disable + note: '删配置文件 {file} 后重试(不一定要禁用 mod)' + sample: samples/mod_runtime/config_failed.log + source: 'HMCL CrashReportAnalyzer.Rule.CONFIG' + + - id: mod.mixin_failure + severity: critical + pattern: '(MixinApplyError|Mixin prepare failed |Mixin apply failed |mixin\.injection\.throwables\.|\.mixins\.json\] FAILED during \))' + confidence: 0.7 + priority: 65 + cause: + key: diagnose.cause.mod.mixin_failure + summary: 'Mixin 注入失败:某个 mod 的 mixin 与当前游戏/其他 mod 的代码不匹配' + detail: '常见于 mod 与游戏版本不配套;看日志里 mixin 的 target 类名能定位到 mod' + fix_action: + - kind: remove_mod + action: disable + note: '按 mixin 配置文件名(xxx.mixins.json)定位 mod' + sample: samples/mod_runtime/mixin_failure.log + source: 'HMCL CrashReportAnalyzer.Rule.MODMIXIN_FAILURE' + + - id: mod.mixin_apply_failed + severity: critical + pattern: 'Mixin apply for mod (?.*) failed' + confidence: 0.85 + priority: 82 + cause: + key: diagnose.cause.mod.mixin_apply_failed + summary: 'mod {id} 的 mixin 应用失败' + fix_action: + - kind: remove_mod + mod_ids: + - '{id}' + action: disable + sample: samples/mod_runtime/mixin_apply_failed.log + source: 'HMCL CrashReportAnalyzer.Rule.MIXIN_APPLY_MOD_FAILED' + + - id: mod.forge_error + severity: warning + scope: file + pattern: 'An exception was thrown, the game will display an error screen and halt\.(?:\r\n|\n|\r)*(?.*(?:\r\n|\n|\r)*(\s*at .*(?:\r\n|\n|\r))+)' + confidence: 0.6 + priority: 55 + cause: + key: diagnose.cause.mod.forge_error + summary: 'Forge 捕获到未处理异常并已弹出错误界面(Forge 自己给了栈)' + detail: '栈顶的 at 行指向哪个 mod 的包名,就去禁用/升级那个 mod' + fix_action: + - kind: seek_help + export_diagnostics: true + sample: samples/mod_runtime/forge_error.log + source: 'HMCL CrashReportAnalyzer.Rule.FORGE_ERROR' + + - id: mod.night_config_fixes + severity: warning + pattern: 'com\.electronwill\.nightconfig\.core\.io\.ParsingException: Not enough data available' + confidence: 0.85 + priority: 75 + cause: + key: diagnose.cause.mod.night_config_fixes + summary: 'night-config 配置文件读坏了(常见于 nightconfigfixes 相关 mod 组合)' + detail: '删掉对应 mod 的配置文件,或移除 nightconfigfixes' + fix_action: + - kind: remove_mod + mod_ids: + - nightconfigfixes + action: remove + sample: samples/mod_runtime/night_config_fixes.log + source: 'HMCL CrashReportAnalyzer.Rule.NIGHT_CONFIG_FIXES' + + - id: mod.install_mixinbootstrap + severity: critical + pattern: 'java\.lang\.ClassNotFoundException: org\.spongepowered\.asm\.launch\.MixinTweaker' + confidence: 0.8 + priority: 75 + cause: + key: diagnose.cause.mod.install_mixinbootstrap + summary: '缺少 MixinBootstrap 前置(Forge 1.12.2 上很多 mod 需要它)' + fix_action: + - kind: reinstall + component: mixin_bootstrap + note: '装上 MixinBootstrap 0.8+' + sample: samples/mod_runtime/install_mixinbootstrap.log + source: 'HMCL CrashReportAnalyzer.Rule.INSTALL_MIXINBOOTSTRAP' diff --git a/crates/core/src/diagnose/rules.d/other.yaml b/crates/core/src/diagnose/rules.d/other.yaml new file mode 100644 index 0000000..e7b61b1 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/other.yaml @@ -0,0 +1,22 @@ +# 规则库 · 其它(1 条) +# 移植自 HMCL CrashReportAnalyzer.Rule 的杂项组(analyse/02 §7.3 第 10 组)。 +# 将来「用户反馈沉淀的新规则」如果归不了类,也先落这里(标记 source 与需要复核)。 +version: 1 +category: other +rules: + - id: other.debug_crash + severity: info + pattern: 'Manually triggered debug crash' + confidence: 0.95 + priority: 70 + cause: + key: diagnose.cause.other.debug_crash + summary: '这次崩溃是手动触发的(游戏里按 F3+C),不是真的故障' + detail: '如果用户点了「导出诊断包」做测试,可以忽略这条' + fix_action: + - kind: seek_help + export_diagnostics: false + needs_more_info: + - '确认是否手动触发的调试崩溃' + sample: samples/other/debug_crash.log + source: 'HMCL CrashReportAnalyzer.Rule.DEBUG_CRASH' diff --git a/crates/core/src/diagnose/rules.d/samples/fabric/loader_0_12.log b/crates/core/src/diagnose/rules.d/samples/fabric/loader_0_12.log new file mode 100644 index 0000000..0f5b91c --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/fabric/loader_0_12.log @@ -0,0 +1,2 @@ +[12:00:40] [main/ERROR]: Failed to start Fabric loader +java.lang.NoClassDefFoundError: org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy diff --git a/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution.log b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution.log new file mode 100644 index 0000000..7ecdc20 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution.log @@ -0,0 +1,3 @@ +net.fabricmc.loader.impl.FormattedException: net.fabricmc.loader.impl.discovery.ModResolutionException: Mod resolution encountered an incompatible mod set! + - Mod 'Sodium' (sodium) 0.5.8 requires version 0.6.0 or later of mod 'Iris' (iris) + - Mod 'Iris' (iris) 0.5.0 is present but is incompatible diff --git a/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_collection.log b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_collection.log new file mode 100644 index 0000000..c57dca2 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_collection.log @@ -0,0 +1 @@ +net.fabricmc.loader.impl.discovery.ModResolutionException: Could not resolve valid mod collection (at: iris requires sodium) diff --git a/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_conflict.log b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_conflict.log new file mode 100644 index 0000000..0ceb775 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_conflict.log @@ -0,0 +1 @@ +net.fabricmc.loader.impl.discovery.ModResolutionException: Found conflicting mods: sodium conflicts with sodium-extra diff --git a/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_details.log b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_details.log new file mode 100644 index 0000000..03c4c77 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_details.log @@ -0,0 +1,3 @@ + Mod File: /mods/sodium-fabric-0.5.0.jar + Failure message: Mod resolution failed +net.fabricmc.loader.impl.discovery.ModResolutionException: see above diff --git a/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_missing.log b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_missing.log new file mode 100644 index 0000000..94eedaa --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_missing.log @@ -0,0 +1 @@ +net.fabricmc.loader.impl.discovery.ModResolutionException: Could not find required mod: sodium requires iris diff --git a/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_missing_minecraft.log b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_missing_minecraft.log new file mode 100644 index 0000000..b7d94aa --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_missing_minecraft.log @@ -0,0 +1 @@ +net.fabricmc.loader.impl.discovery.ModResolutionException: Could not find required mod: sodium requires {minecraft @ 1.20.1} diff --git a/crates/core/src/diagnose/rules.d/samples/fabric/warnings.log b/crates/core/src/diagnose/rules.d/samples/fabric/warnings.log new file mode 100644 index 0000000..20dab22 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/fabric/warnings.log @@ -0,0 +1,3 @@ +Warnings were found! + - Sodium 0.4.10 is incompatible with Iris 1.6.0 +[ diff --git a/crates/core/src/diagnose/rules.d/samples/forge/bootstrap_failed.log b/crates/core/src/diagnose/rules.d/samples/forge/bootstrap_failed.log new file mode 100644 index 0000000..98dee26 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/forge/bootstrap_failed.log @@ -0,0 +1 @@ +[12:00:55] [main/ERROR]: Failed to create mod instance. ModID: alexsmobs, Class: com.github.alexthe666.alexsmobs.AlexsMobs diff --git a/crates/core/src/diagnose/rules.d/samples/forge/duplicate_mods.log b/crates/core/src/diagnose/rules.d/samples/forge/duplicate_mods.log new file mode 100644 index 0000000..04a2863 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/forge/duplicate_mods.log @@ -0,0 +1,3 @@ +Found duplicate mods: + jei-1.20.1-15.2.0.27.jar + jei-1.20.1-15.2.0.28.jar diff --git a/crates/core/src/diagnose/rules.d/samples/forge/duplicated_mod.log b/crates/core/src/diagnose/rules.d/samples/forge/duplicated_mod.log new file mode 100644 index 0000000..83c75f2 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/forge/duplicated_mod.log @@ -0,0 +1 @@ +[12:00:50] [main/ERROR]: Found a duplicate mod jei at /mods/jei-1.20.1-15.2.0.28.jar diff --git a/crates/core/src/diagnose/rules.d/samples/forge/missing_dependencies.log b/crates/core/src/diagnose/rules.d/samples/forge/missing_dependencies.log new file mode 100644 index 0000000..c6b364d --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/forge/missing_dependencies.log @@ -0,0 +1,2 @@ +Missing or unsupported mandatory dependencies: + Mod ID: citadel, Requested by: alexsmobs, Expected range: [1.20.1-2.4.0,), Actual version: MISSING diff --git a/crates/core/src/diagnose/rules.d/samples/forge/module_name_invalid.log b/crates/core/src/diagnose/rules.d/samples/forge/module_name_invalid.log new file mode 100644 index 0000000..f77b204 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/forge/module_name_invalid.log @@ -0,0 +1 @@ +[12:00:54] [main/ERROR]: java.lang.IllegalArgumentException: Invalid module name: '' is not a Java identifier diff --git a/crates/core/src/diagnose/rules.d/samples/forge/optifine_repeat_installation.log b/crates/core/src/diagnose/rules.d/samples/forge/optifine_repeat_installation.log new file mode 100644 index 0000000..f8450c6 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/forge/optifine_repeat_installation.log @@ -0,0 +1 @@ +[12:00:52] [main/ERROR]: org.spongepowered.asm.launch.platform.MixinPlatformManager threw ResolutionException: Module optifine reads another module named optifine diff --git a/crates/core/src/diagnose/rules.d/samples/forge/repeat_installation.log b/crates/core/src/diagnose/rules.d/samples/forge/repeat_installation.log new file mode 100644 index 0000000..203ec4b --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/forge/repeat_installation.log @@ -0,0 +1 @@ +[12:00:51] [main/ERROR]: joptsimple.MultipleArgumentsForOptionException: Found multiple arguments for option net.minecraftforge, but you asked for only one diff --git a/crates/core/src/diagnose/rules.d/samples/forge/too_many_mods.log b/crates/core/src/diagnose/rules.d/samples/forge/too_many_mods.log new file mode 100644 index 0000000..73430ec --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/forge/too_many_mods.log @@ -0,0 +1 @@ +[12:00:53] [main/ERROR]: java.lang.RuntimeException: maximum id range exceeded diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/gl_operation_failure.log b/crates/core/src/diagnose/rules.d/samples/graphics/gl_operation_failure.log new file mode 100644 index 0000000..8e8d5ab --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/gl_operation_failure.log @@ -0,0 +1,3 @@ +[12:00:20] [Render thread/ERROR]: ########## GL ERROR ########## +[12:00:20] [Render thread/ERROR]: @ Pre render +[12:00:20] [Render thread/ERROR]: 1282: Invalid operation diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/graphics_driver.log b/crates/core/src/diagnose/rules.d/samples/graphics/graphics_driver.log new file mode 100644 index 0000000..7baa8ef --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/graphics_driver.log @@ -0,0 +1,7 @@ +# +# A fatal error has been detected by the Java Runtime Environment: +# +# EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ffb1a2b3c4d, pid=12344, tid=0x0000000000002a1c +# +# Problematic frame: +# C [ig8d64.dll+0x9b3c4d] diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/macos_display_port.log b/crates/core/src/diagnose/rules.d/samples/graphics/macos_display_port.log new file mode 100644 index 0000000..e15d8ab --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/macos_display_port.log @@ -0,0 +1 @@ +[12:00:22] [main/ERROR]: java.lang.IllegalStateException: GLFW error before init: [0x10008]Cocoa: Failed to find service port for display diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/no_graphics_backend.log b/crates/core/src/diagnose/rules.d/samples/graphics/no_graphics_backend.log new file mode 100644 index 0000000..33dcf79 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/no_graphics_backend.log @@ -0,0 +1,15 @@ +---- Minecraft Crash Report ---- +// I let you down. Sorry :( + +Time: 2026-10-30 12:00:21 +Description: Initializing game + +net.minecraft.client.Minecraft$GameLoadException: No supported graphics backend was found. + at net.minecraft.client.Minecraft.(Minecraft.java:612) + at net.minecraft.client.main.Main.main(Main.java:184) + +-- Graphics Backend -- + Backend: VULKAN + Backend failure reason: vulkan_loader_missing + Backend: OPENGL + Backend failure reason: opengl_missing diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/opengl_not_supported.log b/crates/core/src/diagnose/rules.d/samples/graphics/opengl_not_supported.log new file mode 100644 index 0000000..e6d21b3 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/opengl_not_supported.log @@ -0,0 +1 @@ +[12:00:21] [main/ERROR]: The driver does not appear to support OpenGL diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/resolution_too_high.log b/crates/core/src/diagnose/rules.d/samples/graphics/resolution_too_high.log new file mode 100644 index 0000000..684495b --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/resolution_too_high.log @@ -0,0 +1 @@ +[12:00:23] [Render thread/WARN]: Maybe try a lower resolution resourcepack? diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/rtss_sodium.log b/crates/core/src/diagnose/rules.d/samples/graphics/rtss_sodium.log new file mode 100644 index 0000000..7528a6d --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/rtss_sodium.log @@ -0,0 +1 @@ +[12:00:24] [main/ERROR]: RivaTuner Statistics Server (RTSS) is not compatible with Sodium diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_device_version_too_low.log b/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_device_version_too_low.log new file mode 100644 index 0000000..f4756a5 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_device_version_too_low.log @@ -0,0 +1,5 @@ +[12:00:21] [Render thread/INFO]: Using graphics backend VULKAN, using drivers: Intel proprietary driver +[12:00:21] [Render thread/ERROR]: Backend failure reason: vulkan_device_version_too_low +[12:00:21] [Render thread/ERROR]: Backend failure message: Device NVIDIA GeForce GT 710 reports Vulkan 1.0.0, at least 1.2 required +[12:00:21] [Render thread/ERROR]: Failed to create backend VULKAN +[12:00:21] [Render thread/INFO]: Falling back to OpenGL diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_loader_missing.log b/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_loader_missing.log new file mode 100644 index 0000000..3580d49 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_loader_missing.log @@ -0,0 +1,5 @@ +[12:00:21] [Render thread/INFO]: Backend library: LWJGL version 3.4.3+13 +[12:00:21] [Render thread/INFO]: Using graphics backend VULKAN, using drivers: AMD proprietary driver +[12:00:21] [Render thread/ERROR]: Vulkan loader library is missing +[12:00:21] [Render thread/ERROR]: Failed to create backend VULKAN: Backend failure reason: vulkan_loader_missing +[12:00:21] [Render thread/INFO]: Falling back to OpenGL diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_missing_extension.log b/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_missing_extension.log new file mode 100644 index 0000000..aabed4e --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_missing_extension.log @@ -0,0 +1,5 @@ +[12:00:21] [Render thread/INFO]: Using graphics backend VULKAN, using drivers: Mesa (zink) +[12:00:21] [Render thread/ERROR]: Backend failure reason: vulkan_missing_extension +[12:00:21] [Render thread/ERROR]: Device [AMD Radeon RX 580] does not support required extensions from FeatureSet [VULKAN_12], missing: [VK_KHR_dynamic_rendering, VK_KHR_push_descriptor] +[12:00:21] [Render thread/ERROR]: Failed to create backend VULKAN +[12:00:21] [Render thread/INFO]: Falling back to OpenGL diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_missing_feature.log b/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_missing_feature.log new file mode 100644 index 0000000..be3a375 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_missing_feature.log @@ -0,0 +1,5 @@ +[12:00:21] [Render thread/INFO]: Using graphics backend VULKAN, using drivers: llvmpipe (Mesa) +[12:00:21] [Render thread/ERROR]: Backend failure reason: vulkan_missing_feature +[12:00:21] [Render thread/ERROR]: Backend failure missing capabilities: VULKAN_CORE_1_2 +[12:00:21] [Render thread/ERROR]: Failed to create backend VULKAN +[12:00:21] [Render thread/INFO]: Falling back to OpenGL diff --git a/crates/core/src/diagnose/rules.d/samples/hs_err/access_violation.log b/crates/core/src/diagnose/rules.d/samples/hs_err/access_violation.log new file mode 100644 index 0000000..072f1a2 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/hs_err/access_violation.log @@ -0,0 +1,34 @@ +# +# A fatal error has been detected by the Java Runtime Environment: +# +# EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ffb1a2b3c4d, pid=12344, tid=0x0000000000002a1c +# +# JRE version: Java(TM) SE Runtime Environment (8.0_51-b16) (build 1.8.0_51-b16) +# Java VM: Java HotSpot(TM) 64-Bit Server VM (25.51-b03 mixed mode windows-amd64 compressed oops) +# Problematic frame: +# C [ig8d64.dll+0x9b3c4d] +# +# Failed to write core dump. Minidumps are not enabled by default on client versions of Windows +# +# If you would like to submit a bug report, please visit: +# http://bugreport.java.com/bugreport/crash.jsp +# The crash happened outside the Java Virtual Machine in native code. +# See problematic frame for where to report the bug. +# + +--------------- T H R E A D --------------- + +Current thread (0x000000001f3b8000): JavaThread "Render thread" [_thread_in_native, id=10780, stack(0x000000001a000000,0x000000001a100000)] + +siginfo: ExceptionCode=0xc0000005, ExceptionInformation=0x0000000000000000 0x0000000000000000 0x0000000000000000 + +VM Arguments: +jvm_args: -Xmx4096m -Xms1024m -Djava.library.path=C:\Users\player\.minecraft\natives +java_command: net.minecraft.launchwrapper.Launch --gameDir . --assetsDir assets + +--------------- H E A P --------------- + +Heap: + garbage-first heap total 4194304K, used 1048576K [0x0000000700000000, 0x0000000800000000) + +Memory: 4k page, physical 16741480k(4716856k free), swap 18672384k(9433608k free) diff --git a/crates/core/src/diagnose/rules.d/samples/hs_err/out_of_memory.log b/crates/core/src/diagnose/rules.d/samples/hs_err/out_of_memory.log new file mode 100644 index 0000000..28e5158 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/hs_err/out_of_memory.log @@ -0,0 +1,19 @@ +# +# There is insufficient memory for the Java Runtime Environment to continue. +# Native memory allocation (mmap) failed to map 1073741824 bytes for committing reserved memory. +# +# Out of Memory Error (os_windows.cpp:3521), pid=9876, tid=0x0000000000001f44 +# +# JRE version: Java(TM) SE Runtime Environment (17.0.9+9) (build 17.0.9+9-LTS-211) +# Java VM: Java HotSpot(TM) 64-Bit Server VM (17.0.9+9-LTS-211, mixed mode, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, windows-amd64) +# + +--------------- T H R E A D --------------- + +Current thread (0x0000021d6f82b1a0): JavaThread "Render thread" [_thread_in_vm, id=14120, stack(0x000000a1b0000000,0x000000a1b0100000)] + +VM Arguments: +jvm_args: -Xmx8192m -Xms2048m +java_command: net.fabricmc.loader.impl.launch.knot.KnotClient --gameDir . + +Memory: 4k page, physical 8134564k(120944k free), swap 9723904k(550664k free) diff --git a/crates/core/src/diagnose/rules.d/samples/integrity/file_already_exists.log b/crates/core/src/diagnose/rules.d/samples/integrity/file_already_exists.log new file mode 100644 index 0000000..6538598 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/integrity/file_already_exists.log @@ -0,0 +1 @@ +[12:00:34] [main/ERROR]: java.nio.file.FileAlreadyExistsException: config/example-mod.toml diff --git a/crates/core/src/diagnose/rules.d/samples/integrity/file_changed.log b/crates/core/src/diagnose/rules.d/samples/integrity/file_changed.log new file mode 100644 index 0000000..c61c24e --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/integrity/file_changed.log @@ -0,0 +1,2 @@ +[12:00:30] [main/ERROR]: Failed to verify class file +java.lang.SecurityException: SHA1 digest error for net/minecraft/client/main/Main.class diff --git a/crates/core/src/diagnose/rules.d/samples/integrity/illegal_access.log b/crates/core/src/diagnose/rules.d/samples/integrity/illegal_access.log new file mode 100644 index 0000000..ae7a12c --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/integrity/illegal_access.log @@ -0,0 +1,2 @@ +[12:00:33] [main/ERROR]: coremod failed +java.lang.IllegalAccessError: tried to access class net.minecraft.client.Minecraft from class com.example.coremod.Hook diff --git a/crates/core/src/diagnose/rules.d/samples/integrity/incomplete_forge.log b/crates/core/src/diagnose/rules.d/samples/integrity/incomplete_forge.log new file mode 100644 index 0000000..6471fca --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/integrity/incomplete_forge.log @@ -0,0 +1 @@ +[12:00:36] [main/ERROR]: java.io.UncheckedIOException: java.io.IOException: Invalid paths argument, contained no existing paths: [/home/user/.minecraft/libraries/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-client.jar] diff --git a/crates/core/src/diagnose/rules.d/samples/integrity/mod_files_decompressed.log b/crates/core/src/diagnose/rules.d/samples/integrity/mod_files_decompressed.log new file mode 100644 index 0000000..0456caf --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/integrity/mod_files_decompressed.log @@ -0,0 +1,2 @@ +[12:00:35] [main/ERROR]: Extracted mod jars found, loading will NOT continue +[12:00:35] [main/ERROR]: The directories below appear to be extracted jar files. Fix this before you continue diff --git a/crates/core/src/diagnose/rules.d/samples/integrity/no_class_def_found.log b/crates/core/src/diagnose/rules.d/samples/integrity/no_class_def_found.log new file mode 100644 index 0000000..e9b4360 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/integrity/no_class_def_found.log @@ -0,0 +1,2 @@ +[12:00:32] [main/ERROR]: A mod crashed on startup +java.lang.NoClassDefFoundError: net/minecraftforge/fml/loading/FMLEnvironment diff --git a/crates/core/src/diagnose/rules.d/samples/integrity/no_such_method.log b/crates/core/src/diagnose/rules.d/samples/integrity/no_such_method.log new file mode 100644 index 0000000..cbafdd0 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/integrity/no_such_method.log @@ -0,0 +1,3 @@ +[12:00:31] [main/ERROR]: Failed to start +java.lang.NoSuchMethodError: net.minecraft.world.World.getBlockState(Lnet/minecraft/core/BlockPos;)Lnet/minecraft/world/level/block/state/BlockState; + at com.example.mod.Foo.bar(Foo.java:42) diff --git a/crates/core/src/diagnose/rules.d/samples/integrity/unsatisfied_link.log b/crates/core/src/diagnose/rules.d/samples/integrity/unsatisfied_link.log new file mode 100644 index 0000000..3504911 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/integrity/unsatisfied_link.log @@ -0,0 +1,2 @@ +[12:00:37] [main/ERROR]: Failed to initialise LWJGL +java.lang.UnsatisfiedLinkError: Failed to locate library: lwjgl.dll diff --git a/crates/core/src/diagnose/rules.d/samples/jvm/java_version_is_too_high.log b/crates/core/src/diagnose/rules.d/samples/jvm/java_version_is_too_high.log new file mode 100644 index 0000000..7f53dd0 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/jvm/java_version_is_too_high.log @@ -0,0 +1,3 @@ +[12:00:06] [main/ERROR]: Unable to launch +java.lang.NoSuchFieldException: ucp + at java.base/java.lang.ClassLoader$UnsafeAccessor.getUnsafe(ClassLoader.java:123) diff --git a/crates/core/src/diagnose/rules.d/samples/jvm/jdk_9.log b/crates/core/src/diagnose/rules.d/samples/jvm/jdk_9.log new file mode 100644 index 0000000..b4670b3 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/jvm/jdk_9.log @@ -0,0 +1,2 @@ +[12:00:05] [main/ERROR]: Failed to launch +java.lang.ClassCastException: java.base/jdk.internal.loader.ClassLoaders$AppClassLoader cannot be cast to java.net.URLClassLoader diff --git a/crates/core/src/diagnose/rules.d/samples/jvm/jvm_32bit.log b/crates/core/src/diagnose/rules.d/samples/jvm/jvm_32bit.log new file mode 100644 index 0000000..37f7dcc --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/jvm/jvm_32bit.log @@ -0,0 +1,3 @@ +[12:00:04] [main/INFO]: Starting JVM with -Xmx4096m +Error occurred during initialization of VM +Could not reserve enough space for 4194304KB object heap diff --git a/crates/core/src/diagnose/rules.d/samples/jvm/mac_jdk8u261.log b/crates/core/src/diagnose/rules.d/samples/jvm/mac_jdk8u261.log new file mode 100644 index 0000000..9fe8637 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/jvm/mac_jdk8u261.log @@ -0,0 +1 @@ +Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'NSWindow drag regions should only be invalidated on the Main Thread!' diff --git a/crates/core/src/diagnose/rules.d/samples/jvm/modlauncher_8.log b/crates/core/src/diagnose/rules.d/samples/jvm/modlauncher_8.log new file mode 100644 index 0000000..07308da --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/jvm/modlauncher_8.log @@ -0,0 +1,3 @@ +[12:00:07] [main/ERROR]: ModLauncher failed +java.lang.NoSuchMethodError: 'void sun.security.util.ManifestEntryVerifier.(java.util.jar.Manifest)' + at cpw.mods.modlauncher.TransformingClassLoader.buildTransformedClassNodeFor(TransformingClassLoader.java:151) diff --git a/crates/core/src/diagnose/rules.d/samples/jvm/need_jdk11.log b/crates/core/src/diagnose/rules.d/samples/jvm/need_jdk11.log new file mode 100644 index 0000000..0fc8526 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/jvm/need_jdk11.log @@ -0,0 +1,2 @@ +[12:00:02] [main/ERROR]: Failed to start the game +java.lang.IllegalArgumentException: The requested compatibility level JAVA_11 could not be set. Level is not supported by the active JRE or ASM version diff --git a/crates/core/src/diagnose/rules.d/samples/jvm/openj9.log b/crates/core/src/diagnose/rules.d/samples/jvm/openj9.log new file mode 100644 index 0000000..346f9fb --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/jvm/openj9.log @@ -0,0 +1,2 @@ +[12:00:01] [main/INFO]: Loading Minecraft 1.20.1 with Forge 47.2.0 +OpenJ9 is incompatible with the authentication libraries used by this launcher diff --git a/crates/core/src/diagnose/rules.d/samples/jvm/too_old_java.log b/crates/core/src/diagnose/rules.d/samples/jvm/too_old_java.log new file mode 100644 index 0000000..0546e67 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/jvm/too_old_java.log @@ -0,0 +1,2 @@ +[12:00:03] [main/ERROR]: Could not find or load main class net.fabricmc.loader.impl.launch.knot.KnotClient +java.lang.UnsupportedClassVersionError: net/fabricmc/loader/impl/launch/knot/KnotClient has been compiled by a more recent version of the Java Runtime (class file version 61.0), this version of the Java Runtime only recognizes class file versions up to 52.0 diff --git a/crates/core/src/diagnose/rules.d/samples/memory/memory_exceeded.log b/crates/core/src/diagnose/rules.d/samples/memory/memory_exceeded.log new file mode 100644 index 0000000..83afa47 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/memory/memory_exceeded.log @@ -0,0 +1,3 @@ +# +# There is insufficient memory for the Java Runtime Environment to continue. +# Native memory allocation (mmap) failed to map 67108864 bytes for committing reserved memory. diff --git a/crates/core/src/diagnose/rules.d/samples/memory/out_of_memory.log b/crates/core/src/diagnose/rules.d/samples/memory/out_of_memory.log new file mode 100644 index 0000000..10c2b86 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/memory/out_of_memory.log @@ -0,0 +1,3 @@ +[12:00:10] [Render thread/ERROR]: Encountered an unexpected exception +java.lang.OutOfMemoryError: Java heap space + at net.minecraft.client.renderer.chunk.RenderChunk.rebuildChunk(RenderChunk.java:123) diff --git a/crates/core/src/diagnose/rules.d/samples/mod_conflict/jade_optifine.log b/crates/core/src/diagnose/rules.d/samples/mod_conflict/jade_optifine.log new file mode 100644 index 0000000..f6d34cc --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_conflict/jade_optifine.log @@ -0,0 +1 @@ +[12:01:15] [main/ERROR]: Critical injection failure: LVT in net/minecraft/client/renderer/GameRenderer::m_109093_(FJZ)V has incompatible changes at opcode 760 in callback jade.mixins.json:GameRendererMixin->@Inject::jade$runTick(FJZLorg/spongepowered/asm/mixin/injection/callback/CallbackInfo;IILcom/mojang/blaze3d/platform/Window;Lorg/joml/Matrix4f;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/gui/GuiGraphics;)V. diff --git a/crates/core/src/diagnose/rules.d/samples/mod_conflict/multipart_optifine.log b/crates/core/src/diagnose/rules.d/samples/mod_conflict/multipart_optifine.log new file mode 100644 index 0000000..54d6c01 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_conflict/multipart_optifine.log @@ -0,0 +1 @@ +[12:01:12] [main/ERROR]: Error occurred applying transform of coremod META-INF/asm/multipart.js function render diff --git a/crates/core/src/diagnose/rules.d/samples/mod_conflict/neoforge_optifine.log b/crates/core/src/diagnose/rules.d/samples/mod_conflict/neoforge_optifine.log new file mode 100644 index 0000000..34a268c --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_conflict/neoforge_optifine.log @@ -0,0 +1 @@ +[12:01:16] [main/ERROR]: cpw.mods.modlauncher.InvalidLauncherSetupException: Invalid Services found OptiFine diff --git a/crates/core/src/diagnose/rules.d/samples/mod_conflict/optifine_forge.log b/crates/core/src/diagnose/rules.d/samples/mod_conflict/optifine_forge.log new file mode 100644 index 0000000..f271bd8 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_conflict/optifine_forge.log @@ -0,0 +1,4 @@ +[12:01:10] [main/ERROR]: Failed to launch Forge +java.lang.NoSuchMethodError: 'java.lang.String com.mojang.blaze3d.systems.RenderSystem.getBackendDescription()' + at net.optifine.reflect.Reflector.(Reflector.java:455) + TRANSFORMER/net.optifine/net.optifine.reflect.Reflector.(Reflector.java:455) diff --git a/crates/core/src/diagnose/rules.d/samples/mod_conflict/optifine_world_load.log b/crates/core/src/diagnose/rules.d/samples/mod_conflict/optifine_world_load.log new file mode 100644 index 0000000..3bdf673 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_conflict/optifine_world_load.log @@ -0,0 +1 @@ +[12:01:17] [main/ERROR]: java.lang.NoSuchMethodError: net.minecraft.world.server.ChunkManager$ProxyTicketManager.shouldForceTicks(J)Z diff --git a/crates/core/src/diagnose/rules.d/samples/mod_conflict/performant_optifine.log b/crates/core/src/diagnose/rules.d/samples/mod_conflict/performant_optifine.log new file mode 100644 index 0000000..43cbeb0 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_conflict/performant_optifine.log @@ -0,0 +1 @@ +[12:01:13] [main/ERROR]: org.spongepowered.asm.mixin.injection.throwables.InjectionError: Critical injection failure: Redirector OnisOnLadder(Lnet/minecraft/block/BlockState;Lnet/minecraft/world/World;Lnet/minecraft/util/math/BlockPos;Lnet/minecraft/entity/LivingEntity;)Z in performant.mixins.json:entity.LivingEntityMixin failed injection check, (0/1) succeeded. Scanned 1 target(s). Using refmap performant.refmap.json diff --git a/crates/core/src/diagnose/rules.d/samples/mod_conflict/shaders_mod.log b/crates/core/src/diagnose/rules.d/samples/mod_conflict/shaders_mod.log new file mode 100644 index 0000000..0af6950 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_conflict/shaders_mod.log @@ -0,0 +1 @@ +[12:01:11] [main/ERROR]: java.lang.RuntimeException: Shaders Mod detected. Please remove it, OptiFine has built-in support for shaders. diff --git a/crates/core/src/diagnose/rules.d/samples/mod_conflict/twilight_optifine.log b/crates/core/src/diagnose/rules.d/samples/mod_conflict/twilight_optifine.log new file mode 100644 index 0000000..e58781c --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_conflict/twilight_optifine.log @@ -0,0 +1 @@ +[12:01:14] [main/ERROR]: java.lang.IllegalArgumentException: 512 outside of image bounds 256 diff --git a/crates/core/src/diagnose/rules.d/samples/mod_runtime/config_failed.log b/crates/core/src/diagnose/rules.d/samples/mod_runtime/config_failed.log new file mode 100644 index 0000000..b77ae0b --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_runtime/config_failed.log @@ -0,0 +1 @@ +[12:01:02] [main/ERROR]: Failed loading config file alexsmobs-common.toml of type COMMON for modid alexsmobs diff --git a/crates/core/src/diagnose/rules.d/samples/mod_runtime/forge_error.log b/crates/core/src/diagnose/rules.d/samples/mod_runtime/forge_error.log new file mode 100644 index 0000000..9a53a92 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_runtime/forge_error.log @@ -0,0 +1,3 @@ +[12:01:05] [main/ERROR]: An exception was thrown, the game will display an error screen and halt. +java.lang.NullPointerException: Cannot invoke "net.minecraft.world.level.Level.getBlockState()" because "level" is null + at com.example.mod.Foo.tick(Foo.java:123) diff --git a/crates/core/src/diagnose/rules.d/samples/mod_runtime/install_mixinbootstrap.log b/crates/core/src/diagnose/rules.d/samples/mod_runtime/install_mixinbootstrap.log new file mode 100644 index 0000000..e810221 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_runtime/install_mixinbootstrap.log @@ -0,0 +1 @@ +[12:01:07] [main/ERROR]: java.lang.ClassNotFoundException: org.spongepowered.asm.launch.MixinTweaker diff --git a/crates/core/src/diagnose/rules.d/samples/mod_runtime/loading_crashed_fabric.log b/crates/core/src/diagnose/rules.d/samples/mod_runtime/loading_crashed_fabric.log new file mode 100644 index 0000000..70bb109 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_runtime/loading_crashed_fabric.log @@ -0,0 +1 @@ +[12:01:01] [main/ERROR]: Could not execute entrypoint stage 'main' due to errors, provided by 'sodium'! diff --git a/crates/core/src/diagnose/rules.d/samples/mod_runtime/loading_crashed_forge.log b/crates/core/src/diagnose/rules.d/samples/mod_runtime/loading_crashed_forge.log new file mode 100644 index 0000000..786ce0c --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_runtime/loading_crashed_forge.log @@ -0,0 +1 @@ +[12:01:00] [main/ERROR]: LoaderExceptionModCrash: Caught exception from Alex's Mobs (alexsmobs) diff --git a/crates/core/src/diagnose/rules.d/samples/mod_runtime/mixin_apply_failed.log b/crates/core/src/diagnose/rules.d/samples/mod_runtime/mixin_apply_failed.log new file mode 100644 index 0000000..60d2499 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_runtime/mixin_apply_failed.log @@ -0,0 +1 @@ +[12:01:04] [main/ERROR]: Mixin apply for mod examplemod failed examplemod.mixins.json:MixinFoo from mod examplemod->@Inject::handler diff --git a/crates/core/src/diagnose/rules.d/samples/mod_runtime/mixin_failure.log b/crates/core/src/diagnose/rules.d/samples/mod_runtime/mixin_failure.log new file mode 100644 index 0000000..40c6276 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_runtime/mixin_failure.log @@ -0,0 +1 @@ +[12:01:03] [main/ERROR]: org.spongepowered.asm.mixin.injection.throwables.InvalidInjectionException: Critical injection failure: @Inject annotation could not find target method diff --git a/crates/core/src/diagnose/rules.d/samples/mod_runtime/night_config_fixes.log b/crates/core/src/diagnose/rules.d/samples/mod_runtime/night_config_fixes.log new file mode 100644 index 0000000..1a4cbe5 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_runtime/night_config_fixes.log @@ -0,0 +1 @@ +[12:01:06] [main/ERROR]: com.electronwill.nightconfig.core.io.ParsingException: Not enough data available diff --git a/crates/core/src/diagnose/rules.d/samples/other/debug_crash.log b/crates/core/src/diagnose/rules.d/samples/other/debug_crash.log new file mode 100644 index 0000000..eef42ff --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/other/debug_crash.log @@ -0,0 +1 @@ +[12:01:30] [Render thread/INFO]: Manually triggered debug crash diff --git a/crates/core/src/diagnose/rules.d/samples/world/block_model.log b/crates/core/src/diagnose/rules.d/samples/world/block_model.log new file mode 100644 index 0000000..627f520 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/world/block_model.log @@ -0,0 +1,6 @@ +[12:01:21] [Worker-Main-1/ERROR]: Unable to bake model +java.lang.RuntimeException: Failed to tessellate +-- Block being tessellated -- +Details: + Block: minecraft:grass_block + Block location: World: (12,64,-34) diff --git a/crates/core/src/diagnose/rules.d/samples/world/entity_tick.log b/crates/core/src/diagnose/rules.d/samples/world/entity_tick.log new file mode 100644 index 0000000..f0bfd21 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/world/entity_tick.log @@ -0,0 +1,6 @@ +[12:01:20] [Server thread/ERROR]: Encountered an unexpected exception +java.lang.NullPointerException: Ticking entity +-- Entity being ticked -- +Details: + Entity Type: minecraft:cow (net.minecraft.world.entity.animal.Cow) + Entity's Exact location: 123.45, 64.00, -67.89 diff --git a/crates/core/src/diagnose/rules.d/world.yaml b/crates/core/src/diagnose/rules.d/world.yaml new file mode 100644 index 0000000..7c197eb --- /dev/null +++ b/crates/core/src/diagnose/rules.d/world.yaml @@ -0,0 +1,39 @@ +# 规则库 · 游戏实体/方块(2 条) +# 移植自 HMCL CrashReportAnalyzer.Rule 的实体/方块组(analyse/02 §7.3 第 9 组): +# 崩在 tick 时能拿到实体类型/方块类型与坐标,是「哪个 mod 的实体坏了」的直接线索。 +version: 1 +category: world +rules: + - id: world.entity_tick + severity: critical + scope: file + pattern: "Entity Type: (?.*)[\\w\\W\\n\\r]*?Entity's Exact location: (?.*)" + confidence: 0.75 + priority: 70 + cause: + key: diagnose.cause.world.entity_tick + summary: '崩在实体 tick:实体 {type}(坐标 {location})' + detail: '看看这个实体来自哪个 mod(血条/怪物增强/刷怪类 mod 居多),禁用该 mod 或删掉存档里这个实体' + fix_action: + - kind: remove_mod + action: disable + note: '按实体类型定位 mod' + sample: samples/world/entity_tick.log + source: 'HMCL CrashReportAnalyzer.Rule.ENTITY' + + - id: world.block_model + severity: warning + scope: file + pattern: 'Block: (?.*)[\w\W\n\r]*?Block location: (?.*)' + confidence: 0.65 + priority: 65 + cause: + key: diagnose.cause.world.block_model + summary: '崩在方块({type},坐标 {location}):多半是方块模型/贴图相关 mod 的问题' + detail: '定位该方块所属 mod;资源包也可能导致(先换回原版资源包验证)' + fix_action: + - kind: remove_mod + action: disable + note: '按方块类型定位 mod / 先换回原版资源包' + sample: samples/world/block_model.log + source: 'HMCL CrashReportAnalyzer.Rule.BLOCK' diff --git a/crates/core/src/diagnose/rules.rs b/crates/core/src/diagnose/rules.rs new file mode 100644 index 0000000..c61e852 --- /dev/null +++ b/crates/core/src/diagnose/rules.rs @@ -0,0 +1,319 @@ +//! 诊断规则模型:YAML schema + 编译后的规则。 +//! +//! 设计定稿:`DevDocs/PlanDoc/06-launch-and-diagnostics.md` §2.2–§2.3; +//! 规则来源:HMCL `CrashReportAnalyzer.Rule` 59 条(`DevDocs/analyse/02-launch-pipeline.md` §7.3)。 +//! 字段说明与给 M16/M18 的契约见同目录 `CONTRACT.md`。 +//! +//! 相对 HMCL「枚举即签名库」的四处升级(analyse/02 §7.10): +//! 1. 规则是**数据**不是代码:加规则只改 `rules.d/*.yaml`,不重编译; +//! 2. 每条规则带 `confidence`/`priority`/`suppress`,可做主因排序(HMCL 只能平铺); +//! 3. 命名捕获组由正则自声明,不再有 `groupNames` 顺序契约; +//! 4. 每条规则绑定六维修复动作,直接成为 agent 工具的参数来源。 + +use std::collections::BTreeMap; +use std::fmt; + +use regex::Regex; +use serde::Deserialize; + +// 六维修复动作按体量拆两个文件(本目录文件上限 400 行),从本模块统一导出公开面。 +#[path = "fix_action.rs"] +mod fix_action; +#[path = "fix_params.rs"] +mod fix_params; + +pub use fix_action::{class_file_to_java_major, FixAction, FixActionKind, MaterializedFixAction}; +pub use fix_params::{ + AdjustMemory, ChangeGraphics, ChangeJava, ComponentKind, GraphicsSetting, JavaVendor, + ModAction, Reinstall, RemoveMod, SeekHelp, +}; + +/// 规则分类:HMCL 59 条规则的 10 个根因分组,与 `rules.d/.yaml` 一一对应。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuleCategory { + /// JVM 实现 / Java 版本不兼容。 + Jvm, + /// 堆内存不足、系统内存耗尽。 + Memory, + /// 显卡驱动 / OpenGL / 分辨率 / 图形覆盖层。 + Graphics, + /// 文件被改、安装不完整、原生库缺失。 + Integrity, + /// Fabric 的 ModResolutionException 家族。 + Fabric, + /// Forge 的依赖解析 / 重复安装 / mod 元数据。 + Forge, + /// mod 加载期或运行期崩溃(含 mixin)。 + ModRuntime, + /// 具体 mod 与 OptiFine 等成名冲突。 + ModConflict, + /// 崩在某个实体 / 方块的 tick。 + World, + /// 其它(手动触发调试崩溃等)。 + Other, +} + +impl RuleCategory { + /// 稳定字符串(也用于诊断输出的 `category` 字段)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Jvm => "jvm", + Self::Memory => "memory", + Self::Graphics => "graphics", + Self::Integrity => "integrity", + Self::Fabric => "fabric", + Self::Forge => "forge", + Self::ModRuntime => "mod_runtime", + Self::ModConflict => "mod_conflict", + Self::World => "world", + Self::Other => "other", + } + } + + /// 全部分类(loader 校验规则文件归属、测试遍历用)。 + #[must_use] + pub const fn all() -> [Self; 10] { + [ + Self::Jvm, + Self::Memory, + Self::Graphics, + Self::Integrity, + Self::Fabric, + Self::Forge, + Self::ModRuntime, + Self::ModConflict, + Self::World, + Self::Other, + ] + } +} + +impl fmt::Display for RuleCategory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// 结论严重度(UI 配色与「是否弹窗」的依据)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Severity { + /// 提示性:能跑但不最佳。 + Info, + /// 警告:可疑但结论不确定。 + #[default] + Warning, + /// 严重:基本可以确定崩溃原因。 + Critical, +} + +impl Severity { + /// 稳定字符串(诊断输出用)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Info => "info", + Self::Warning => "warning", + Self::Critical => "critical", + } + } +} + +impl fmt::Display for Severity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// 匹配范围:决定引擎怎么切文本,也是性能开关(analyse/02 §8.3 第 1 条)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MatchScope { + /// 逐行匹配(默认,绝大多数 Java 异常都是单行签名)。 + #[default] + Line, + /// 按空行切块匹配(一段异常 + 上下文)。 + Block, + /// 整份文本匹配(仅跨行贪婪模式:mod 解析列表、实体 tick 上下文等)。 + File, +} + +/// 结论文案(`summary` 支持 `{group}` 占位符,`key` 是稳定的 i18n/AI 引用 id)。 +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Cause { + /// 稳定 id,如 `diagnose.cause.jvm.too_old_java`。 + pub key: String, + /// 一句话结论(中文,可直接给用户看)。 + pub summary: String, + /// 更细的解释 / 处理建议。 + #[serde(default)] + pub detail: Option, +} + +impl Cause { + /// 代入捕获组后的(结论, 细化说明)。 + #[must_use] + pub fn render(&self, groups: &BTreeMap) -> (String, Option) { + ( + substitute(&self.summary, groups), + self.detail + .as_ref() + .map(|detail| substitute(detail, groups)), + ) + } +} + +/// 一条规则的 YAML schema。 +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Rule { + /// 全局唯一 id(`命名空间.短名`)。 + pub id: String, + /// 严重度。 + #[serde(default)] + pub severity: Severity, + /// 正则(Rust `regex` 语法,命名组用 `(?…)`)。 + pub pattern: String, + /// 匹配范围。 + #[serde(default)] + pub scope: MatchScope, + /// 命中可信度 0.0~1.0。 + #[serde(default = "default_confidence")] + pub confidence: f32, + /// 主因排序权重 0~100。 + #[serde(default = "default_priority")] + pub priority: u8, + /// 命中本规则时把列出的规则降级为次生结论。 + #[serde(default)] + pub suppress: Vec, + /// 结论文案。 + pub cause: Cause, + /// 六维修复动作。 + #[serde(default)] + pub fix_action: Vec, + /// 回归样本路径(相对 `rules.d/`)。 + #[serde(default)] + pub sample: Option, + /// 出处(HMCL 枚举名 / issue 链接)。 + #[serde(default)] + pub source: Option, +} + +fn default_confidence() -> f32 { + 0.5 +} + +fn default_priority() -> u8 { + 50 +} + +/// 编译后的规则(正则只编译一次)。 +#[derive(Debug, Clone)] +pub struct CompiledRule { + rule: Rule, + category: RuleCategory, + regex: Regex, +} + +impl CompiledRule { + /// 编译一条规则。 + pub fn compile(rule: Rule, category: RuleCategory) -> Result { + let regex = Regex::new(&rule.pattern).map_err(|err| RuleError::InvalidPattern { + id: rule.id.clone(), + message: err.to_string(), + })?; + Ok(Self { + rule, + category, + regex, + }) + } + + /// 原始规则。 + #[must_use] + pub const fn rule(&self) -> &Rule { + &self.rule + } + + /// 规则 id。 + #[must_use] + pub fn id(&self) -> &str { + &self.rule.id + } + + /// 所属分类。 + #[must_use] + pub const fn category(&self) -> RuleCategory { + self.category + } + + /// 匹配范围。 + #[must_use] + pub const fn scope(&self) -> MatchScope { + self.rule.scope + } + + /// 编译好的正则。 + #[must_use] + pub const fn regex(&self) -> &Regex { + &self.regex + } + + /// 命名捕获组名(文档/测试用;顺序无关,取代 HMCL 的 `groupNames` 契约)。 + #[must_use] + pub fn named_groups(&self) -> Vec<&str> { + self.regex.capture_names().flatten().collect() + } +} + +/// 规则编译错误。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RuleError { + /// 正则非法(规则 id + 正则库报错原文)。 + InvalidPattern { + /// 出错的规则 id。 + id: String, + /// 正则库报错。 + message: String, + }, +} + +impl fmt::Display for RuleError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidPattern { id, message } => { + write!(f, "规则 {id} 的正则非法:{message}") + } + } + } +} + +impl std::error::Error for RuleError {} + +/// 把 `{group}` 占位符替换成捕获组值;未知占位符原样保留(便于发现规则写错)。 +#[must_use] +pub fn substitute(template: &str, groups: &BTreeMap) -> String { + if !template.contains('{') { + return template.to_owned(); + } + let mut out = String::with_capacity(template.len()); + let mut rest = template; + while let Some(start) = rest.find('{') { + let Some(close) = rest[start..].find('}') else { + break; + }; + let end = start + close; + let name = &rest[start + 1..end]; + out.push_str(&rest[..start]); + match groups.get(name) { + Some(value) => out.push_str(value), + None => out.push_str(&rest[start..=end]), + } + rest = &rest[end + 1..]; + } + out.push_str(rest); + out +} diff --git a/crates/core/src/diagnose/signals.rs b/crates/core/src/diagnose/signals.rs new file mode 100644 index 0000000..73a34d7 --- /dev/null +++ b/crates/core/src/diagnose/signals.rs @@ -0,0 +1,396 @@ +//! 采集到的原子信号:日志环形缓冲 + 退出信号。 +//! +//! 五类信号里的「退出码」和「日志环形缓冲」两种(analyse/02 §7.1 信号 A/B)。 +//! 环境快照(信号 E)与 mod 清单在 [`crate::diagnose::collectors`] 里,因为它带采集动作。 +//! +//! 环形缓冲相对 HMCL `Log.java`(固定 2000 行、一律淘汰)的升级: +//! 命中 `Exception|Error|Caused by|OutOfMemory` 的行**永久保留**, +//! 解决「崩在启动早期、根因被后续输出挤出缓冲区」(analyse/02 §7.10-3)。 + +use std::collections::VecDeque; +use std::fmt; + +/// 一条日志文本的来源(诊断输出的 `evidence[]` 前缀也是它)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SignalOrigin { + /// 游戏进程 stdout。 + Stdout, + /// 游戏进程 stderr。 + Stderr, + /// `logs/latest.log`(内存缓冲不够长时的兜底)。 + LatestLog, + /// `crash-reports/crash-*.txt`。 + CrashReport, + /// `hs_err_pid*.log` 的摘要文本。 + HsErr, + /// 启动器自身日志 / 结构化事件(`cmd_built`、`assembly_failed` 等)。 + Launcher, +} + +impl SignalOrigin { + /// 稳定字符串(evidence 前缀 / 序列化用)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Stdout => "stdout", + Self::Stderr => "stderr", + Self::LatestLog => "latest_log", + Self::CrashReport => "crash_report", + Self::HsErr => "hs_err", + Self::Launcher => "launcher", + } + } + + /// `launch` 侧 `LineSink` 的 `stream` 字段映射(`stdout` / `stderr`)。 + #[must_use] + pub fn from_stream_name(stream: &str) -> Self { + match stream.trim().to_ascii_lowercase().as_str() { + "stderr" => Self::Stderr, + _ => Self::Stdout, + } + } + + /// 取证优先级:同一规则在多个来源命中时保留 rank 最小者(崩溃报告 > hs_err > 日志)。 + #[must_use] + pub const fn evidence_rank(self) -> u8 { + match self { + Self::CrashReport => 0, + Self::HsErr => 1, + Self::Launcher => 2, + Self::LatestLog => 3, + Self::Stdout => 4, + Self::Stderr => 5, + } + } +} + +impl fmt::Display for SignalOrigin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// 环形缓冲里的一行。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogLine { + /// 来源。 + pub origin: SignalOrigin, + /// 行文本(不含行尾换行)。 + pub text: String, +} + +impl LogLine { + /// 构造一行。 + #[must_use] + pub fn new(origin: SignalOrigin, text: impl Into) -> Self { + Self { + origin, + text: text.into(), + } + } +} + +/// 是否「关键行」:不随环形淘汰(HMCL 局限 3 的正面修正)。 +#[must_use] +pub fn is_key_line(text: &str) -> bool { + const MARKERS: [&str; 4] = ["Exception", "Error", "Caused by", "OutOfMemory"]; + MARKERS.iter().any(|marker| text.contains(marker)) +} + +/// 日志环形缓冲:最近 N 行 + 被判为关键行的历史永久保留。 +#[derive(Debug, Clone)] +pub struct LogBuffer { + capacity: usize, + key_capacity: usize, + recent: VecDeque, + /// 已从环形区淘汰、但因为是关键行而留下来的行(按时间顺序)。 + kept: VecDeque, + pushed: usize, + dropped: usize, +} + +impl Default for LogBuffer { + fn default() -> Self { + Self::new(2000) + } +} + +impl LogBuffer { + /// 默认容量 2000 行(与 HMCL 的 `Log` 一致)。 + #[must_use] + pub fn new(capacity: usize) -> Self { + Self { + capacity: capacity.max(1), + key_capacity: capacity.max(1), + recent: VecDeque::new(), + kept: VecDeque::new(), + pushed: 0, + dropped: 0, + } + } + + /// 关键行保留上限(默认 = 环形容量)。 + #[must_use] + pub fn with_key_capacity(mut self, key_capacity: usize) -> Self { + self.key_capacity = key_capacity.max(1); + self + } + + /// 记一行(`launch` 的 stdout/stderr 事件逐行喂进来)。 + pub fn push(&mut self, origin: SignalOrigin, line: &str) { + let text = line.trim_end_matches(['\r', '\n']).to_owned(); + self.pushed += 1; + if self.recent.len() >= self.capacity { + if let Some(evicted) = self.recent.pop_front() { + self.dropped += 1; + if is_key_line(&evicted.text) { + if self.kept.len() >= self.key_capacity { + self.kept.pop_front(); + } + self.kept.push_back(evicted); + } + } + } + self.recent.push_back(LogLine::new(origin, text)); + } + + /// 一次性喂多行(导入日志、`latest.log` 兜底)。 + pub fn extend<'a, I>(&mut self, origin: SignalOrigin, lines: I) + where + I: IntoIterator, + { + for line in lines { + self.push(origin, line); + } + } + + /// 按时间顺序返回全部保留行(关键行历史在前,最近 N 行在后)。 + pub fn lines(&self) -> impl Iterator { + self.kept.iter().chain(self.recent.iter()) + } + + /// 拼成可供规则匹配的文本(`\n` 连接)。 + #[must_use] + pub fn text(&self) -> String { + let mut out = String::with_capacity(self.byte_len()); + for line in self.lines() { + out.push_str(&line.text); + out.push('\n'); + } + out + } + + /// 当前保留行数。 + #[must_use] + pub fn len(&self) -> usize { + self.kept.len() + self.recent.len() + } + + /// 是否一行都没有。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// 一共推入多少行。 + #[must_use] + pub const fn pushed(&self) -> usize { + self.pushed + } + + /// 被淘汰多少行(不含被永久保留的关键行)。 + #[must_use] + pub const fn dropped(&self) -> usize { + self.dropped + } + + /// 因关键行规则而保留下来的历史行数。 + #[must_use] + pub fn kept_key_lines(&self) -> usize { + self.kept.len() + } + + fn byte_len(&self) -> usize { + self.lines().map(|line| line.text.len() + 1).sum() + } +} + +/// 退出类型。字符串值与 `launch-接口约定.md` §3 `exit.exit_type` 完全一致。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub enum ExitType { + /// 正常退出(但可能是静默失败,见 [`ExitSignal::silent_failure`])。 + #[default] + Normal, + /// 应用层崩溃(有崩溃关键字 / 非 0 退出码)。 + ApplicationError, + /// JVM 没起来(`-Xmx` 过大、参数非法、JVM 致命错误)。 + JvmError, + /// 被 SIGKILL(Linux 137 / 系统 OOM killer)。 + Sigkill, + /// 被 SIGTERM(143)。 + Sigterm, + /// 被启动器主动停止(用户取消 / watchdog)。 + Killed, + /// 等待被中断(会话被丢弃)。 + Interrupted, +} + +impl ExitType { + /// 稳定字符串(与 launch 约定同值)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Normal => "normal", + Self::ApplicationError => "application_error", + Self::JvmError => "jvm_error", + Self::Sigkill => "sigkill", + Self::Sigterm => "sigterm", + Self::Killed => "killed", + Self::Interrupted => "interrupted", + } + } + + /// 从 launch 约定字符串反解(未知值回落 `ApplicationError`,绝不 panic)。 + #[must_use] + pub fn from_name(name: &str) -> Self { + match name.trim().to_ascii_lowercase().as_str() { + "normal" => Self::Normal, + "jvm_error" => Self::JvmError, + "sigkill" => Self::Sigkill, + "sigterm" => Self::Sigterm, + "killed" => Self::Killed, + "interrupted" => Self::Interrupted, + _ => Self::ApplicationError, + } + } + + /// 是否异常结束(非 `Normal`)。 + #[must_use] + pub const fn is_abnormal(self) -> bool { + !matches!(self, Self::Normal) + } +} + +impl fmt::Display for ExitType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// JVM 没起来的三个签名(analyse/02 §7.2,`JVM_ERROR` 判定依据)。 +pub const JVM_ERROR_KEYWORDS: [&str; 3] = [ + "Could not create the Java Virtual Machine", + "Error occurred during initialization of VM", + "A fatal exception has occurred. Program will exit.", +]; + +/// 应用层崩溃关键字(HMCL `ExitWaiter` 的 5 条 + 我们能拿到的补充)。 +pub const CRASH_KEYWORDS: [&str; 5] = [ + "Game crashed! Crash report saved to", + "Exception in thread", + "Unhandled exception", + "Unable to launch", + "An exception was thrown, the game will display an error screen and halt", +]; + +/// Windows 常见退出码(`0xC0000005` 等按 `i32` 表示,analyse/02 §8.4-2)。 +pub const EXIT_ACCESS_VIOLATION: i32 = -1_073_741_819; +/// `0xC0000409` STACK_BUFFER_OVERRUN。 +pub const EXIT_STACK_BUFFER_OVERRUN: i32 = -1_073_740_791; +/// `0xC0000135` DLL_NOT_FOUND。 +pub const EXIT_DLL_NOT_FOUND: i32 = -1_073_741_515; +/// POSIX SIGKILL(128 + 9)。 +pub const EXIT_SIGKILL: i32 = 137; +/// POSIX SIGTERM(128 + 15)。 +pub const EXIT_SIGTERM: i32 = 143; + +/// 进程退出信号(`launch` 的 `exit` 事件映射过来;离线诊断时由 [`ExitSignal::classify`] 反推)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ExitSignal { + /// 退出码(被信号杀死时为负或 128+N)。 + pub code: Option, + /// 退出类型。 + pub exit_type: ExitType, + /// 进程存活毫秒数。 + pub uptime_ms: Option, + /// stderr/错误行条数。 + pub error_lines: u32, + /// 退出码 0 但疑似静默失败(存活 <30s 或 stderr 有错误特征)。 + pub silent_failure: bool, + /// Windows 状态码原始串(`0xc0000005`)。 + pub windows_status: Option, + /// 命中的崩溃关键字。 + pub crash_keywords: Vec, +} + +impl ExitSignal { + /// 从原始退出事实推断类型(离线诊断 / 导入日志用;launch 侧有权威版本)。 + #[must_use] + pub fn classify( + code: Option, + uptime_ms: Option, + error_lines: u32, + error_text: &str, + ) -> Self { + let has = |keywords: &[&str]| keywords.iter().any(|kw| error_text.contains(kw)); + let mut crash_keywords = Vec::new(); + for keyword in CRASH_KEYWORDS { + if error_text.contains(keyword) { + crash_keywords.push(keyword.to_owned()); + } + } + let exit_type = match code { + Some(EXIT_SIGKILL) => ExitType::Sigkill, + Some(EXIT_SIGTERM) => ExitType::Sigterm, + // 与 HMCL `ExitType` 一致:只有「非 0 退出码 + JVM 关键字」才算 JVM_ERROR。 + // exit 0 却打出这些关键字的场景归到靜默失败(silent_failure)而不是 JVM 错误。 + Some(0) => ExitType::Normal, + Some(_) if has(&JVM_ERROR_KEYWORDS) => ExitType::JvmError, + Some(_) => ExitType::ApplicationError, + None => ExitType::ApplicationError, + }; + let short_lived = uptime_ms.is_some_and(|ms| ms < 30_000); + let silent_failure = exit_type == ExitType::Normal + && (short_lived || error_lines > 0 || has(&CRASH_KEYWORDS)); + Self { + code, + exit_type, + uptime_ms, + error_lines, + silent_failure, + windows_status: code.and_then(hex_status), + crash_keywords, + } + } + + /// Windows 状态码的十六进制串(如 `0xc0000005`)。 + #[must_use] + pub fn status_hex(&self) -> Option { + self.windows_status + .clone() + .or_else(|| self.code.and_then(hex_status)) + } + + /// 退出码是否是 Windows 访问违例(`0xC0000005`)。 + #[must_use] + pub fn is_access_violation(&self) -> bool { + self.code == Some(EXIT_ACCESS_VIOLATION) + } + + /// 一句话摘要(写进诊断报告 / 证据行)。 + #[must_use] + pub fn summary(&self) -> String { + let code = self + .code + .map_or_else(|| "未知".to_owned(), |code| code.to_string()); + let uptime = self + .uptime_ms + .map_or_else(String::new, |ms| format!(",存活 {} ms", ms)); + format!("退出码 {code}({}){uptime}", self.exit_type) + } +} + +fn hex_status(code: i32) -> Option { + // 只有 Windows 的 NTSTATUS 形态(负数高位为 1)才有意义。 + (code < 0).then(|| format!("0x{:08x}", code as u32)) +} diff --git a/crates/core/src/diagnose/sources.rs b/crates/core/src/diagnose/sources.rs new file mode 100644 index 0000000..49cddd1 --- /dev/null +++ b/crates/core/src/diagnose/sources.rs @@ -0,0 +1,299 @@ +//! 五类信号的具体采集器(推送类 2 个 + 磁盘类 3 个)。 +//! +//! 由 `collectors.rs` 用 `#[path]` 声明并统一导出;拆分只为守单文件 400 行上限。 + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use crate::diagnose::hs_err; + +use super::{ + CapturedText, CollectError, CollectedSources, CollectionContext, EnvironmentSnapshot, + ExitSignal, SignalBundle, SignalCollector, SignalKind, SignalOrigin, +}; + +/// 推送类:退出信号(launch 的 `exit` 事件已算好)。 +#[derive(Debug, Clone)] +pub struct ExitCollector { + signal: ExitSignal, +} + +impl ExitCollector { + /// 构造。 + #[must_use] + pub fn new(signal: ExitSignal) -> Self { + Self { signal } + } +} + +impl SignalCollector for ExitCollector { + fn kind(&self) -> SignalKind { + SignalKind::Exit + } + + fn collect( + &self, + _ctx: &CollectionContext, + out: &mut SignalBundle, + ) -> Result { + out.exit = Some(self.signal.clone()); + Ok(CollectedSources::new()) + } +} + +/// 推送类:环境快照 + mod 清单(launch/game 侧已有数据)。 +#[derive(Debug, Clone, Default)] +pub struct EnvironmentCollector { + snapshot: EnvironmentSnapshot, +} + +impl EnvironmentCollector { + /// 构造。 + #[must_use] + pub fn new(snapshot: EnvironmentSnapshot) -> Self { + Self { snapshot } + } +} + +impl SignalCollector for EnvironmentCollector { + fn kind(&self) -> SignalKind { + SignalKind::Environment + } + + fn collect( + &self, + _ctx: &CollectionContext, + out: &mut SignalBundle, + ) -> Result { + out.mods = self.snapshot.mods.clone(); + out.environment = Some(self.snapshot.clone()); + Ok(CollectedSources::new()) + } +} + +/// 磁盘类:`crash-reports/crash-*.txt`(双通道里的「权威全文」)。 +#[derive(Debug, Clone, Default)] +pub struct CrashReportCollector { + dir: Option, +} + +impl CrashReportCollector { + /// 默认扫 `/crash-reports`。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 指定目录。 + #[must_use] + pub fn with_dir(dir: impl Into) -> Self { + Self { + dir: Some(dir.into()), + } + } +} + +impl SignalCollector for CrashReportCollector { + fn kind(&self) -> SignalKind { + SignalKind::CrashReport + } + + fn collect( + &self, + ctx: &CollectionContext, + out: &mut SignalBundle, + ) -> Result { + let dir = self + .dir + .clone() + .unwrap_or_else(|| ctx.run_dir.join("crash-reports")); + let Some(path) = newest_file(&dir, ctx.started_at, |name| { + name.starts_with("crash-") && name.ends_with(".txt") + }) else { + return Ok(CollectedSources::new()); + }; + let text = read_lossy(SignalKind::CrashReport, &path)?; + let modified = modified_at(&path); + let mut sources = CollectedSources::new(); + sources.push(&path); + out.sources.push(path.clone()); + out.crash_report = Some(CapturedText { + path, + text, + modified, + }); + Ok(sources) + } +} + +/// 磁盘类:`hs_err_pid*.log`(HMCL 的空白区,显卡驱动类崩溃的唯一权威证据)。 +#[derive(Debug, Clone, Default)] +pub struct HsErrCollector { + dirs: Vec, +} + +impl HsErrCollector { + /// 默认扫 ``(JVM 把 hs_err 写在进程工作目录)。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 指定候选目录表(**整表替换**,不是追加;给出后不再自动扫 `run_dir`)。 + /// + /// 典型用法:`HsErrCollector::with_dirs([run_dir.clone(), home_dir])`。 + #[must_use] + pub fn with_dirs(dirs: impl IntoIterator) -> Self { + Self { + dirs: dirs.into_iter().collect(), + } + } +} + +impl SignalCollector for HsErrCollector { + fn kind(&self) -> SignalKind { + SignalKind::HsErr + } + + fn collect( + &self, + ctx: &CollectionContext, + out: &mut SignalBundle, + ) -> Result { + let dirs = if self.dirs.is_empty() { + vec![ctx.run_dir.clone()] + } else { + self.dirs.clone() + }; + let mut newest: Option = None; + for dir in dirs { + if let Some(path) = newest_file(&dir, ctx.started_at, |name| { + name.starts_with("hs_err_pid") && name.ends_with(".log") + }) { + newest = match newest { + Some(current) => match (modified_at(¤t), modified_at(&path)) { + (Some(a), Some(b)) if b <= a => Some(current), + _ => Some(path), + }, + None => Some(path), + }; + } + } + let Some(path) = newest else { + return Ok(CollectedSources::new()); + }; + let report = hs_err::parse_file(&path).map_err(|error| CollectError { + kind: SignalKind::HsErr, + path: Some(path.clone()), + message: error.to_string(), + })?; + let mut sources = CollectedSources::new(); + sources.push(&path); + out.sources.push(path); + out.hs_err = Some(report); + Ok(sources) + } +} + +/// 磁盘类:`logs/latest.log` 尾巴(内存缓冲不够长时的兜底)。 +#[derive(Debug, Clone)] +pub struct LatestLogCollector { + path: Option, + max_lines: usize, +} + +impl Default for LatestLogCollector { + fn default() -> Self { + Self::new() + } +} + +impl LatestLogCollector { + /// 默认读 `/logs/latest.log` 的最后 5000 行。 + #[must_use] + pub fn new() -> Self { + Self { + path: None, + max_lines: 5000, + } + } + + /// 保留行数上限。 + #[must_use] + pub fn with_max_lines(mut self, max_lines: usize) -> Self { + self.max_lines = max_lines.max(1); + self + } + + /// 指定文件。 + #[must_use] + pub fn with_path(path: impl Into) -> Self { + Self { + path: Some(path.into()), + max_lines: 5000, + } + } +} + +impl SignalCollector for LatestLogCollector { + fn kind(&self) -> SignalKind { + SignalKind::Log + } + + fn collect( + &self, + ctx: &CollectionContext, + out: &mut SignalBundle, + ) -> Result { + let path = self + .path + .clone() + .unwrap_or_else(|| ctx.run_dir.join("logs").join("latest.log")); + if !path.is_file() { + return Ok(CollectedSources::new()); + } + let text = read_lossy(SignalKind::Log, &path)?; + let lines = text.lines().collect::>(); + let start = lines.len().saturating_sub(self.max_lines); + out.log + .extend(SignalOrigin::LatestLog, lines[start..].iter().copied()); + let mut sources = CollectedSources::new(); + sources.push(&path); + out.sources.push(path); + Ok(sources) + } +} + +fn newest_file( + dir: &Path, + since: Option, + matches: impl Fn(&str) -> bool, +) -> Option { + let entries = fs::read_dir(dir).ok()?; + entries + .flatten() + .filter_map(|entry| { + let name = entry.file_name().to_string_lossy().into_owned(); + if !matches(&name) { + return None; + } + let path = entry.path(); + let modified = modified_at(&path)?; + if since.is_some_and(|since| modified < since) { + return None; + } + Some((modified, path)) + }) + .max_by(|a, b| a.0.cmp(&b.0)) + .map(|(_, path)| path) +} + +fn modified_at(path: &Path) -> Option { + fs::metadata(path).ok()?.modified().ok() +} + +fn read_lossy(kind: SignalKind, path: &Path) -> Result { + let bytes = fs::read(path).map_err(|error| CollectError::io(kind, path, &error))?; + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} diff --git a/crates/core/src/diagnose/synthetic.rs b/crates/core/src/diagnose/synthetic.rs new file mode 100644 index 0000000..3353bd6 --- /dev/null +++ b/crates/core/src/diagnose/synthetic.rs @@ -0,0 +1,279 @@ +//! 引擎内置结论:退出码 / hs_err 这类**结构化信号**没有文本签名,塞 YAML 别扭,放在这里。 +//! +//! 它们参与和其它规则一样的排序与抑制流程(`priority`/`confidence`/`suppress` 语义相同), +//! 所以「主因是驱动、次生是图形规则」这种关系能自动成立。 +//! 由 `engine.rs` 用 `#[path]` 声明,拆分只为守单文件 400 行上限。 + +use super::{DiagnosisInput, Evidence, Finding}; +use crate::diagnose::collectors::{ + ExitSignal, ExitType, SignalOrigin, EXIT_DLL_NOT_FOUND, EXIT_STACK_BUFFER_OVERRUN, +}; +use crate::diagnose::hs_err::{HsErrKind, HsErrReport}; +use crate::diagnose::rules::{ + AdjustMemory, ChangeGraphics, ChangeJava, ComponentKind, FixAction, GraphicsSetting, + JavaVendor, Reinstall, RuleCategory, SeekHelp, Severity, +}; + +/// 所有内置结论。 +#[must_use] +pub fn synthetic_findings(input: &DiagnosisInput) -> Vec { + let mut out = Vec::new(); + if let Some(exit) = &input.exit { + out.extend(exit_findings(exit)); + } + if let Some(report) = &input.hs_err { + out.extend(hs_err_findings(report)); + } + out +} + +fn exit_findings(exit: &ExitSignal) -> Vec { + let mut out = Vec::new(); + if exit.is_access_violation() { + let mut finding = base( + "signal.access_violation", + RuleCategory::Graphics, + Severity::Critical, + 0.55, + 82, + "diagnose.cause.signal.access_violation", + &format!( + "进程以 0xC0000005(访问违例)退出:多半是显卡驱动/渲染栈崩溃,建议先更新显卡驱动或换渲染器({})", + exit.summary() + ), + ); + finding.fix_actions = vec![FixAction::ChangeGraphics(ChangeGraphics { + setting: GraphicsSetting::Driver, + update_driver: true, + suggested_value: None, + note: Some("访问违例优先怀疑显卡驱动".to_owned()), + })]; + finding.suppress = vec!["graphics.graphics_driver".to_owned()]; + finding.evidence = vec![Evidence::new(SignalOrigin::Launcher, 0, exit.summary())]; + out.push(finding); + } + if exit.code == Some(EXIT_DLL_NOT_FOUND) { + let mut finding = base( + "signal.dll_not_found", + RuleCategory::Integrity, + Severity::Critical, + 0.7, + 85, + "diagnose.cause.signal.dll_not_found", + "进程以 0xC0000135(找不到 DLL)退出:缺原生库或 Java 安装损坏,重装 natives/Java 组件", + ); + finding.fix_actions = vec![FixAction::Reinstall(Reinstall { + component: ComponentKind::Natives, + path_hint: None, + note: Some("找不到 DLL:优先修 natives,其次重装 Java".to_owned()), + })]; + finding.evidence = vec![Evidence::new(SignalOrigin::Launcher, 0, exit.summary())]; + out.push(finding); + } + if exit.code == Some(EXIT_STACK_BUFFER_OVERRUN) { + let mut finding = base( + "signal.stack_buffer_overrun", + RuleCategory::Jvm, + Severity::Warning, + 0.5, + 60, + "diagnose.cause.signal.stack_buffer_overrun", + "进程以 0xC0000409(栈缓冲区溢出)退出:JVM/原生库内部错误,需要 hs_err 或崩溃报告进一步判断", + ); + finding.fix_actions = vec![FixAction::SeekHelp(SeekHelp { + export_diagnostics: true, + needs_more_info: vec!["run 目录下的 hs_err_pid*.log".to_owned()], + note: None, + })]; + finding.evidence = vec![Evidence::new(SignalOrigin::Launcher, 0, exit.summary())]; + out.push(finding); + } + if exit.exit_type == ExitType::Sigkill { + let mut finding = base( + "signal.sigkill", + RuleCategory::Memory, + Severity::Warning, + 0.6, + 70, + "diagnose.cause.signal.sigkill", + "进程被 SIGKILL 强杀:常见于系统内存耗尽(OOM killer)或外部清理进程,建议降低 -Xmx 或扩大虚拟内存", + ); + finding.fix_actions = vec![FixAction::AdjustMemory(AdjustMemory { + increase_system_swap: true, + note: Some("被强杀:先确认系统内存是否够".to_owned()), + ..AdjustMemory::default() + })]; + finding.evidence = vec![Evidence::new(SignalOrigin::Launcher, 0, exit.summary())]; + out.push(finding); + } + if exit.exit_type == ExitType::JvmError { + let mut finding = base( + "signal.jvm_error", + RuleCategory::Jvm, + Severity::Critical, + 0.55, + 72, + "diagnose.cause.signal.jvm_error", + "JVM 未能启动(游戏根本没跑起来):先查 -Xmx 是否超过可用内存、JVM 参数是否非法", + ); + finding.fix_actions = vec![ + FixAction::AdjustMemory(AdjustMemory { + increase_system_swap: true, + note: Some("JVM 初始化失败,优先怀疑 -Xmx 过大".to_owned()), + ..AdjustMemory::default() + }), + FixAction::ChangeJava(ChangeJava { + vendor: JavaVendor::Hotspot, + note: Some("换成 HotSpot 的官方 Java 试试".to_owned()), + ..ChangeJava::default() + }), + ]; + finding.evidence = vec![Evidence::new(SignalOrigin::Launcher, 0, exit.summary())]; + out.push(finding); + } + if exit.silent_failure { + let mut finding = base( + "signal.silent_failure", + RuleCategory::Other, + Severity::Warning, + 0.4, + 55, + "diagnose.cause.signal.silent_failure", + &format!( + "退出码为 0 但疑似静默失败({} 条错误行):游戏可能刚起来就被 mod 异常终止,需要更长的日志确认", + exit.error_lines + ), + ); + finding.fix_actions = vec![FixAction::SeekHelp(SeekHelp { + export_diagnostics: true, + needs_more_info: vec!["把日志行数上限调到 20000 后复现一次".to_owned()], + note: None, + })]; + finding.evidence = vec![Evidence::new(SignalOrigin::Launcher, 0, exit.summary())]; + out.push(finding); + } + out +} + +fn hs_err_findings(report: &HsErrReport) -> Vec { + let mut out = Vec::new(); + match report.kind { + HsErrKind::AccessViolation => { + let module = report + .failed_frame + .as_ref() + .and_then(|frame| frame.module.clone()) + .unwrap_or_else(|| "未知模块".to_owned()); + let mut finding = base( + "hs_err.access_violation", + RuleCategory::Graphics, + Severity::Critical, + 0.75, + 88, + "diagnose.cause.hs_err.access_violation", + &format!( + "JVM 致命错误 {module}(访问违例,失败帧 {}):原生层崩溃,显卡驱动/渲染栈是首要嫌疑", + report + .failed_frame + .as_ref() + .map_or("未知", |frame| frame.raw.as_str()) + ), + ); + finding.fix_actions = vec![ + FixAction::ChangeGraphics(ChangeGraphics { + setting: GraphicsSetting::Driver, + update_driver: true, + suggested_value: None, + note: Some(format!("失败帧落在 {module}")), + }), + FixAction::SeekHelp(SeekHelp { + export_diagnostics: true, + needs_more_info: Vec::new(), + note: Some("把 hs_err 一并上报,便于确认驱动版本".to_owned()), + }), + ]; + finding.suppress = vec!["graphics.graphics_driver".to_owned()]; + finding.evidence = vec![Evidence::new(SignalOrigin::HsErr, 1, report.summary())]; + out.push(finding); + } + HsErrKind::OutOfMemory => { + let memory = report + .memory + .as_ref() + .map_or_else(String::new, |memory| memory.summary()); + let mut finding = base( + "hs_err.out_of_memory", + RuleCategory::Memory, + Severity::Critical, + 0.85, + 86, + "diagnose.cause.hs_err.out_of_memory", + &format!("JVM 因内存不足终止:{memory},降低 -Xmx 或扩大系统虚拟内存"), + ); + finding.fix_actions = vec![FixAction::AdjustMemory(AdjustMemory { + increase_system_swap: true, + note: Some(memory), + ..AdjustMemory::default() + })]; + finding.evidence = vec![Evidence::new(SignalOrigin::HsErr, 1, report.summary())]; + out.push(finding); + } + HsErrKind::StackOverflow => { + let mut finding = base( + "hs_err.stack_overflow", + RuleCategory::ModRuntime, + Severity::Warning, + 0.6, + 62, + "diagnose.cause.hs_err.stack_overflow", + "JVM 栈溢出导致的致命错误:通常是某个 mod 的递归/死循环,禁用最近装的 mod 再试", + ); + finding.fix_actions = vec![FixAction::SeekHelp(SeekHelp { + export_diagnostics: true, + needs_more_info: vec!["最近安装/更新的 mod 列表".to_owned()], + note: None, + })]; + finding.evidence = vec![Evidence::new(SignalOrigin::HsErr, 1, report.summary())]; + out.push(finding); + } + HsErrKind::InternalError => { + let mut finding = base( + "hs_err.internal_error", + RuleCategory::Jvm, + Severity::Warning, + 0.5, + 62, + "diagnose.cause.hs_err.internal_error", + "JVM 内部致命错误:优先换官方 HotSpot Java,其次怀疑原生库(natives)损坏", + ); + finding.fix_actions = vec![FixAction::ChangeJava(ChangeJava { + vendor: JavaVendor::Hotspot, + ..ChangeJava::default() + })]; + finding.evidence = vec![Evidence::new(SignalOrigin::HsErr, 1, report.summary())]; + out.push(finding); + } + HsErrKind::Other => {} + } + out +} + +fn base( + rule_id: &str, + category: RuleCategory, + severity: Severity, + confidence: f32, + priority: u8, + message_key: &str, + message: &str, +) -> Finding { + Finding::skeleton( + rule_id, + category, + severity, + confidence, + priority, + message_key, + message, + ) +} diff --git a/crates/core/src/download/adoptium.rs b/crates/core/src/download/adoptium.rs new file mode 100644 index 0000000..a08f6ca --- /dev/null +++ b/crates/core/src/download/adoptium.rs @@ -0,0 +1,204 @@ +//! Adoptium(Temurin)JDK 的院校镜像展开。 +//! +//! D13 定案:国内模式下 JDK 按健康度在 TUNA / 华为云 等镜像之间选, +//! 全部不可达才回官方。难点是**镜像路径与官方下载链接不同形**: +//! +//! 官方(api.adoptium.net 会 302 到这里): +//! `https://github.com/adoptium/temurin17-binaries/releases/download/JDK17.0.13+11/OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip` +//! 清华 TUNA: +//! `https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/jdk/x64/windows/OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip` +//! +//! 所以这里按产物文件名反解 `(major, flavor, arch, os, filename)`,再按各镜像的模板拼目标 URL。 +//! **`flavor` 必须是 `jdk` 或 `jre`**:院校镜像的目录结构是 `/{major}/{jdk|jre}/{arch}/{os}/`, +//! JRE 请求若错拼成 `/jdk/` 会 404 打满所有镜像,最后退回官方直链(镜像加速白费)。 +//! 解析失败不报错——只保留官方源,下载照常能完成。 +//! +//! # 清单可用性(M33 实测,2026-10-30) +//! +//! 按 `{base}/{21}/jdk/x64/windows/` 逐个打点: +//! +//! | 镜像 | 结果 | +//! | --- | --- | +//! | 清华 TUNA `mirrors.tuna.tsinghua.edu.cn/Adoptium` | 200(真实下载 205 MB / 22 秒成功) | +//! | 中科大 USTC | **404**——`/adoptium/` 下只有 `deb/` `rpm/` `releases/`,没有这套目录树 | +//! | 上交 SJTU / 北外 BFSU | **404**(同样没有该目录树) | +//! | 华为云 | **假阳性**:门户对任意路径都回 `200` + 12 KB HTML(M34 实测,`/v1/repositories` 里没有 adoptium) | +//! +//! 因此 `mirrors.json` 里这四项全是 `enabled: false`:留着当「将来恢复」的登记, +//! 但不让它们每次安装都白跑一趟(健康度虽然会冷却,首轮仍要付代价)。 +//! **判可用性别只看状态码**:真仓库目录页含 `
`,门户兜底页没有——状态码 200 说明不了什么。
+//!
+//! # 4xx 不是「源坏」
+//!
+//! TUNA 每个 `{major}/{jdk|jre}/{arch}/{os}/` 目录只保留**最新一个补丁**(实测 21.0.12 → 404,
+//! 21.0.13 → 200 且大小与 Adoptium API 一致)。所以 `NotFound` 在引擎里走
+//! `HealthRegistry::record_missing`:不计失败、不进冷却,只留诊断痕迹。
+
+use serde::{Deserialize, Serialize};
+
+use super::mirrors::MirrorTable;
+use super::types::Candidate;
+
+const fn default_true() -> bool {
+    true
+}
+
+/// 一个 Adoptium 院校镜像。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AdoptiumMirror {
+    /// 源标识(`tuna` / `ustc` / `sjtu` / `bfsu`)。
+    pub id: String,
+    /// 展示名。
+    pub name: String,
+    /// 镜像根(形如 `https://mirrors.tuna.tsinghua.edu.cn/Adoptium`)。
+    pub base: String,
+    /// 是否启用。
+    #[serde(default = "default_true")]
+    pub enabled: bool,
+    /// 该源只保留「最新补丁」(TUNA 实测:每个 `{major}/{jdk|jre}/{arch}/{os}/` 只有 1 个归档)。
+    ///
+    /// 标记本身不改 URL 形态,只给诊断与 `java vendors` 提示用:旧补丁在这里必然 404,
+    /// 引擎侧把 4xx 当「正常降级」而不是「源坏」(见 `download::health::record_missing`)。
+    #[serde(default)]
+    pub latest_only: bool,
+}
+
+impl MirrorTable {
+    /// Adoptium 类 URL 展开成院校镜像候选(按清单顺序;健康度排序由引擎负责)。
+    #[must_use]
+    pub fn adoptium_candidates(&self, url: &str) -> Vec {
+        let Some(artifact) = parse_adoptium_artifact(url) else {
+            return Vec::new();
+        };
+        self.adoptium
+            .iter()
+            .filter(|mirror| mirror.enabled)
+            .map(|mirror| {
+                let base = mirror.base.trim_end_matches('/');
+                Candidate::new(mirror.id.clone(), artifact.mirror_url(base))
+            })
+            .collect()
+    }
+}
+
+/// Adoptium 产物形态:JDK 还是 JRE(决定镜像的 `{major}/jdk|jre/` 路径段)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum JdkFlavor {
+    /// 完整开发包(`OpenJDK17U-jdk_...`)。
+    Jdk,
+    /// 运行时(`OpenJDK17U-jre_...`)。
+    Jre,
+}
+
+impl JdkFlavor {
+    /// 镜像路径里的段名。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Jdk => "jdk",
+            Self::Jre => "jre",
+        }
+    }
+
+    /// 从产物文件名判形态:`OpenJDK17U-jdk_` / `OpenJDK17U-jre_` / `*-jre-17…` 兜底。
+    #[must_use]
+    fn from_filename(filename: &str) -> Self {
+        if filename.contains("-jre") || filename.contains("_jre") {
+            Self::Jre
+        } else {
+            Self::Jdk
+        }
+    }
+}
+
+/// 解析出来的 Adoptium 产物。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct AdoptiumArtifact {
+    /// 大版本号(`17` / `21`)。
+    pub major: String,
+    /// JDK 还是 JRE。
+    pub flavor: JdkFlavor,
+    /// CPU 架构(`x64` / `aarch64` / …)。
+    pub arch: String,
+    /// 目标系统(`windows` / `linux` / `mac` / …)。
+    pub os: String,
+    /// 产物文件名(含扩展名)。
+    pub filename: String,
+}
+
+impl AdoptiumArtifact {
+    /// 拼某个镜像根下的下载地址:`{base}/{major}/{jdk|jre}/{arch}/{os}/{filename}`。
+    #[must_use]
+    pub fn mirror_url(&self, base: &str) -> String {
+        format!(
+            "{}/{}/{}/{}/{}/{}",
+            base.trim_end_matches('/'),
+            self.major,
+            self.flavor.as_str(),
+            self.arch,
+            self.os,
+            self.filename
+        )
+    }
+}
+
+/// 解析 Adoptium 产物名。
+///
+/// 输入形如 `https://github.com/adoptium/temurin17-binaries/releases/download/JDK17.0.13%2B11/OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip`。
+/// 解析失败返回 `None`(此时只保留官方源,不影响下载)。
+#[must_use]
+pub fn parse_adoptium_artifact(url: &str) -> Option {
+    let filename = url.rsplit('/').next()?;
+    if filename.is_empty()
+        || (!filename.contains("OpenJDK") && !filename.contains("jdk") && !filename.contains("jre"))
+    {
+        return None;
+    }
+    let mut major: Option = None;
+    let mut arch: Option<&str> = None;
+    let mut os: Option<&str> = None;
+    for token in filename.split('_') {
+        if let Some(stripped) = token.strip_prefix("OpenJDK") {
+            let digits: String = stripped.chars().take_while(char::is_ascii_digit).collect();
+            if !digits.is_empty() {
+                major = Some(digits);
+            }
+        }
+        arch = arch.or(match token {
+            "x64" => Some("x64"),
+            "x86-32" | "x86" => Some("x86-32"),
+            "aarch64" => Some("aarch64"),
+            "arm" => Some("arm"),
+            "ppc64le" => Some("ppc64le"),
+            "s390x" => Some("s390x"),
+            _ => None,
+        });
+        os = os.or(match token {
+            "windows" => Some("windows"),
+            "linux" => Some("linux"),
+            "mac" => Some("mac"),
+            "alpine-linux" => Some("alpine-linux"),
+            "aix" => Some("aix"),
+            _ => None,
+        });
+    }
+    let major = match major {
+        Some(major) => major,
+        None => {
+            // 兜底形态:`jdk-17.0.13+11_...` 之类。
+            let rest = filename.split("jdk-").nth(1)?;
+            let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
+            if digits.is_empty() {
+                return None;
+            }
+            digits
+        }
+    };
+    Some(AdoptiumArtifact {
+        major,
+        flavor: JdkFlavor::from_filename(filename),
+        arch: arch?.to_string(),
+        os: os?.to_string(),
+        filename: filename.to_string(),
+    })
+}
diff --git a/crates/core/src/download/atomic.rs b/crates/core/src/download/atomic.rs
new file mode 100644
index 0000000..1a09327
--- /dev/null
+++ b/crates/core/src/download/atomic.rs
@@ -0,0 +1,384 @@
+//! 原子落盘:临时文件守卫 + `tmp → rename`。
+//!
+//! 设计文档 §4 的 HMCL 坑之一:没有「先校验后落盘」,崩溃/取消会留下「看起来存在但内容坏了」
+//! 的文件,之后所有「存在即跳过」的判断全部失效。这里的规矩:
+//!
+//! * 下载永远写进同级中间文件,校验通过才 `rename` 到目标路径;
+//! * `PartFile` 在 `Drop` 时自动清理(除非已 `commit`),取消/early-return 都不会留垃圾;
+//! * 小文件(索引、健康度)走 `write_atomic`,同样先写临时文件再 `rename`。
+//!
+//! # 中间文件的身份
+//!
+//! 中间文件名是 `.ullpart--`:`session` 是进程号,`seq` 是进程内
+//! 自增序号(一个 `PartFile` 一个)。旧版命名只由目标路径决定(`.ullpart`),
+//! 同一数据根同时跑两个启动器进程时两边共用一个中间文件:先提交的一方 `rename` 走,
+//! 另一方就拿到 `os error 2`(M35 真机:两个 `ull install` 并行装同一数据根,
+//! 各失败 819 / 1116 个资源)。
+//!
+//! 单进程语义(幂等 / 断点续传 / `current_bytes` 跳过判定)刻意保持不变:
+//!
+//! * [`PartFile::for_dest`] 先在目标目录里找这个目标的旧中间文件并**收养**(本进程的、
+//!   旧版无主的、以及外进程但静置超过 [`ADOPT_MIN_AGE`] 的),改名成本次路径;后续
+//!   续传判定完全照旧,跨进程/跨版本中断的下载仍然续得上。
+//! * 孤儿清理只碰两类:本会话留下又没人在用的副本,和无主(旧版命名)且超过
+//!   [`ORPHAN_MAX_AGE`] 的残留。本进程正在用的路径登记在活跃表里一律让开;外进程的
+//!   中间文件除了「静置超过 [`ORPHAN_MAX_AGE`] 且同一个目标」的,一概不删——没有进程
+//!   存活性探测就无法证明那边已经死了,宁可再生点垃圾也不重犯 `os error 2`。
+
+use std::collections::HashSet;
+use std::ffi::OsString;
+use std::fs::{self, File, OpenOptions};
+use std::io::{self, Write};
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Mutex, OnceLock};
+use std::time::{Duration, SystemTime};
+
+/// 下载中间文件后缀(中间文件家族的前缀:`.ullpart` 或 `.ullpart--`)。
+pub const PART_SUFFIX: &str = ".ullpart";
+/// 续传元数据后缀(挂在中间文件路径后面)。
+pub const META_SUFFIX: &str = ".ullmeta.json";
+/// 收养阈值:别的进程留下的中间文件静置这么久才敢接管。
+///
+/// 活着的下载每读到一个块就写一次(读超时默认 30s 就报错退出),所以「10 分钟没写」
+/// 基本等价于「那边已经死了」。
+pub const ADOPT_MIN_AGE: Duration = Duration::from_secs(10 * 60);
+/// 孤儿阈值:无主(旧版命名)中间文件超过这个岁数才清。
+pub const ORPHAN_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
+
+/// 同进程内中间文件/临时文件的去重序号。
+static SEQ: AtomicU64 = AtomicU64::new(0);
+
+fn sibling(dest: &Path, suffix: &str) -> PathBuf {
+    let mut name = dest.file_name().map_or_else(OsString::new, OsString::from);
+    name.push(suffix);
+    match dest.parent() {
+        Some(parent) => parent.join(name),
+        None => PathBuf::from(name),
+    }
+}
+
+/// 本进程序号(中间文件与 `write_atomic` 的临时文件共用)。
+fn next_seq() -> u64 {
+    SEQ.fetch_add(1, Ordering::Relaxed)
+}
+
+/// 本进程此刻仍在用的中间文件路径(清理与收养都必须让开)。
+fn live_parts() -> &'static Mutex> {
+    static LIVE: OnceLock>> = OnceLock::new();
+    LIVE.get_or_init(|| Mutex::new(HashSet::new()))
+}
+
+fn mark_live(path: &Path, live: bool) {
+    let mut set = live_parts()
+        .lock()
+        .unwrap_or_else(std::sync::PoisonError::into_inner);
+    if live {
+        set.insert(path.to_path_buf());
+    } else {
+        set.remove(path);
+    }
+}
+
+fn is_live(path: &Path) -> bool {
+    live_parts()
+        .lock()
+        .unwrap_or_else(std::sync::PoisonError::into_inner)
+        .contains(path)
+}
+
+/// 中间文件属主(从文件名解析)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Owner {
+    /// 旧版命名:没有进程标识(加标识之前留下的续传材料)。
+    Legacy,
+    /// 某个进程(会话)留下的中间文件。
+    Session(u32),
+}
+
+fn strip_meta(name: &str) -> &str {
+    name.strip_suffix(META_SUFFIX).unwrap_or(name)
+}
+
+/// 名字里指向的中间文件属主;不是我们认识的名字形状 → `None`(一概不碰)。
+fn owner_of(file_name: &str) -> Option {
+    let base = strip_meta(file_name);
+    let index = base.rfind(PART_SUFFIX)?;
+    if index == 0 {
+        return None;
+    }
+    let tail = &base[index + PART_SUFFIX.len()..];
+    if tail.is_empty() {
+        return Some(Owner::Legacy);
+    }
+    let mut pieces = tail.strip_prefix('-')?.split('-');
+    let session = pieces.next()?.parse::().ok()?;
+    pieces.next()?.parse::().ok()?;
+    if pieces.next().is_some() {
+        return None;
+    }
+    Some(Owner::Session(session))
+}
+
+/// 本次会话的中间文件路径:`.ullpart--`。
+fn part_path(dest: &Path, session: u32, seq: u64) -> PathBuf {
+    let mut name = dest.file_name().map_or_else(OsString::new, OsString::from);
+    name.push(format!("{PART_SUFFIX}-{session}-{seq}"));
+    match dest.parent() {
+        Some(parent) => parent.join(name),
+        None => PathBuf::from(name),
+    }
+}
+
+/// 文件距上次修改多久(取不到就当刚写过:清理与收养一律保守)。
+fn age_of(path: &Path) -> Duration {
+    fs::metadata(path)
+        .and_then(|meta| meta.modified())
+        .ok()
+        .and_then(|modified| SystemTime::now().duration_since(modified).ok())
+        .unwrap_or_default()
+}
+
+/// 删掉一个中间文件连同它挂的续传元数据。
+fn remove_family(part: &Path) {
+    let _ = fs::remove_file(part);
+    let _ = fs::remove_file(sibling(part, META_SUFFIX));
+}
+
+/// 目录扫描的三类结果。
+#[derive(Default)]
+struct Scan {
+    /// 可收养的旧中间文件(最年轻的那个)。
+    adopted: Option,
+    /// 本目标的全部中间文件副本(含被收养者)。
+    leftovers: Vec<(PathBuf, Owner, Duration)>,
+    /// 无主且超龄的孤儿。
+    orphans: Vec,
+}
+
+fn scan(dest: &Path, session: u32) -> Scan {
+    let mut scan = Scan::default();
+    let Some(dir) = dest
+        .parent()
+        .filter(|parent| !parent.as_os_str().is_empty())
+    else {
+        return scan;
+    };
+    let Some(dest_name) = dest.file_name() else {
+        return scan;
+    };
+    let family = format!("{}{PART_SUFFIX}", dest_name.to_string_lossy());
+    let Ok(entries) = fs::read_dir(dir) else {
+        return scan;
+    };
+    let mut newest: Option = None;
+    for entry in entries.flatten() {
+        let name = entry.file_name().to_string_lossy().into_owned();
+        if name.ends_with(META_SUFFIX) {
+            continue; // 元数据跟着它挂的中间文件一起处理
+        }
+        let Some(owner) = owner_of(&name) else {
+            continue;
+        };
+        let file = entry.path();
+        if !file.is_file() {
+            continue;
+        }
+        let age = age_of(&file);
+        if !name.starts_with(&family) {
+            if owner == Owner::Legacy && age >= ORPHAN_MAX_AGE {
+                scan.orphans.push(file);
+            }
+            continue;
+        }
+        let adoptable = match owner {
+            // 本会话的旧副本:没人在用就能接管(保留「同一进程内重跑可续传」)。
+            Owner::Session(owner_session) if owner_session == session => !is_live(&file),
+            // 外进程的:静置够久才敢动手,否则可能正在被写。
+            Owner::Session(_) => age >= ADOPT_MIN_AGE,
+            Owner::Legacy => true,
+        };
+        if adoptable && newest.is_none_or(|best| age < best) {
+            newest = Some(age);
+            scan.adopted = Some(file.clone());
+        }
+        scan.leftovers.push((file, owner, age));
+    }
+    scan
+}
+
+/// 为本次下载准备中间文件:收养可续传的旧文件,清掉本会话多余副本与无主孤儿。
+///
+/// 全程尽力而为:清理失败绝不影响下载本身。
+fn prepare(dest: &Path, path: &Path, session: u32) {
+    let scan = scan(dest, session);
+
+    if let Some(found) = scan.adopted {
+        // 同一目录内改名,跨进程也是原子的:谁先动手谁拿到这份续传材料。
+        if found != *path && fs::rename(&found, path).is_ok() {
+            let meta = sibling(&found, META_SUFFIX);
+            if meta.is_file() {
+                let _ = fs::rename(&meta, sibling(path, META_SUFFIX));
+            }
+        }
+    }
+
+    for (leftover, owner, age) in scan.leftovers {
+        if leftover == *path || is_live(&leftover) {
+            continue;
+        }
+        // 本会话落败的副本留着没意义;外进程的只有超龄才敢清。
+        if owner == Owner::Session(session) || age >= ORPHAN_MAX_AGE {
+            remove_family(&leftover);
+        }
+    }
+
+    for orphan in scan.orphans {
+        remove_family(&orphan);
+    }
+}
+
+/// 下载中间文件的守卫。
+#[derive(Debug)]
+pub struct PartFile {
+    path: PathBuf,
+    committed: bool,
+    preserved: bool,
+}
+
+impl PartFile {
+    /// 为目标路径派生中间文件(名字带进程标识,并顺手收养旧的续传材料)。
+    #[must_use]
+    pub fn for_dest(dest: &Path) -> Self {
+        let session = std::process::id();
+        let path = part_path(dest, session, next_seq());
+        mark_live(&path, true);
+        prepare(dest, &path, session);
+        Self {
+            path,
+            committed: false,
+            preserved: false,
+        }
+    }
+
+    /// 中间文件路径。
+    #[must_use]
+    pub fn path(&self) -> &Path {
+        &self.path
+    }
+
+    /// 续传元数据路径。
+    #[must_use]
+    pub fn meta_path(&self) -> PathBuf {
+        sibling(&self.path, META_SUFFIX)
+    }
+
+    /// 中间文件是否存在。
+    #[must_use]
+    pub fn exists(&self) -> bool {
+        self.path.is_file()
+    }
+
+    /// 已写入的字节数(不存在时为 0)。
+    #[must_use]
+    pub fn len(&self) -> u64 {
+        fs::metadata(&self.path).map_or(0, |meta| meta.len())
+    }
+
+    /// 是否为空(续传判定要区分「文件不存在」和「文件存在但 0 字节」)。
+    #[must_use]
+    pub fn is_empty(&self) -> bool {
+        self.len() == 0
+    }
+
+    /// 以追加方式打开(续传)。
+    pub fn open_append(&self) -> io::Result {
+        ensure_parent(&self.path)?;
+        OpenOptions::new()
+            .create(true)
+            .append(true)
+            .open(&self.path)
+    }
+
+    /// 以截断方式打开(从头下载)。
+    pub fn open_truncate(&self) -> io::Result {
+        ensure_parent(&self.path)?;
+        OpenOptions::new()
+            .create(true)
+            .write(true)
+            .truncate(true)
+            .open(&self.path)
+    }
+
+    /// 校验通过后提交:把中间文件 `rename` 到目标路径(同一目录,跨文件系统不会发生)。
+    pub fn commit(mut self, dest: &Path) -> io::Result<()> {
+        ensure_parent(dest)?;
+        fs::rename(&self.path, dest)?;
+        self.committed = true;
+        // rename 后中间文件已不存在,元数据是纯冗余,顺手清掉。
+        let _ = fs::remove_file(self.meta_path());
+        Ok(())
+    }
+
+    /// 主动丢弃中间文件(换源/校验失败)。
+    pub fn discard(&mut self) {
+        remove_family(&self.path);
+    }
+
+    /// 确保中间文件的父目录存在(异步版)。
+    ///
+    /// 单连接路径由 [`Self::open_append`] / [`Self::open_truncate`] 顺手建目录,
+    /// 但**分块路径**是先 `tokio::fs::OpenOptions` 预分配整文件、再并发写段——
+    /// 它不走那两个入口,所以得显式补这一步,否则「下到还不存在的目录」会全部源失败
+    /// (现场故障:`data/java-managed/.cache/` 尚未创建时 6 个候选源瞬间全挂)。
+    pub(crate) async fn ensure_parent_async(&self) -> io::Result<()> {
+        let Some(parent) = self.path.parent() else {
+            return Ok(());
+        };
+        if parent.as_os_str().is_empty() {
+            return Ok(());
+        }
+        tokio::fs::create_dir_all(parent).await
+    }
+
+    /// 保留中间文件(跨会话续传用):`Drop` 时不再清理。
+    pub fn preserve(&mut self) {
+        self.preserved = true;
+    }
+}
+
+impl Drop for PartFile {
+    fn drop(&mut self) {
+        if !self.committed && !self.preserved {
+            remove_family(&self.path);
+        }
+        mark_live(&self.path, false);
+    }
+}
+
+/// 原子写小文件:`.tmp--` → `rename(path)`。
+///
+/// 临时名同样带进程标识:两个进程同时写同一份小文件(健康度、续传元数据)时
+/// 不会互相把对方的临时文件 `rename` 走。
+pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
+    ensure_parent(path)?;
+    let tmp = sibling(path, &format!(".tmp-{}-{}", std::process::id(), next_seq()));
+    {
+        let mut file = File::create(&tmp)?;
+        file.write_all(bytes)?;
+        file.flush()?;
+        file.sync_all()?;
+    }
+    fs::rename(&tmp, path).inspect_err(|_| {
+        let _ = fs::remove_file(&tmp);
+    })
+}
+
+fn ensure_parent(path: &Path) -> io::Result<()> {
+    if let Some(parent) = path.parent() {
+        if !parent.as_os_str().is_empty() {
+            fs::create_dir_all(parent)?;
+        }
+    }
+    Ok(())
+}
diff --git a/crates/core/src/download/backoff.rs b/crates/core/src/download/backoff.rs
new file mode 100644
index 0000000..980f42c
--- /dev/null
+++ b/crates/core/src/download/backoff.rs
@@ -0,0 +1,120 @@
+//! 指数退避 + 抖动。
+//!
+//! 修 HMCL 的坑(analyse/03 §7.6 第 5 点):它的重试循环里没有 sleep,5xx 会变成
+//! 「6 次瞬时连打」,把弱网镜像直接压垮。这里给出 1s / 2s / 4s… 上限 30s,
+//! ±30% 抖动打散多实例/多文件的同步重试风暴。
+//!
+//! 抖动源是自带的 xorshift64*,不引 `rand`(体积 + Win7 依赖面)。
+
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+use serde::{Deserialize, Serialize};
+
+/// 退避策略(可序列化进设置)。
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+pub struct BackoffPolicy {
+    /// 首次退避(毫秒)。
+    pub base_ms: u64,
+    /// 单次退避上限(毫秒)。
+    pub max_ms: u64,
+    /// 抖动比例(0.0~1.0,0.3 = ±30%)。
+    pub jitter_ratio: f64,
+    /// 每个源的尝试次数上限。
+    pub max_attempts: u32,
+}
+
+impl BackoffPolicy {
+    /// 不重试(一次失败立刻换源)。
+    #[must_use]
+    pub const fn none() -> Self {
+        Self {
+            base_ms: 0,
+            max_ms: 0,
+            jitter_ratio: 0.0,
+            max_attempts: 1,
+        }
+    }
+
+    /// 第 `attempt` 次重试前的**无抖动**等待时间(`attempt` 从 0 起)。
+    #[must_use]
+    pub fn delay(&self, attempt: u32) -> Duration {
+        let factor = 1u64.checked_shl(attempt.min(32)).unwrap_or(u64::MAX).max(1);
+        Duration::from_millis(self.base_ms.saturating_mul(factor).min(self.max_ms))
+    }
+
+    /// 第 `attempt` 次重试前的等待时间,`rng` 为 `[0,1)` 的抖动量(可复现,便于单测)。
+    ///
+    /// 结果永不超过 `max_ms`,且永不低于 `base_ms`(当 `base_ms <= max_ms` 时)。
+    #[must_use]
+    pub fn delay_jittered(&self, attempt: u32, rng: f64) -> Duration {
+        let base_ms = self.delay(attempt).as_millis() as f64;
+        let ratio = self.jitter_ratio.clamp(0.0, 1.0);
+        let factor = 1.0 + ratio * (2.0 * rng.clamp(0.0, 1.0) - 1.0);
+        let ceiling = (self.max_ms as f64).max(base_ms);
+        let jittered_ms = (base_ms * factor).clamp(0.0, ceiling);
+        Duration::from_millis(jittered_ms as u64)
+    }
+
+    /// 用抖动源取一次等待时间。
+    #[must_use]
+    pub fn next_delay(&self, attempt: u32, jitter: &mut Jitter) -> Duration {
+        self.delay_jittered(attempt, jitter.next_fraction())
+    }
+}
+
+impl Default for BackoffPolicy {
+    fn default() -> Self {
+        Self {
+            base_ms: 1_000,
+            max_ms: 30_000,
+            jitter_ratio: 0.3,
+            max_attempts: 3,
+        }
+    }
+}
+
+/// xorshift64* 抖动源:确定性、无外部依赖。
+#[derive(Debug, Clone)]
+pub struct Jitter {
+    state: u64,
+}
+
+impl Jitter {
+    /// 固定种子构造(同种子的序列可复现 → 单测友好)。
+    #[must_use]
+    pub const fn new(seed: u64) -> Self {
+        Self {
+            state: if seed == 0 {
+                0x9E37_79B9_7F4A_7C15
+            } else {
+                seed
+            },
+        }
+    }
+
+    /// 下一个 64 位随机数。
+    pub fn next_u64(&mut self) -> u64 {
+        let mut x = self.state;
+        x ^= x << 13;
+        x ^= x >> 7;
+        x ^= x << 17;
+        self.state = x;
+        x.wrapping_mul(0x2545_F491_4F6C_DD1D)
+    }
+
+    /// 下一个 `[0,1)` 的浮点。
+    pub fn next_fraction(&mut self) -> f64 {
+        // 取高 53 位映射到 [0,1),与 f64 的尾数精度一致。
+        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
+    }
+}
+
+impl Default for Jitter {
+    fn default() -> Self {
+        let seed = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .map(|d| d.as_nanos() as u64)
+            .unwrap_or(0x1234_5678_9ABC_DEF0);
+        Self::new(seed)
+    }
+}
diff --git a/crates/core/src/download/blocking.rs b/crates/core/src/download/blocking.rs
new file mode 100644
index 0000000..1a3c4f2
--- /dev/null
+++ b/crates/core/src/download/blocking.rs
@@ -0,0 +1,55 @@
+//! 同步桥:让**非 async 宿主**驱动异步下载引擎。
+//!
+//! `core::download` 的引擎是异步的(tokio + reqwest),GUI 侧自带运行时;
+//! 但 headless 消费者(`crates/cli` 的 `ull`)不依赖 tokio,也不该为一次安装
+//! 把整个异步运行时依赖树拉进 CLI 的 manifest(D18:CLI 依赖树保持干净)。
+//!
+//! 因此把「建一个短命运行时 + 跑完整段 future」这两件事收在 core 里:
+//!
+//! ```ignore
+//! let outcome = unlucky_core::download::blocking::block_on(async { /* 规划 + 下载 */ });
+//! ```
+//!
+//! 约定:**整段**安装流程包在一次 `block_on` 里(同一个运行时),不要每次下载都建一个——
+//! reqwest 的连接池绑定在创建它的运行时上,跨运行时会退化甚至报「dispatch task is gone」。
+//!
+//! 这是给「单线程 CLI」用的逃生口,不是通用设施:app 层请直接用原生异步。
+
+use std::future::Future;
+
+/// 在一个临时 tokio 多线程运行时上跑完 `future`。
+///
+/// 运行时随调用结束销毁,因此适合「一条命令一次阻塞」的 CLI 生命周期。
+///
+/// # Panics
+///
+/// 运行时建不起来(系统资源耗尽)时 panic:此时进程已无法继续,早点暴露比静默降级好。
+/// 在已有 tokio 运行时上下文里调用同样会 panic(tokio 不允许嵌套 `block_on`)。
+pub fn block_on(future: F) -> F::Output {
+    let runtime = tokio::runtime::Builder::new_multi_thread()
+        .worker_threads(2)
+        .thread_name("ull-blocking")
+        .enable_all()
+        .build()
+        .expect("创建 tokio 运行时失败(内存/线程资源不足)");
+    runtime.block_on(future)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::block_on;
+
+    #[test]
+    fn drives_a_plain_future_to_completion() {
+        assert_eq!(block_on(async { 1 + 1 }), 2);
+    }
+
+    #[test]
+    fn drives_tokio_timers() {
+        let start = std::time::Instant::now();
+        block_on(async {
+            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
+        });
+        assert!(start.elapsed() >= std::time::Duration::from_millis(5));
+    }
+}
diff --git a/crates/core/src/download/bytes.rs b/crates/core/src/download/bytes.rs
new file mode 100644
index 0000000..f191f51
--- /dev/null
+++ b/crates/core/src/download/bytes.rs
@@ -0,0 +1,138 @@
+//! 内存下载:版本清单 / 版本 JSON / 资源索引这类「小文本」资源。
+//!
+//! 与文件下载共用候选源、退避与健康度逻辑,但不续传、不分块——它们要么很小,
+//! 要么强时效(`ETag` 语义比续传更合适)。
+
+use std::time::Instant;
+
+use super::cache::now_ms;
+use super::engine::{millis, DownloadEngine};
+use super::error::{DownloadError, DownloadErrorKind};
+use super::http;
+use super::progress::ProgressEvent;
+use super::types::{Candidate, DownloadId, DownloadRequest};
+use super::verify;
+
+/// 内存下载的上限(manifest 类资源不该有几十 MB;防御恶意镜像把人撑爆)。
+pub const MAX_IN_MEMORY_BYTES: u64 = 64 * 1024 * 1024;
+
+impl DownloadEngine {
+    /// 内存下载(版本清单 / 版本 JSON / 索引等小文本资源)。
+    pub async fn download_bytes(&self, req: DownloadRequest) -> Result, DownloadError> {
+        let started = Instant::now();
+        let id = DownloadId::next();
+        req.validate()?;
+        let plan = self.plan(&req, now_ms());
+        self.announce(&id, &req, plan.len());
+        if plan.is_empty() {
+            return Err(DownloadError::new(
+                DownloadErrorKind::InvalidRequest,
+                format!("没有可用候选源:{}", req.url),
+            ));
+        }
+
+        let _permit = self.acquire().await?;
+        let mut suppressed: Vec = Vec::new();
+
+        for candidate in &plan {
+            // 与文件下载同一套规则:冷却中/刚出冷却的源只给一次探测机会。
+            let max_attempts = self.attempts_for(&candidate.source, req.kind, now_ms());
+            for attempt in 0..max_attempts {
+                match self.fetch_bytes(&req, candidate).await {
+                    Ok(bytes) => {
+                        self.health.record_success(
+                            &candidate.source,
+                            req.kind,
+                            started.elapsed(),
+                            now_ms(),
+                        );
+                        self.emit(ProgressEvent::Completed {
+                            id,
+                            bytes: bytes.len() as u64,
+                            from_cache: false,
+                            source: candidate.source.clone(),
+                            elapsed_ms: millis(started.elapsed()),
+                        });
+                        self.flush_health();
+                        return Ok(bytes);
+                    }
+                    Err(err) => {
+                        self.health.record_failure(
+                            &candidate.source,
+                            req.kind,
+                            &err.message,
+                            now_ms(),
+                        );
+                        let retry = err.kind.is_retryable() && attempt + 1 < max_attempts;
+                        let delay = self.next_delay(attempt);
+                        self.emit(ProgressEvent::SourceFailed {
+                            id,
+                            source: candidate.source.clone(),
+                            reason: err.message.clone(),
+                            attempt,
+                            will_retry: retry,
+                            retry_in_ms: if retry { millis(delay) } else { 0 },
+                        });
+                        if !retry {
+                            suppressed.push(format!("{}: {}", candidate.source, err.message));
+                            break;
+                        }
+                        tokio::time::sleep(delay).await;
+                    }
+                }
+            }
+        }
+
+        self.flush_health();
+        Err(DownloadError::new(
+            DownloadErrorKind::AllSourcesFailed,
+            format!("所有候选源都取不到文本资源:{}", req.url),
+        )
+        .with_suppressed(suppressed))
+    }
+
+    async fn fetch_bytes(
+        &self,
+        req: &DownloadRequest,
+        candidate: &Candidate,
+    ) -> Result, DownloadError> {
+        let response = self
+            .http
+            .get(&candidate.url)
+            .send()
+            .await
+            .map_err(|err| http::classify_reqwest(&err, &candidate.url))?;
+        let status = response.status();
+        if !status.is_success() {
+            return Err(http::classify_status(status.as_u16(), &candidate.url)
+                .with_source(candidate.source.as_str()));
+        }
+        if let Some(len) = http::content_length(response.headers()) {
+            if len > MAX_IN_MEMORY_BYTES {
+                return Err(DownloadError::new(
+                    DownloadErrorKind::InvalidRequest,
+                    format!(
+                        "文本资源过大({len} 字节),拒绝载入内存:{}",
+                        candidate.url
+                    ),
+                )
+                .with_source(candidate.source.as_str()));
+            }
+        }
+        let body = response
+            .bytes()
+            .await
+            .map_err(|err| http::classify_reqwest(&err, &candidate.url))?;
+        if let Some(integrity) = &req.integrity {
+            let actual = verify::digest_bytes(integrity.algo, &body);
+            if !verify::matches(&integrity.hex, &actual) {
+                return Err(DownloadError::new(
+                    DownloadErrorKind::Integrity,
+                    format!("校验不符:期望 {} 实得 {actual}", integrity.hex),
+                )
+                .with_source(candidate.source.as_str()));
+            }
+        }
+        Ok(body.to_vec())
+    }
+}
diff --git a/crates/core/src/download/cache.rs b/crates/core/src/download/cache.rs
new file mode 100644
index 0000000..7676e83
--- /dev/null
+++ b/crates/core/src/download/cache.rs
@@ -0,0 +1,285 @@
+//! 内容寻址缓存:两级分片 + 「记录信任」命中快路径。
+//!
+//! 布局承设计文档 `04 §2` 与 analyse/03 §5.7:`/sha1//`(两级分片,
+//! 避开 NTFS 单目录几十万条目塌陷)。
+//!
+//! 修 HMCL 的坑(analyse/03 §8.2 第 7 点):它每次命中都重算整个文件的 SHA-1,
+//! 3000 个资源对象意味着启动期几百 MB 的无谓读盘。这里写入时记录 `{hash, size, mtime}`,
+//! **此后信任记录**:size/mtime 都没变就直接命中;只有元数据变了才重算一次摘要并刷新记录。
+
+use std::collections::BTreeMap;
+use std::io;
+use std::path::{Path, PathBuf};
+use std::sync::Mutex;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use serde::{Deserialize, Serialize};
+
+use super::atomic::write_atomic;
+use super::types::{HashAlgo, Integrity};
+use super::verify;
+
+/// 索引文件名(放在缓存根下)。
+pub const INDEX_FILE: &str = "index.json";
+/// 索引格式版本。
+pub const INDEX_VERSION: u32 = 1;
+
+/// 一条缓存记录。
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CacheRecord {
+    /// 算法。
+    pub algo: HashAlgo,
+    /// 摘要(小写十六进制)。
+    pub hash: String,
+    /// 字节数。
+    pub size: u64,
+    /// 文件修改时间(unix 毫秒)——记录信任的锚点。
+    pub mtime_ms: u64,
+    /// 写入缓存时间。
+    pub cached_at_ms: u64,
+    /// 最近一次真正校验的时间(0 = 从未校验,走记录信任)。
+    pub last_verified_ms: u64,
+}
+
+/// 缓存统计(诊断页)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct CacheStats {
+    /// 条目数。
+    pub entries: usize,
+    /// 总字节数。
+    pub total_bytes: u64,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct CacheIndex {
+    version: u32,
+    #[serde(default)]
+    entries: BTreeMap,
+}
+
+impl Default for CacheIndex {
+    fn default() -> Self {
+        Self {
+            version: INDEX_VERSION,
+            entries: BTreeMap::new(),
+        }
+    }
+}
+
+/// 内容寻址缓存。
+#[derive(Debug)]
+pub struct ContentCache {
+    root: PathBuf,
+    index: Mutex,
+}
+
+impl ContentCache {
+    /// 打开(或初始化)缓存根目录;索引损坏时从空表重建(缓存随时可丢,不阻断启动)。
+    #[must_use]
+    pub fn open(root: impl Into) -> Self {
+        let root = root.into();
+        let index_path = root.join(INDEX_FILE);
+        let index = std::fs::read(&index_path)
+            .ok()
+            .and_then(|bytes| serde_json::from_slice::(&bytes).ok())
+            .unwrap_or_default();
+        Self {
+            root,
+            index: Mutex::new(index),
+        }
+    }
+
+    /// 缓存根目录。
+    #[must_use]
+    pub fn root(&self) -> &Path {
+        &self.root
+    }
+
+    /// 某个摘要对应的缓存路径(两级分片)。
+    #[must_use]
+    pub fn path_for(&self, algo: HashAlgo, hash: &str) -> PathBuf {
+        let shard = hash.get(..2).unwrap_or(hash);
+        self.root.join(algo.as_str()).join(shard).join(hash)
+    }
+
+    fn lock(&self) -> std::sync::MutexGuard<'_, CacheIndex> {
+        self.index
+            .lock()
+            .unwrap_or_else(|poisoned| poisoned.into_inner())
+    }
+
+    fn key(algo: HashAlgo, hash: &str) -> String {
+        format!("{}:{hash}", algo.as_str())
+    }
+
+    /// 命中查询(记录信任:size + mtime 未变则**不重算**摘要)。
+    #[must_use]
+    pub fn lookup(&self, integrity: &Integrity, now_ms: u64) -> Option {
+        let path = self.path_for(integrity.algo, &integrity.hex);
+        let record = self
+            .lock()
+            .entries
+            .get(&Self::key(integrity.algo, &integrity.hex))
+            .cloned()?;
+        let Ok(meta) = std::fs::metadata(&path) else {
+            // 文件被删:记录作废,避免每次命中都白查一次磁盘。
+            self.forget(integrity);
+            return None;
+        };
+        if !meta.is_file() || meta.len() != record.size {
+            // 文件被删/被截断:记录作废。
+            self.forget(integrity);
+            return None;
+        }
+        let mtime_ms = mtime_ms(&meta);
+        if mtime_ms == record.mtime_ms {
+            return Some(path);
+        }
+        // 元数据变了(外部动过文件):重算一次摘要再决定。
+        let Ok(actual) = verify::digest_file(&path, integrity.algo) else {
+            self.forget(integrity);
+            return None;
+        };
+        if !verify::matches(&integrity.hex, &actual) {
+            self.forget(integrity);
+            return None;
+        }
+        self.refresh_record(integrity, &meta, now_ms);
+        Some(path)
+    }
+
+    /// 强制校验的命中查询(抽样/定期后台校验用,慢但可信)。
+    #[must_use]
+    pub fn lookup_verified(&self, integrity: &Integrity, now_ms: u64) -> Option {
+        let path = self.path_for(integrity.algo, &integrity.hex);
+        let meta = std::fs::metadata(&path).ok()?;
+        if !meta.is_file() {
+            self.forget(integrity);
+            return None;
+        }
+        let actual = verify::digest_file(&path, integrity.algo).ok()?;
+        if !verify::matches(&integrity.hex, &actual) {
+            self.forget(integrity);
+            return None;
+        }
+        self.refresh_record(integrity, &meta, now_ms);
+        Some(path)
+    }
+
+    /// 把已校验通过的文件放进缓存(硬链接优先,跨卷退化为复制)。
+    pub fn insert(&self, source: &Path, integrity: &Integrity, now_ms: u64) -> io::Result {
+        let target = self.path_for(integrity.algo, &integrity.hex);
+        if let Some(parent) = target.parent() {
+            std::fs::create_dir_all(parent)?;
+        }
+        if !target.is_file() {
+            link_or_copy(source, &target)?;
+        }
+        self.register(&target, integrity, now_ms)?;
+        Ok(target)
+    }
+
+    /// 登记一个已在缓存位的文件(`assets` 已在游戏目录里的补登记场景)。
+    pub fn register(&self, path: &Path, integrity: &Integrity, now_ms: u64) -> io::Result<()> {
+        let meta = std::fs::metadata(path)?;
+        let record = CacheRecord {
+            algo: integrity.algo,
+            hash: integrity.hex.clone(),
+            size: meta.len(),
+            mtime_ms: mtime_ms(&meta),
+            cached_at_ms: now_ms,
+            last_verified_ms: now_ms,
+        };
+        self.lock()
+            .entries
+            .insert(Self::key(integrity.algo, &integrity.hex), record);
+        Ok(())
+    }
+
+    /// 删除一条缓存(含文件)。
+    pub fn remove(&self, integrity: &Integrity) -> io::Result<()> {
+        let path = self.path_for(integrity.algo, &integrity.hex);
+        self.forget(integrity);
+        match std::fs::remove_file(&path) {
+            Ok(()) => Ok(()),
+            Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
+            Err(err) => Err(err),
+        }
+    }
+
+    fn forget(&self, integrity: &Integrity) {
+        self.lock()
+            .entries
+            .remove(&Self::key(integrity.algo, &integrity.hex));
+    }
+
+    fn refresh_record(&self, integrity: &Integrity, meta: &std::fs::Metadata, now_ms: u64) {
+        if let Some(record) = self
+            .lock()
+            .entries
+            .get_mut(&Self::key(integrity.algo, &integrity.hex))
+        {
+            record.size = meta.len();
+            record.mtime_ms = mtime_ms(meta);
+            record.last_verified_ms = now_ms;
+        }
+    }
+
+    /// 统计。
+    #[must_use]
+    pub fn stats(&self) -> CacheStats {
+        let index = self.lock();
+        CacheStats {
+            entries: index.entries.len(),
+            total_bytes: index.entries.values().map(|record| record.size).sum(),
+        }
+    }
+
+    /// 索引条目数(`stats` 的便捷版本)。
+    #[must_use]
+    pub fn len(&self) -> usize {
+        self.lock().entries.len()
+    }
+
+    /// 索引是否为空。
+    #[must_use]
+    pub fn is_empty(&self) -> bool {
+        self.len() == 0
+    }
+
+    /// 原子持久化索引(`cache/index.json`)。
+    pub fn persist(&self) -> io::Result<()> {
+        let json = {
+            let index = self.lock();
+            serde_json::to_vec_pretty(&*index).map_err(io::Error::other)?
+        };
+        write_atomic(&self.root.join(INDEX_FILE), &json)
+    }
+}
+
+/// 硬链接优先,失败退化为复制(跨卷/权限不足时)。
+fn link_or_copy(source: &Path, target: &Path) -> io::Result<()> {
+    match std::fs::hard_link(source, target) {
+        Ok(()) => Ok(()),
+        Err(_) => std::fs::copy(source, target).map(|_| ()),
+    }
+}
+
+fn mtime_ms(meta: &std::fs::Metadata) -> u64 {
+    meta.modified()
+        .ok()
+        .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
+        .map_or(0, |since| {
+            u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
+        })
+}
+
+/// 当前 unix 毫秒(引擎统一取一次再往下传,避免测试里到处读时钟)。
+#[must_use]
+pub fn now_ms() -> u64 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map_or(0, |since| {
+            u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
+        })
+}
diff --git a/crates/core/src/download/chunked.rs b/crates/core/src/download/chunked.rs
new file mode 100644
index 0000000..61853e3
--- /dev/null
+++ b/crates/core/src/download/chunked.rs
@@ -0,0 +1,296 @@
+//! 大文件多连接分块下载:分块规划、`Content-Range` 解析、Range 能力判定与单块抓取。
+//!
+//! HMCL 只做「单文件单连接 + 全局并发闸门」(analyse/03 §5.5),大 jar 会抢占许可并形成
+//! 头阻塞。这里对「大文件 + 支持 Range」的资源开 N 条连接并行取段(默认 N=4),
+//! 每条连接写进同一个**预分配**中间文件的不同偏移;服务器不支持 Range(返回 200 而不是 206)
+//! 时由引擎自动退化为单连接。
+
+use std::path::Path;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::Duration;
+
+use super::error::{DownloadError, DownloadErrorKind};
+use super::http;
+use super::types::ResourceKind;
+
+/// 默认分块连接数(设计文档 Q19 定案 N=4)。
+pub const DEFAULT_CONNECTIONS: usize = 4;
+/// 分块连接数上限(再多收益递减且容易被镜像限流)。
+pub const MAX_CONNECTIONS: usize = 16;
+/// 默认开启分块的最小文件大小(8 MiB)。
+pub const DEFAULT_MIN_SIZE: u64 = 8 * 1024 * 1024;
+
+/// 分块下载配置。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ChunkedConfig {
+    /// 总开关(设置项)。
+    pub enabled: bool,
+    /// 并行连接数。
+    pub connections: usize,
+    /// 小于该大小不做分块。
+    pub min_size: u64,
+}
+
+impl Default for ChunkedConfig {
+    fn default() -> Self {
+        Self {
+            enabled: true,
+            connections: DEFAULT_CONNECTIONS,
+            min_size: DEFAULT_MIN_SIZE,
+        }
+    }
+}
+
+impl ChunkedConfig {
+    /// 关闭分块。
+    #[must_use]
+    pub const fn disabled() -> Self {
+        Self {
+            enabled: false,
+            connections: 1,
+            min_size: DEFAULT_MIN_SIZE,
+        }
+    }
+
+    /// 该资源当前是否走分块。
+    #[must_use]
+    pub fn applies_to(&self, kind: ResourceKind, size: Option) -> bool {
+        self.enabled
+            && self.connections > 1
+            && kind.prefers_chunked()
+            && size.is_some_and(|size| size >= self.min_size)
+    }
+
+    /// 实际生效的连接数(夹在 `1..=MAX_CONNECTIONS`)。
+    #[must_use]
+    pub fn effective_connections(&self) -> usize {
+        self.connections.clamp(1, MAX_CONNECTIONS)
+    }
+}
+
+/// 闭区间字节段(HTTP Range 的语义是闭区间,这里保持一致免得 +1/-1 到处飞)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ByteRange {
+    /// 起始偏移(含)。
+    pub start: u64,
+    /// 结束偏移(**含**)。
+    pub end: u64,
+}
+
+impl ByteRange {
+    /// 构造。
+    #[must_use]
+    pub const fn new(start: u64, end: u64) -> Self {
+        Self { start, end }
+    }
+
+    /// 段长度(字节)。
+    #[must_use]
+    pub const fn len(&self) -> u64 {
+        if self.end < self.start {
+            0
+        } else {
+            self.end - self.start + 1
+        }
+    }
+
+    /// 空段。
+    #[must_use]
+    pub const fn is_empty(&self) -> bool {
+        self.end < self.start
+    }
+
+    /// `Range` 头的值。
+    #[must_use]
+    pub fn header_value(&self) -> String {
+        format!("bytes={}-{}", self.start, self.end)
+    }
+}
+
+/// 把 `total` 均分成不超过 `connections` 段(纯函数,单测重点)。
+///
+/// 保证:段序列连续、无重叠、并集恰为 `[0, total)`;段数 = `min(connections, total)`。
+#[must_use]
+pub fn plan(total: u64, connections: usize) -> Vec {
+    if total == 0 {
+        return Vec::new();
+    }
+    let parts = connections.clamp(1, MAX_CONNECTIONS) as u64;
+    let count = parts.min(total);
+    let base = total / count;
+    let remainder = total % count;
+
+    let mut ranges = Vec::with_capacity(count as usize);
+    let mut offset = 0_u64;
+    for index in 0..count {
+        // 余数摊到前几段,避免最后一段特别大。
+        let extra = u64::from(index < remainder);
+        let len = base + extra;
+        if len == 0 {
+            continue;
+        }
+        ranges.push(ByteRange::new(offset, offset + len - 1));
+        offset += len;
+    }
+    ranges
+}
+
+/// `Content-Range: bytes 0-99/1000` 的解析结果。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ContentRange {
+    /// 起始。
+    pub start: u64,
+    /// 结束(含)。
+    pub end: u64,
+    /// 总长度(`*` 时为 `None`)。
+    pub total: Option,
+}
+
+/// 解析 `Content-Range`(畸形一律 `None`,调用方退化处理)。
+#[must_use]
+pub fn parse_content_range(value: &str) -> Option {
+    let rest = value.trim().strip_prefix("bytes")?.trim_start();
+    let (range, total) = rest.split_once('/')?;
+    let (start, end) = range.split_once('-')?;
+    let start: u64 = start.trim().parse().ok()?;
+    let end: u64 = end.trim().parse().ok()?;
+    if end < start {
+        return None;
+    }
+    let total = match total.trim() {
+        "*" => None,
+        other => Some(other.parse().ok()?),
+    };
+    Some(ContentRange { start, end, total })
+}
+
+/// 服务器对 Range 的支持度。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RangeSupport {
+    /// 支持(`Accept-Ranges: bytes` 或正常返回 206)。
+    Supported,
+    /// 明确不支持。
+    Unsupported,
+    /// 未知(既没头也没探测)。
+    Unknown,
+}
+
+/// 判定 Range 支持度。
+#[must_use]
+pub fn range_support(accept_ranges: Option<&str>, status: u16) -> RangeSupport {
+    if status == 206 {
+        return RangeSupport::Supported;
+    }
+    match accept_ranges {
+        Some(value) if value.to_ascii_lowercase().contains("bytes") => RangeSupport::Supported,
+        Some(value) if value.to_ascii_lowercase().contains("none") => RangeSupport::Unsupported,
+        Some(_) => RangeSupport::Unsupported,
+        None => RangeSupport::Unknown,
+    }
+}
+
+/// 206 响应的 `Content-Range` 是否与请求段一致。
+///
+/// * 缺头 → 宽松放过(个别服务器省略该头,后面的长度校验仍能兜底);
+/// * 有头 → 起止必须严格等于请求段,畸形头一律判不符。
+///
+/// 这条防御与单连接路径(`transfer.rs` 里比对 `content_range.start != offset`)对齐:
+/// 只在「长度恰好」时坏文件才会溜过长度校验,起止比对把这条路堵死。
+#[must_use]
+pub fn content_range_matches(requested: ByteRange, value: Option<&str>) -> bool {
+    let Some(value) = value else {
+        return true;
+    };
+    parse_content_range(value)
+        .is_some_and(|parsed| parsed.start == requested.start && parsed.end == requested.end)
+}
+
+/// 抓一个段,写到预分配文件的 `range.start` 偏移处,并把字节数累加进 `progress`。
+///
+/// 服务器若对带 `Range` 的请求返回 200(无视 Range),立刻返回 `RangeUnsupported`
+/// —— 引擎据此退化单连接,而不是把整份文件重复写 N 遍。
+pub async fn fetch_chunk(
+    client: &reqwest::Client,
+    url: &str,
+    range: ByteRange,
+    part: &Path,
+    read_timeout: Duration,
+    progress: &AtomicU64,
+) -> Result {
+    use tokio::io::{AsyncSeekExt, AsyncWriteExt};
+
+    let request = client
+        .get(url)
+        .header(reqwest::header::RANGE, range.header_value());
+    let response = request
+        .send()
+        .await
+        .map_err(|err| http::classify_reqwest(&err, url))?;
+
+    let status = response.status().as_u16();
+    if status == 200 {
+        return Err(DownloadError::new(
+            DownloadErrorKind::RangeUnsupported,
+            format!("服务器忽略 Range 请求({url})"),
+        ));
+    }
+    if status != 206 {
+        return Err(http::classify_status(status, url));
+    }
+    // 服务器可能「回 206 但段不对」(CDN 缓存错位、Range 实现有 bug)。
+    // 判不符时按 RangeUnsupported 处理:引擎会丢弃中间文件、退化单连接重下,
+    // 这比在同一条坏路径上重试更划算。
+    let content_range = http::header_str(response.headers(), reqwest::header::CONTENT_RANGE);
+    if !content_range_matches(range, content_range) {
+        return Err(DownloadError::new(
+            DownloadErrorKind::RangeUnsupported,
+            format!(
+                "Content-Range 与请求段不符({url}):请求 {},响应 {}",
+                range.header_value(),
+                content_range.unwrap_or("<缺失>")
+            ),
+        ));
+    }
+
+    let mut file = tokio::fs::OpenOptions::new()
+        .write(true)
+        .open(part)
+        .await
+        .map_err(|err| io_error(&err, "打开分块中间文件失败"))?;
+    file.seek(std::io::SeekFrom::Start(range.start))
+        .await
+        .map_err(|err| io_error(&err, "定位分块偏移失败"))?;
+
+    let mut stream = response.bytes_stream();
+    let mut written = 0_u64;
+    loop {
+        let next = tokio::time::timeout(read_timeout, futures_util::StreamExt::next(&mut stream))
+            .await
+            .map_err(|_| {
+                DownloadError::new(DownloadErrorKind::Timeout, format!("分块读取超时({url})"))
+            })?;
+        let Some(chunk) = next else { break };
+        let chunk = chunk.map_err(|err| http::classify_reqwest(&err, url))?;
+        file.write_all(&chunk)
+            .await
+            .map_err(|err| io_error(&err, "写入分块失败"))?;
+        let len = chunk.len() as u64;
+        written += len;
+        progress.fetch_add(len, Ordering::Relaxed);
+    }
+    file.flush()
+        .await
+        .map_err(|err| io_error(&err, "刷新分块失败"))?;
+
+    if written != range.len() {
+        return Err(DownloadError::new(
+            DownloadErrorKind::Network,
+            format!("分块长度不符:期望 {} 实得 {written}({url})", range.len()),
+        ));
+    }
+    Ok(written)
+}
+
+fn io_error(err: &std::io::Error, context: &str) -> DownloadError {
+    DownloadError::new(DownloadErrorKind::Io, format!("{context}: {err}"))
+}
diff --git a/crates/core/src/download/config.rs b/crates/core/src/download/config.rs
new file mode 100644
index 0000000..8fcf892
--- /dev/null
+++ b/crates/core/src/download/config.rs
@@ -0,0 +1,46 @@
+//! 引擎配置:设置层(core::setting)直接映射到这里。
+
+use std::time::Duration;
+
+use super::backoff::BackoffPolicy;
+use super::chunked::ChunkedConfig;
+use super::types::ResumePolicy;
+
+/// 引擎配置。
+#[derive(Debug, Clone)]
+pub struct EngineConfig {
+    /// 全局并发许可数(tokio `Semaphore`),默认 64。
+    pub max_concurrency: usize,
+    /// 每个源的尝试次数上限,默认 3。
+    pub max_attempts_per_source: u32,
+    /// 连接超时,默认 10s。
+    pub connect_timeout: Duration,
+    /// 单次读超时(读空闲判定,不是整体超时),默认 30s。
+    pub read_timeout: Duration,
+    /// User-Agent。
+    pub user_agent: String,
+    /// 进度事件节流间隔,默认 250ms。
+    pub progress_throttle: Duration,
+    /// 退避策略。
+    pub backoff: BackoffPolicy,
+    /// 分块下载配置。
+    pub chunked: ChunkedConfig,
+    /// 默认续传策略(请求级 `Never` 优先级更高)。
+    pub resume: ResumePolicy,
+}
+
+impl Default for EngineConfig {
+    fn default() -> Self {
+        Self {
+            max_concurrency: 64,
+            max_attempts_per_source: 3,
+            connect_timeout: Duration::from_secs(10),
+            read_timeout: Duration::from_secs(30),
+            user_agent: concat!("UnluckyLauncher/", env!("CARGO_PKG_VERSION")).to_string(),
+            progress_throttle: Duration::from_millis(250),
+            backoff: BackoffPolicy::default(),
+            chunked: ChunkedConfig::default(),
+            resume: ResumePolicy::Conservative,
+        }
+    }
+}
diff --git a/crates/core/src/download/engine.rs b/crates/core/src/download/engine.rs
new file mode 100644
index 0000000..1c73895
--- /dev/null
+++ b/crates/core/src/download/engine.rs
@@ -0,0 +1,397 @@
+//! 下载引擎:编排层。
+//!
+//! 一次 `download()` 的固定剧情:
+//!
+//! ```text
+//! 校验请求 → 内容寻址缓存直取(零网络)
+//!          → 候选规划(镜像表展开 + 健康度排序)
+//!          → Semaphore 限流
+//!          → 候选源外循环(换源),每源内循环(指数退避 + 抖动)
+//!              → 续传判定(保守)→ 单连接 / 多连接分块 → 流式校验 → 原子落盘
+//!          → 失败则保留中间文件供下次续传
+//! ```
+//!
+//! 最后一步保留的是**本会话命名的**中间文件(`.ullpart--`,不再固定
+//! 叫 `.ullpart`):下次(甚至下次启动、重启后再下同一个文件)由 `PartFile::for_dest`
+//! 在目标目录里认领它并改名成新的会话名,续传判定照旧。详细身份规则见 `atomic.rs` 模块头的
+//! 「中间文件的身份」。
+//!
+//! 具体传输细节在 `transfer.rs`,错误分类在 `http.rs`,本文件只负责顺序与策略。
+
+use std::path::Path;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use tokio::sync::Semaphore;
+
+use super::atomic::PartFile;
+use super::backoff::Jitter;
+use super::cache::{now_ms, ContentCache};
+use super::config::EngineConfig;
+use super::error::{DownloadError, DownloadErrorKind};
+use super::health::HealthRegistry;
+use super::http;
+use super::mirrors::MirrorTable;
+use super::progress::{ProgressEmitter, ProgressEvent};
+use super::region::RegionProfile;
+use super::types::{
+    Candidate, DownloadId, DownloadOutcome, DownloadRequest, ResourceKind, ResumePolicy,
+    SOURCE_OFFICIAL,
+};
+
+/// 一次成功尝试的产物描述。
+pub(crate) struct AttemptOk {
+    /// 文件总字节数。
+    pub bytes: u64,
+    /// 续传起点(0 = 从头下)。
+    pub resumed_from: u64,
+}
+
+/// 下载引擎(`Arc` 后全局共享;内部无状态以外的可变数据只有抖动源)。
+#[derive(Debug)]
+pub struct DownloadEngine {
+    pub(crate) http: reqwest::Client,
+    pub(crate) cfg: EngineConfig,
+    pub(crate) progress: ProgressEmitter,
+    pub(crate) health: Arc,
+    pub(crate) cache: Arc,
+    pub(crate) jitter: std::sync::Mutex,
+    mirrors: Arc,
+    region: Arc,
+    sem: Arc,
+}
+
+impl DownloadEngine {
+    /// 构造引擎(用 `with_progress` 挂事件流)。
+    #[must_use]
+    pub fn new(
+        cfg: EngineConfig,
+        mirrors: Arc,
+        region: Arc,
+        health: Arc,
+        cache: Arc,
+    ) -> Self {
+        let http = http::build_client(&cfg).unwrap_or_else(|_| reqwest::Client::new());
+        let sem = Arc::new(Semaphore::new(cfg.max_concurrency.max(1)));
+        let seed = now_ms() ^ (u64::from(cfg.max_concurrency as u32) << 32);
+        Self {
+            http,
+            cfg,
+            progress: ProgressEmitter::silent(),
+            health,
+            cache,
+            jitter: std::sync::Mutex::new(Jitter::new(seed)),
+            mirrors,
+            region,
+            sem,
+        }
+    }
+
+    /// 挂上进度事件发射器。
+    #[must_use]
+    pub fn with_progress(mut self, progress: ProgressEmitter) -> Self {
+        self.progress = progress;
+        self
+    }
+
+    /// 配置。
+    #[must_use]
+    pub fn config(&self) -> &EngineConfig {
+        &self.cfg
+    }
+
+    /// HTTP 客户端(同一条连接池与超时配置)。
+    ///
+    /// 给「一锤子查询」用——例如 Adoptium 的版本查询(`core::game::java::api`):
+    /// 没必要为了一个 GET 再造一个 client,代理/超时/证书栈都在这里统一。
+    #[must_use]
+    pub fn client(&self) -> &reqwest::Client {
+        &self.http
+    }
+
+    /// 当前地区档案(设置页展示)。
+    #[must_use]
+    pub fn region(&self) -> &RegionProfile {
+        &self.region
+    }
+
+    /// 镜像表(`game::java` 的 Oracle / MS / Zulu 分发表在这里查)。
+    #[must_use]
+    pub fn mirrors(&self) -> &MirrorTable {
+        &self.mirrors
+    }
+
+    /// 健康度注册表(诊断页)。
+    #[must_use]
+    pub fn health(&self) -> &HealthRegistry {
+        &self.health
+    }
+
+    /// 内容寻址缓存。
+    #[must_use]
+    pub fn cache(&self) -> &ContentCache {
+        &self.cache
+    }
+
+    /// 候选规划:镜像展开 → 官方位置保持不变 → 其余按健康度排序。
+    #[must_use]
+    pub fn plan(&self, req: &DownloadRequest, now_ms: u64) -> Vec {
+        let expanded = self.mirrors.expand(&req.url, req.kind, &self.region);
+        let official_first = expanded
+            .first()
+            .is_none_or(|candidate| candidate.source == SOURCE_OFFICIAL);
+        let mut official = Vec::new();
+        let mut mirrors = Vec::new();
+        for candidate in expanded {
+            if candidate.source == SOURCE_OFFICIAL {
+                official.push(candidate);
+            } else {
+                mirrors.push(candidate);
+            }
+        }
+        let ranked = self.health.rank(&mirrors, req.kind, now_ms);
+        if official_first {
+            official.extend(ranked);
+            official
+        } else {
+            let mut out = ranked;
+            out.extend(official);
+            out
+        }
+    }
+
+    /// 单文件下载。
+    pub async fn download(
+        &self,
+        mut req: DownloadRequest,
+    ) -> Result {
+        let started = Instant::now();
+        let id = DownloadId::next();
+        req.validate()?;
+        if self.cfg.resume == ResumePolicy::Never {
+            req.resume = ResumePolicy::Never;
+        }
+
+        if let Some(integrity) = req.integrity.clone() {
+            if let Some(cached) = self.cache.lookup(&integrity, now_ms()) {
+                let bytes = materialize(&cached, &req.dest).map_err(|err| {
+                    DownloadError::new(
+                        DownloadErrorKind::Io,
+                        format!("缓存直取失败({}):{err}", req.dest.display()),
+                    )
+                })?;
+                self.emit(ProgressEvent::Completed {
+                    id,
+                    bytes,
+                    from_cache: true,
+                    source: "cache".to_string(),
+                    elapsed_ms: millis(started.elapsed()),
+                });
+                return Ok(DownloadOutcome {
+                    id,
+                    path: req.dest,
+                    bytes,
+                    from_cache: true,
+                    source: "cache".to_string(),
+                    resumed_from: 0,
+                    elapsed: started.elapsed(),
+                });
+            }
+        }
+
+        let plan = self.plan(&req, now_ms());
+        self.announce(&id, &req, plan.len());
+        if plan.is_empty() {
+            return Err(DownloadError::new(
+                DownloadErrorKind::InvalidRequest,
+                format!("没有可用候选源:{}", req.url),
+            ));
+        }
+
+        let _permit = self.acquire().await?;
+        let mut part = PartFile::for_dest(&req.dest);
+        let mut suppressed: Vec = Vec::new();
+        let mut previous: Option = None;
+
+        for candidate in &plan {
+            if let Some(from) = previous.take() {
+                self.emit(ProgressEvent::SourceSwitched {
+                    id,
+                    from,
+                    to: candidate.source.clone(),
+                });
+            }
+            // 冷却中 / 刚出冷却的源只给一次机会(那就是「恢复探测」)。
+            let max_attempts = self.attempts_for(&candidate.source, req.kind, now_ms());
+
+            for attempt in 0..max_attempts {
+                let attempt_started = Instant::now();
+                let outcome = self.attempt(id, &req, candidate, &mut part).await;
+                match outcome {
+                    Ok(ok) => {
+                        self.health.record_success(
+                            &candidate.source,
+                            req.kind,
+                            attempt_started.elapsed(),
+                            now_ms(),
+                        );
+                        let outcome = self.finish(&req, part, id, candidate, ok, started)?;
+                        self.flush_health();
+                        return Ok(outcome);
+                    }
+                    Err(err) => {
+                        // 4xx(NotFound)不算「源坏」:院校镜像只留最新补丁,请求旧补丁必然 404,
+                        // 记成失败会把唯一可用镜像冷却掉(M33/M34 实测:TUNA 每个目录只保 1 个归档)。
+                        if err.kind == DownloadErrorKind::NotFound {
+                            self.health.record_missing(
+                                &candidate.source,
+                                req.kind,
+                                &err.message,
+                                now_ms(),
+                            );
+                        } else {
+                            self.health.record_failure(
+                                &candidate.source,
+                                req.kind,
+                                &err.message,
+                                now_ms(),
+                            );
+                        }
+                        let retry = err.kind.is_retryable() && attempt + 1 < max_attempts;
+                        let delay = self.next_delay(attempt);
+                        self.emit(ProgressEvent::SourceFailed {
+                            id,
+                            source: candidate.source.clone(),
+                            reason: err.message.clone(),
+                            attempt,
+                            will_retry: retry,
+                            retry_in_ms: if retry { millis(delay) } else { 0 },
+                        });
+                        if !retry {
+                            suppressed.push(format!("{}: {}", candidate.source, err.message));
+                            break;
+                        }
+                        tokio::time::sleep(delay).await;
+                    }
+                }
+            }
+            previous = Some(candidate.source.clone());
+        }
+
+        // 全部候选失败:保留中间文件与元数据,下次(甚至下次启动)可续传。
+        part.preserve();
+        self.flush_health();
+        Err(DownloadError::new(
+            DownloadErrorKind::AllSourcesFailed,
+            format!("{} 个候选源全部失败:{}", plan.len(), req.url),
+        )
+        .with_suppressed(suppressed))
+    }
+
+    fn finish(
+        &self,
+        req: &DownloadRequest,
+        part: PartFile,
+        id: DownloadId,
+        candidate: &Candidate,
+        ok: AttemptOk,
+        started: Instant,
+    ) -> Result {
+        if let Some(integrity) = &req.integrity {
+            // 缓存写入失败不影响交付(缓存纯属加速)。
+            let _ = self.cache.insert(part.path(), integrity, now_ms());
+            let _ = self.cache.persist();
+        }
+        part.commit(&req.dest).map_err(|err| {
+            DownloadError::new(
+                DownloadErrorKind::Io,
+                format!("原子落盘失败({}):{err}", req.dest.display()),
+            )
+        })?;
+        let elapsed = started.elapsed();
+        self.emit(ProgressEvent::Completed {
+            id,
+            bytes: ok.bytes,
+            from_cache: false,
+            source: candidate.source.clone(),
+            elapsed_ms: millis(elapsed),
+        });
+        Ok(DownloadOutcome {
+            id,
+            path: req.dest.clone(),
+            bytes: ok.bytes,
+            from_cache: false,
+            source: candidate.source.clone(),
+            resumed_from: ok.resumed_from,
+            elapsed,
+        })
+    }
+
+    pub(crate) fn announce(&self, id: &DownloadId, req: &DownloadRequest, candidates: usize) {
+        self.emit(ProgressEvent::Started {
+            id: *id,
+            kind: req.kind,
+            url: req.url.clone(),
+            dest: req.dest.display().to_string(),
+            candidates,
+            total_bytes: req.expected_size,
+        });
+    }
+
+    pub(crate) async fn acquire(&self) -> Result {
+        Arc::clone(&self.sem).acquire_owned().await.map_err(|_| {
+            DownloadError::new(DownloadErrorKind::Io, "并发闸门已关闭(引擎正在销毁)")
+        })
+    }
+
+    /// 该源此刻给几次尝试:
+    ///
+    /// * 冷却中 → 1 次(这次就是「恢复探测」,失败会重新冷却);
+    /// * 刚出冷却、还没洗白 → 也只给 1 次(`is_probe()`,别让死源每次到期都吃满 3 次);
+    /// * 正常 → 配置的 `max_attempts_per_source`。
+    pub(crate) fn attempts_for(&self, source: &str, kind: ResourceKind, now_ms: u64) -> u32 {
+        let health = self.health.health_of(source, kind);
+        if health.is_cooling_down(now_ms) || health.is_probe(now_ms) {
+            1
+        } else {
+            self.cfg.max_attempts_per_source.max(1)
+        }
+    }
+
+    /// 一次下载收尾时把健康度节流落盘(对齐 `cache.persist()` 的位置,但带节流)。
+    pub(crate) fn flush_health(&self) {
+        let _ = self.health.flush(now_ms());
+    }
+
+    pub(crate) fn next_delay(&self, attempt: u32) -> Duration {
+        let mut jitter = self
+            .jitter
+            .lock()
+            .unwrap_or_else(|poisoned| poisoned.into_inner());
+        self.cfg.backoff.next_delay(attempt, &mut jitter)
+    }
+
+    pub(crate) fn emit(&self, event: ProgressEvent) {
+        self.progress.emit(event);
+    }
+}
+
+/// 缓存命中时把文件变成工作副本(硬链接优先,跨卷退化为复制)。
+fn materialize(source: &Path, dest: &Path) -> std::io::Result {
+    if let Some(parent) = dest.parent() {
+        if !parent.as_os_str().is_empty() {
+            std::fs::create_dir_all(parent)?;
+        }
+    }
+    if dest.exists() {
+        let _ = std::fs::remove_file(dest);
+    }
+    if std::fs::hard_link(source, dest).is_err() {
+        std::fs::copy(source, dest)?;
+    }
+    Ok(std::fs::metadata(dest)?.len())
+}
+
+pub(crate) fn millis(duration: Duration) -> u64 {
+    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
+}
diff --git a/crates/core/src/download/error.rs b/crates/core/src/download/error.rs
new file mode 100644
index 0000000..ed5f6cc
--- /dev/null
+++ b/crates/core/src/download/error.rs
@@ -0,0 +1,125 @@
+//! 下载错误:三态分类(同源重试 / 立刻换源 / 整体失败)+ 失败摘要。
+//!
+//! core 不引 `thiserror`(P0 阶段少一个依赖),手写 `Display`/`Error` 足够。
+
+use std::fmt;
+
+use serde::{Deserialize, Serialize};
+
+/// 错误分类:决定「同源重试」「立刻换源」还是「整体失败」。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum DownloadErrorKind {
+    /// 请求本身不合法(不重试)。
+    InvalidRequest,
+    /// 404/410/403:该源没有这个资源,立刻换源。
+    NotFound,
+    /// 5xx / 429:同源退避重试。
+    ServerError,
+    /// 超时:同源退避重试,多次后换源。
+    Timeout,
+    /// 连接/传输层错误。
+    Network,
+    /// 校验不符:同源重试一次后换源(可能是镜像内容不一致)。
+    Integrity,
+    /// 服务器不支持 Range(内部用于退化单连接,不向上暴露为失败)。
+    RangeUnsupported,
+    /// 本地 IO 错误(不重试)。
+    Io,
+    /// 所有候选源都失败。
+    AllSourcesFailed,
+}
+
+impl DownloadErrorKind {
+    /// 稳定字符串 id。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::InvalidRequest => "invalid_request",
+            Self::NotFound => "not_found",
+            Self::ServerError => "server_error",
+            Self::Timeout => "timeout",
+            Self::Network => "network",
+            Self::Integrity => "integrity",
+            Self::RangeUnsupported => "range_unsupported",
+            Self::Io => "io",
+            Self::AllSourcesFailed => "all_sources_failed",
+        }
+    }
+
+    /// 是否值得在同源上退避重试。
+    #[must_use]
+    pub const fn is_retryable(self) -> bool {
+        matches!(
+            self,
+            Self::ServerError | Self::Timeout | Self::Network | Self::Integrity
+        )
+    }
+}
+
+/// 下载错误。
+#[derive(Debug, Clone)]
+pub struct DownloadError {
+    /// 分类。
+    pub kind: DownloadErrorKind,
+    /// 出错的源(整体失败时为 `None`)。
+    pub source: Option,
+    /// 人类可读描述。
+    pub message: String,
+    /// 已经尝试的次数(含本次)。
+    pub attempts: usize,
+    /// 其它候选源的失败摘要,便于诊断「到底哪一层挂了」。
+    pub suppressed: Vec,
+}
+
+impl DownloadError {
+    /// 构造错误。
+    #[must_use]
+    pub fn new(kind: DownloadErrorKind, message: impl Into) -> Self {
+        Self {
+            kind,
+            source: None,
+            message: message.into(),
+            attempts: 0,
+            suppressed: Vec::new(),
+        }
+    }
+
+    /// 标注出错的源。
+    #[must_use]
+    pub fn with_source(mut self, source: impl Into) -> Self {
+        self.source = Some(source.into());
+        self
+    }
+
+    /// 标注尝试次数。
+    #[must_use]
+    pub fn with_attempts(mut self, attempts: usize) -> Self {
+        self.attempts = attempts;
+        self
+    }
+
+    /// 汇总其它源的失败摘要。
+    #[must_use]
+    pub fn with_suppressed(mut self, suppressed: Vec) -> Self {
+        self.suppressed = suppressed;
+        self
+    }
+}
+
+impl fmt::Display for DownloadError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match &self.source {
+            Some(source) => write!(
+                f,
+                "[{}] {}(源 {})",
+                self.kind.as_str(),
+                self.message,
+                source
+            ),
+            None => write!(f, "[{}] {}", self.kind.as_str(), self.message),
+        }
+    }
+}
+
+impl std::error::Error for DownloadError {}
diff --git a/crates/core/src/download/health.rs b/crates/core/src/download/health.rs
new file mode 100644
index 0000000..d3f6169
--- /dev/null
+++ b/crates/core/src/download/health.rs
@@ -0,0 +1,399 @@
+//! 源健康度记忆:`源 × 资源类型` 的 EMA RTT / 失败率 / 连续失败。
+//!
+//! HMCL 没有这一层(analyse/03 §8.4 的「下载快」一栏是纯并发 + 缓存),于是同一台机器上
+//! 一个常年 502 的镜像会被每个文件重试一遍。这里的规则:
+//!
+//! * 连续 **3** 次失败 → 冷却 **10 分钟**,期间该源排到最后(仍保留为兜底候选);
+//! * 冷却结束后自动进入**恢复探测**:下一次尝试就是一次探测,成功即清零回队,失败则重新冷却;
+//! * 记录持久化到 `cache/health.json`(原子写),进程重启后仍然生效:
+//!   **冷却状态迁移时立刻落盘**(进入/退出冷却这种罕见事件),其余变化标脏,
+//!   由引擎在一次 `download()` / `download_bytes()` 收尾时 `flush()` 节流落盘,
+//!   避免「批量下 3000 个资源 = 3000 次原子写」;
+//! * 排序打分 = EMA RTT + 失败率惩罚 + 连续失败惩罚,冷却中的源加一个大惩罚。
+
+use std::collections::HashMap;
+use std::io;
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+use std::sync::Mutex;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+use serde::{Deserialize, Serialize};
+
+use super::health_store;
+use super::types::{Candidate, ResourceKind, SOURCE_OFFICIAL};
+
+/// 连续失败多少次进入冷却。
+pub const FAILURE_THRESHOLD: u32 = 3;
+/// 冷却时长(毫秒):连续失败后的冷板凳时间。
+pub const COOLDOWN_MS: u64 = 10 * 60 * 1000;
+/// EMA 的平滑系数(0.3 意味着新样本占 30%)。
+pub const EMA_ALPHA: f64 = 0.3;
+/// 冷板凳/未知源的基础打分。
+pub const SCORE_UNKNOWN: f64 = 400.0;
+/// 落盘节流:两次 flush 之间的最小间隔(毫秒)。
+pub const SAVE_THROTTLE_MS: u64 = 5_000;
+/// 冷却中的惩罚项。
+pub const SCORE_COOLDOWN_PENALTY: f64 = 1_000.0;
+
+/// 健康度的记忆键:一个源在一种资源类型上的表现是独立的。
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct HealthKey {
+    /// 源标识(`official` / `bmclapi` / `tuna` …)。
+    pub source: String,
+    /// 资源类型。
+    pub kind: ResourceKind,
+}
+
+impl HealthKey {
+    /// 构造键。
+    #[must_use]
+    pub fn new(source: impl Into, kind: ResourceKind) -> Self {
+        Self {
+            source: source.into(),
+            kind,
+        }
+    }
+}
+
+/// 单个 `源 × 资源类型` 的健康记录。
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct SourceHealth {
+    /// 成功请求的 RTT 指数移动平均(毫秒);无样本时为 0。
+    pub ema_rtt_ms: f64,
+    /// 参与 EMA 的样本数。
+    pub samples: u64,
+    /// 累计成功次数。
+    pub successes: u64,
+    /// 累计失败次数。
+    pub failures: u64,
+    /// 累计「该源没有这个资源」(4xx)。**不计失败、不进冷却**——院校镜像通常只留最新补丁,
+    /// 请求旧补丁必然 404,把这种情况算成「源坏」会在一次安装里把唯一可用镜像冷却掉。
+    #[serde(default)]
+    pub not_found: u64,
+    /// 连续失败次数。
+    pub consecutive_failures: u32,
+    /// 冷却截止时间(unix 毫秒,0 = 未冷却)。
+    pub cooldown_until_ms: u64,
+    /// 最近一次失败原因(诊断页用)。
+    pub last_error: Option,
+    /// 最后更新时间(unix 毫秒)。
+    pub updated_at_ms: u64,
+}
+
+impl Default for SourceHealth {
+    fn default() -> Self {
+        Self {
+            ema_rtt_ms: 0.0,
+            samples: 0,
+            successes: 0,
+            failures: 0,
+            not_found: 0,
+            consecutive_failures: 0,
+            cooldown_until_ms: 0,
+            last_error: None,
+            updated_at_ms: 0,
+        }
+    }
+}
+
+impl SourceHealth {
+    /// 记录一次成功:重置连续失败与冷却,更新 EMA RTT。
+    pub fn record_success(&mut self, rtt_ms: u64, now_ms: u64) {
+        self.successes += 1;
+        self.consecutive_failures = 0;
+        self.cooldown_until_ms = 0;
+        self.last_error = None;
+        self.updated_at_ms = now_ms;
+        let rtt = rtt_ms as f64;
+        self.ema_rtt_ms = if self.samples == 0 {
+            rtt
+        } else {
+            EMA_ALPHA * rtt + (1.0 - EMA_ALPHA) * self.ema_rtt_ms
+        };
+        self.samples += 1;
+    }
+
+    /// 记录一次失败:达到阈值即进入冷却。
+    pub fn record_failure(&mut self, reason: &str, now_ms: u64) {
+        self.failures += 1;
+        self.consecutive_failures = self.consecutive_failures.saturating_add(1);
+        self.updated_at_ms = now_ms;
+        self.last_error = Some(reason.to_string());
+        if self.consecutive_failures >= FAILURE_THRESHOLD {
+            self.cooldown_until_ms = now_ms.saturating_add(COOLDOWN_MS);
+        }
+    }
+
+    /// 记录一次「该源没有这个资源」(HTTP 4xx):只留诊断痕迹,不动失败率与冷却。
+    pub fn record_missing(&mut self, reason: &str, now_ms: u64) {
+        self.not_found = self.not_found.saturating_add(1);
+        self.updated_at_ms = now_ms;
+        self.last_error = Some(reason.to_string());
+    }
+
+    /// 是否处于冷却期。
+    #[must_use]
+    pub fn is_cooling_down(&self, now_ms: u64) -> bool {
+        self.cooldown_until_ms > now_ms
+    }
+
+    /// 上次冷却是否已到期(此时这次尝试就是「恢复探测」)。
+    #[must_use]
+    pub fn is_probe(&self, now_ms: u64) -> bool {
+        self.cooldown_until_ms != 0 && self.cooldown_until_ms <= now_ms
+    }
+
+    /// 失败率(0.0~1.0)。
+    #[must_use]
+    pub fn failure_rate(&self) -> f64 {
+        let total = self.successes + self.failures;
+        if total == 0 {
+            0.0
+        } else {
+            self.failures as f64 / total as f64
+        }
+    }
+
+    /// 排序打分(越小越优先)。
+    #[must_use]
+    pub fn score(&self, now_ms: u64) -> f64 {
+        if self.is_cooling_down(now_ms) {
+            return SCORE_COOLDOWN_PENALTY + self.ema_rtt_ms;
+        }
+        if self.samples == 0 && self.failures == 0 {
+            return SCORE_UNKNOWN;
+        }
+        self.ema_rtt_ms + self.failure_rate() * 500.0 + f64::from(self.consecutive_failures) * 50.0
+    }
+}
+
+/// 健康度注册表(进程内共享,`Arc` 后传给引擎)。
+#[derive(Debug)]
+pub struct HealthRegistry {
+    path: Option,
+    entries: Mutex>,
+    dirty: AtomicBool,
+    last_save_ms: AtomicU64,
+}
+
+impl Default for HealthRegistry {
+    fn default() -> Self {
+        Self::in_memory()
+    }
+}
+
+impl HealthRegistry {
+    /// 纯内存实例(测试用 / 用户关掉健康度记忆时)。
+    #[must_use]
+    pub fn in_memory() -> Self {
+        Self {
+            path: None,
+            entries: Mutex::new(HashMap::new()),
+            dirty: AtomicBool::new(false),
+            last_save_ms: AtomicU64::new(0),
+        }
+    }
+
+    /// 打开持久化文件(不存在则从空表开始;损坏/超龄记录则忽略)。
+    #[must_use]
+    pub fn open(path: impl Into, max_age_ms: u64, now_ms: u64) -> Self {
+        let path = path.into();
+        let entries = health_store::load(&path, max_age_ms, now_ms);
+        Self {
+            path: Some(path),
+            entries: Mutex::new(entries),
+            dirty: AtomicBool::new(false),
+            last_save_ms: AtomicU64::new(now_ms),
+        }
+    }
+
+    /// 按 `cache/` 根目录打开(**生产接线用这一条**):文件固定在 `/health.json`,
+    /// 保留期用默认值。
+    #[must_use]
+    pub fn open_in(cache_root: impl AsRef, now_ms: u64) -> Self {
+        Self::open(
+            cache_root.as_ref().join(health_store::FILE_NAME),
+            health_store::MAX_AGE_MS,
+            now_ms,
+        )
+    }
+
+    /// 持久化文件路径(诊断页展示用)。
+    #[must_use]
+    pub fn path(&self) -> Option<&Path> {
+        self.path.as_deref()
+    }
+
+    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> {
+        self.entries
+            .lock()
+            .unwrap_or_else(|poisoned| poisoned.into_inner())
+    }
+
+    /// 读取某个键的记录(不存在则返回默认值)。
+    #[must_use]
+    pub fn health_of(&self, source: &str, kind: ResourceKind) -> SourceHealth {
+        self.lock()
+            .get(&HealthKey::new(source, kind))
+            .cloned()
+            .unwrap_or_default()
+    }
+
+    /// 记录成功;返回冷却状态是否发生变化(变化即立刻落盘)。
+    pub fn record_success(
+        &self,
+        source: &str,
+        kind: ResourceKind,
+        rtt: Duration,
+        now_ms: u64,
+    ) -> bool {
+        let changed = {
+            let mut entries = self.lock();
+            let health = entries.entry(HealthKey::new(source, kind)).or_default();
+            let before = health.cooldown_until_ms;
+            health.record_success(rtt.as_millis() as u64, now_ms);
+            before != health.cooldown_until_ms
+        };
+        self.after_record(changed);
+        changed
+    }
+
+    /// 记录失败;返回冷却状态是否发生变化(第 3 次连续失败会进入冷却 → 立刻落盘)。
+    pub fn record_failure(
+        &self,
+        source: &str,
+        kind: ResourceKind,
+        reason: &str,
+        now_ms: u64,
+    ) -> bool {
+        let changed = {
+            let mut entries = self.lock();
+            let health = entries.entry(HealthKey::new(source, kind)).or_default();
+            let before = health.cooldown_until_ms;
+            health.record_failure(reason, now_ms);
+            before != health.cooldown_until_ms
+        };
+        self.after_record(changed);
+        changed
+    }
+
+    /// 记录「该源没有这个资源」(4xx):不计失败、不进冷却,只更新诊断字段。
+    pub fn record_missing(&self, source: &str, kind: ResourceKind, reason: &str, now_ms: u64) {
+        {
+            let mut entries = self.lock();
+            let health = entries.entry(HealthKey::new(source, kind)).or_default();
+            health.record_missing(reason, now_ms);
+        }
+        self.after_record(false);
+    }
+
+    /// 记录后:标脏,冷却状态迁移则立刻落盘(这种事件很少,不会打爆磁盘)。
+    fn after_record(&self, cooldown_changed: bool) {
+        self.dirty.store(true, Ordering::Relaxed);
+        if cooldown_changed {
+            let _ = self.save();
+        }
+    }
+
+    /// 该源当前是否在冷却期。
+    #[must_use]
+    pub fn is_cooling_down(&self, source: &str, kind: ResourceKind, now_ms: u64) -> bool {
+        self.health_of(source, kind).is_cooling_down(now_ms)
+    }
+
+    /// 按健康度重排候选序列:健康好的在前,冷却中的排到最后(仍保留为兜底)。
+    #[must_use]
+    pub fn rank(
+        &self,
+        candidates: &[Candidate],
+        kind: ResourceKind,
+        now_ms: u64,
+    ) -> Vec {
+        let entries = self.lock();
+        let mut scored: Vec<(f64, usize, Candidate)> = candidates
+            .iter()
+            .enumerate()
+            .map(|(index, candidate)| {
+                let score = entries
+                    .get(&HealthKey::new(candidate.source.clone(), kind))
+                    .map_or(SCORE_UNKNOWN, |health| health.score(now_ms));
+                (score, index, candidate.clone())
+            })
+            .collect();
+        scored.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
+        scored
+            .into_iter()
+            .map(|(_, _, candidate)| candidate)
+            .collect()
+    }
+
+    /// 快照(诊断页/测试用),按源名排序保证稳定。
+    #[must_use]
+    pub fn snapshot(&self) -> Vec<(HealthKey, SourceHealth)> {
+        let entries = self.lock();
+        let mut rows: Vec<(HealthKey, SourceHealth)> = entries
+            .iter()
+            .map(|(key, health)| (key.clone(), health.clone()))
+            .collect();
+        rows.sort_by(|a, b| {
+            a.0.source
+                .cmp(&b.0.source)
+                .then(a.0.kind.as_str().cmp(b.0.kind.as_str()))
+        });
+        rows
+    }
+
+    /// 清空(设置页「重置下载源健康度」)。
+    pub fn clear(&self) {
+        self.lock().clear();
+    }
+
+    /// 无条件原子持久化到 `cache/health.json`(纯内存实例上是空操作)。
+    pub fn save(&self) -> io::Result<()> {
+        let Some(path) = self.path.clone() else {
+            self.dirty.store(false, Ordering::Relaxed);
+            return Ok(());
+        };
+        health_store::store(&path, &self.lock())?;
+        self.dirty.store(false, Ordering::Relaxed);
+        self.last_save_ms.store(now_wall_ms(), Ordering::Relaxed);
+        Ok(())
+    }
+
+    /// 下载收尾时的节流落盘:只有「脏」且距上次落盘超过 [`SAVE_THROTTLE_MS`] 才写。
+    ///
+    /// 引擎在每个 `download()` / `download_bytes()` 结束时调它——批量下载因此最多
+    /// 每 5 秒写一次盘,而不是每个文件一次。
+    pub fn flush(&self, now_ms: u64) -> io::Result<()> {
+        if self.path.is_none() || !self.dirty.load(Ordering::Relaxed) {
+            return Ok(());
+        }
+        let last = self.last_save_ms.load(Ordering::Relaxed);
+        if now_ms.saturating_sub(last) < SAVE_THROTTLE_MS {
+            return Ok(());
+        }
+        self.save()
+    }
+
+    /// 是否有未落盘的改动(诊断页/测试用)。
+    #[must_use]
+    pub fn is_dirty(&self) -> bool {
+        self.dirty.load(Ordering::Relaxed)
+    }
+}
+
+/// 官方源的空健康记录便捷构造(game 层拼候选时常用)。
+#[must_use]
+pub fn official_key(kind: ResourceKind) -> HealthKey {
+    HealthKey::new(SOURCE_OFFICIAL, kind)
+}
+
+/// 记录落盘时刻用的墙钟(不为它引入时钟抽象,测试走显式 `now_ms` 参数)。
+fn now_wall_ms() -> u64 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map_or(0, |since| {
+            u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
+        })
+}
diff --git a/crates/core/src/download/health_store.rs b/crates/core/src/download/health_store.rs
new file mode 100644
index 0000000..179d8fa
--- /dev/null
+++ b/crates/core/src/download/health_store.rs
@@ -0,0 +1,80 @@
+//! 健康度的落盘格式与读盘/写盘(`cache/health.json`)。
+//!
+//! 与 `health.rs` 分开的原因很实际:序列化格式与文件 IO 的细节会随版本增长(字段增删、
+//! 迁移、按源分文件…),把这些从「打分/冷却」的纯逻辑里挪出来,两边都好读好改。
+
+use std::collections::HashMap;
+use std::io;
+use std::path::Path;
+
+use serde::{Deserialize, Serialize};
+
+use super::atomic::write_atomic;
+use super::health::{HealthKey, SourceHealth};
+use super::types::ResourceKind;
+
+/// 健康度文件名(放在 `cache/` 下,与 `index.json` 同级)。
+pub const FILE_NAME: &str = "health.json";
+/// 记录保留期:超过这个时间没更新过的源直接丢掉(缓存目录可能被放很久)。
+pub const MAX_AGE_MS: u64 = 7 * 24 * 60 * 60 * 1000;
+
+/// 持久化的条目形态(`HealthKey` 拍平,便于 JSON 阅读)。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct PersistedEntry {
+    source: String,
+    kind: ResourceKind,
+    health: SourceHealth,
+}
+
+/// 持久化文件的容器。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct PersistedFile {
+    version: u32,
+    #[serde(default)]
+    entries: Vec,
+}
+
+/// 读盘:不存在/损坏/超龄都只丢掉对应记录,绝不阻断启动。
+pub(crate) fn load(path: &Path, max_age_ms: u64, now_ms: u64) -> HashMap {
+    let mut entries = HashMap::new();
+    let Ok(bytes) = std::fs::read(path) else {
+        return entries;
+    };
+    match serde_json::from_slice::(&bytes) {
+        Ok(file) => {
+            for entry in file.entries {
+                if entry.health.updated_at_ms.saturating_add(max_age_ms) >= now_ms {
+                    entries.insert(HealthKey::new(entry.source, entry.kind), entry.health);
+                }
+            }
+        }
+        Err(err) => warn(&format!("健康度文件解析失败,按空表处理:{err}")),
+    }
+    entries
+}
+
+/// 写盘:原子写(`tmp → rename`),失败由调用方决定是记日志还是忽略。
+pub(crate) fn store(path: &Path, entries: &HashMap) -> io::Result<()> {
+    let file = PersistedFile {
+        version: 1,
+        entries: entries
+            .iter()
+            .map(|(key, health)| PersistedEntry {
+                source: key.source.clone(),
+                kind: key.kind,
+                health: health.clone(),
+            })
+            .collect(),
+    };
+    let json = serde_json::to_vec_pretty(&file).map_err(io::Error::other)?;
+    write_atomic(path, &json)
+}
+
+/// core 不引 tracing(P0 阶段日志由 app 层接管),这里只做一次静默的 stderr 兜底。
+fn warn(message: &str) {
+    if cfg!(debug_assertions) {
+        eprintln!("[ull::download] {message}");
+    } else {
+        let _ = message;
+    }
+}
diff --git a/crates/core/src/download/http.rs b/crates/core/src/download/http.rs
new file mode 100644
index 0000000..6e6fd66
--- /dev/null
+++ b/crates/core/src/download/http.rs
@@ -0,0 +1,183 @@
+//! HTTP 薄封装:客户端构造、错误分类、响应头读取。
+//!
+//! 关键取舍:
+//! * **`Accept-Encoding: identity`** —— 下载要按字节对齐做 Range/校验,gzip 会让
+//!   `Content-Length` 与实际写入量打架(HMCL 为此专门写了按解压后偏移计数的
+//!   `HttpResumeContext`,analyse/03 §5.4),我们直接从协议层绕开这个问题;
+//! * 重定向上限 20 跳(承 HMCL),跳转后仍按最终 URL 做 host 校验由调用方负责;
+//! * 错误分类只做「同源重试 / 立刻换源 / 整体失败」三态,不做细颗粒映射。
+
+use reqwest::header::{
+    HeaderMap, HeaderName, ACCEPT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, ETAG, LAST_MODIFIED,
+};
+use reqwest::redirect::Policy;
+
+use super::config::EngineConfig;
+use super::error::{DownloadError, DownloadErrorKind};
+
+/// 重定向上限(承 HMCL 的 20 跳)。
+pub const MAX_REDIRECTS: usize = 20;
+
+/// 构造共享的 `reqwest::Client`(连接池、超时、重定向策略一次定好)。
+pub fn build_client(cfg: &EngineConfig) -> Result {
+    let mut headers = HeaderMap::new();
+    headers.insert(
+        ACCEPT_ENCODING,
+        reqwest::header::HeaderValue::from_static("identity"),
+    );
+    reqwest::Client::builder()
+        .user_agent(cfg.user_agent.clone())
+        .connect_timeout(cfg.connect_timeout)
+        .redirect(Policy::limited(MAX_REDIRECTS))
+        .default_headers(headers)
+        .build()
+        .map_err(|err| {
+            DownloadError::new(
+                DownloadErrorKind::Network,
+                format!("构造 HTTP 客户端失败:{err}"),
+            )
+        })
+}
+
+/// 把 `reqwest::Error` 映射成我们的错误分类。
+#[must_use]
+pub fn classify_reqwest(err: &reqwest::Error, url: &str) -> DownloadError {
+    let kind = if err.is_timeout() {
+        DownloadErrorKind::Timeout
+    } else if let Some(status) = err.status() {
+        return classify_status(status.as_u16(), url);
+    } else {
+        DownloadErrorKind::Network
+    };
+    DownloadError::new(kind, format!("{err}")).with_source(url)
+}
+
+/// HTTP 状态码 → 错误分类。
+#[must_use]
+pub fn classify_status(status: u16, url: &str) -> DownloadError {
+    let kind = match status {
+        404 | 410 => DownloadErrorKind::NotFound,
+        // 403/401 对镜像来说基本等于「这条路不能走」,直接换源比退避更有用。
+        401 | 403 => DownloadErrorKind::NotFound,
+        408 => DownloadErrorKind::Timeout,
+        416 => DownloadErrorKind::RangeUnsupported,
+        429 => DownloadErrorKind::ServerError,
+        500..=599 => DownloadErrorKind::ServerError,
+        _ => DownloadErrorKind::ServerError,
+    };
+    DownloadError::new(kind, format!("HTTP {status}")).with_source(url)
+}
+
+/// 读一个头的字符串值。
+#[must_use]
+pub fn header_str(headers: &HeaderMap, name: HeaderName) -> Option<&str> {
+    headers.get(name).and_then(|value| value.to_str().ok())
+}
+
+/// `Content-Length`。
+#[must_use]
+pub fn content_length(headers: &HeaderMap) -> Option {
+    header_str(headers, CONTENT_LENGTH).and_then(|value| value.trim().parse().ok())
+}
+
+/// `ETag`(续传的强校验器)。
+#[must_use]
+pub fn etag(headers: &HeaderMap) -> Option {
+    header_str(headers, ETAG).map(str::to_string)
+}
+
+/// `Last-Modified`(弱校验器,ETag 缺失时的兜底)。
+#[must_use]
+pub fn last_modified(headers: &HeaderMap) -> Option {
+    header_str(headers, LAST_MODIFIED).map(str::to_string)
+}
+
+/// `Content-Type`。
+#[must_use]
+pub fn content_type(headers: &HeaderMap) -> Option<&str> {
+    header_str(headers, CONTENT_TYPE)
+}
+
+/// 判断一个 2xx 响应是不是「伪装的档案」(镜像门户的假阳性兜底页)。
+///
+/// 出处:research/06 §0.2 / §5.3 第 2 条——`mirrors.huaweicloud.com` 对**任意不存在的路径**
+/// 都回 `200 + 12 434 字节 Angular 门户 HTML`,只看状态码会把死源当活源。
+///
+/// 两条判据(都只在证据充分时才拒):
+/// 1. `Content-Type` 含 `html` → 一定是门户页,不是归档;
+/// 2. 已知期望大小、且是整文件请求(`offset == 0`)、响应带 `Content-Length` 却对不上
+///    → 长度对拍失败,同样不是我们要的那个文件。
+///
+/// 返回 `Some(原因)` 时调用方应当**换源**(归到 `NotFound` 类:不算源坏、不进冷却)。
+#[must_use]
+pub fn html_mirror_reason(
+    headers: &HeaderMap,
+    expected_size: Option,
+    offset: u64,
+) -> Option<&'static str> {
+    if content_type(headers).is_some_and(|value| value.to_ascii_lowercase().contains("html")) {
+        return Some("源返回 HTML(镜像门户兜底页,假阳性:该路径没有真实归档)");
+    }
+    if offset == 0 {
+        if let (Some(expected), Some(actual)) = (expected_size, content_length(headers)) {
+            if expected > 0 && actual != expected {
+                return Some("源返回的 Content-Length 与期望大小不符(疑似门户页/截断响应)");
+            }
+        }
+    }
+    None
+}
+
+#[cfg(test)]
+mod tests {
+    use reqwest::header::{HeaderMap, HeaderValue, CONTENT_LENGTH, CONTENT_TYPE};
+
+    use super::{html_mirror_reason, MAX_REDIRECTS};
+
+    fn headers(content_type: Option<&str>, length: Option) -> HeaderMap {
+        let mut map = HeaderMap::new();
+        if let Some(value) = content_type {
+            map.insert(CONTENT_TYPE, HeaderValue::from_str(value).expect("头值"));
+        }
+        if let Some(value) = length {
+            map.insert(CONTENT_LENGTH, HeaderValue::from(value));
+        }
+        map
+    }
+
+    #[test]
+    fn html_portal_page_is_rejected_even_with_200() {
+        // 华为云实测:任意不存在的路径 → 200 + text/html + 12434 字节。
+        let map = headers(Some("text/html;charset=UTF-8"), Some(12_434));
+        assert_eq!(
+            html_mirror_reason(&map, Some(201_328_186), 0),
+            Some("源返回 HTML(镜像门户兜底页,假阳性:该路径没有真实归档)")
+        );
+        // 只有 Content-Type 也能判(没有期望大小时)。
+        assert!(html_mirror_reason(&map, None, 0).is_some());
+    }
+
+    #[test]
+    fn real_archive_matching_the_expected_size_passes() {
+        let map = headers(Some("application/zip"), Some(201_328_186));
+        assert_eq!(html_mirror_reason(&map, Some(201_328_186), 0), None);
+        // 镜像没给 Content-Type 也照放行(有 sha256 兜底)。
+        let bare = headers(None, Some(201_328_186));
+        assert_eq!(html_mirror_reason(&bare, Some(201_328_186), 0), None);
+    }
+
+    #[test]
+    fn mismatched_length_is_rejected_but_resumed_reads_are_left_alone() {
+        let map = headers(Some("application/octet-stream"), Some(973_920));
+        assert!(html_mirror_reason(&map, Some(205_421_281), 0).is_some());
+        // 续传片段当然比全量小:不能按整文件口径对拍。
+        assert_eq!(html_mirror_reason(&map, Some(205_421_281), 973_920), None);
+        // 未知期望大小 → 不做长度判定。
+        assert_eq!(html_mirror_reason(&map, None, 0), None);
+    }
+
+    #[test]
+    fn redirect_cap_is_still_twenty() {
+        assert_eq!(MAX_REDIRECTS, 20);
+    }
+}
diff --git a/crates/core/src/download/jdk_sources.rs b/crates/core/src/download/jdk_sources.rs
new file mode 100644
index 0000000..428f3e8
--- /dev/null
+++ b/crates/core/src/download/jdk_sources.rs
@@ -0,0 +1,178 @@
+//! JDK 分发源表(`mirrors.json` v3):Oracle OpenJDK / Microsoft OpenJDK / Azul Zulu。
+//!
+//! v2 只有 `adoptium`(Temurin)一张表,因为当时只做一家发行版。D23 定下三家 + 第四备选后,
+//! 三家的分发形态各不相同,表结构也就各有一套(research/06 §5.2 条目样例照抄):
+//!
+//! | 发行版 | 枚举方式 | 下载形态 |
+//! | --- | --- | --- |
+//! | Oracle OpenJDK | Apache 目录页(华为云 / 南大,与 `download.java.net` 同构) | `{base}/{ver}/{filename}`,hash 段不可推导 |
+//! | Microsoft OpenJDK | 内置版本快照(官方只有人读 HTML 表) | `aka.ms` 模板 301 → VS CDN,校验走成对的 `.sha256sum.txt` |
+//! | Zulu | 免登录 JSON API(两跳:列表 → 详情) | `download_url` 直出,只信 `sha256_hash` |
+//!
+//! 表本身只是「数据」:怎么用由 `game::java` 的 `oracle` / `microsoft` / `zulu` 决定。
+//! v3 兼容读 v2:新表全部 `#[serde(default)]`,老文件照常解析(新表为空,回退链自然退化成只有 Temurin)。
+
+use serde::{Deserialize, Serialize};
+
+use super::mirrors::MirrorTable;
+
+const fn default_true() -> bool {
+    true
+}
+
+/// 一个 Oracle OpenJDK 镜像(`download.java.net/openjdk` 的忠实镜像)。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct OracleMirror {
+    /// 源标识(`huawei` / `nju`)。
+    pub id: String,
+    /// 展示名。
+    pub name: String,
+    /// 目录根(形如 `https://mirrors.huaweicloud.com/openjdk`)。
+    pub base: String,
+    /// 是否启用。
+    #[serde(default = "default_true")]
+    pub enabled: bool,
+    /// 实测吞吐(展示用,不参与决策)。
+    #[serde(default)]
+    pub speed: String,
+    /// 备注(展示用)。
+    #[serde(default)]
+    pub note: String,
+}
+
+impl OracleMirror {
+    /// 目录根(去掉尾斜杠,拼路径时不会再出双斜杠)。
+    #[must_use]
+    pub fn base(&self) -> &str {
+        self.base.trim_end_matches('/')
+    }
+}
+
+/// Microsoft OpenJDK 的分发表:官方模板 + 内置版本快照。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MsOpenJdkTable {
+    /// 人读清单页(刷新快照时抓的页面,仅供排查)。
+    #[serde(default)]
+    pub list: String,
+    /// 下载模板:`microsoft-jdk-{version}-{os}-{arch}.zip`。
+    pub template: String,
+    /// 校验模板:同一文件名 + `.sha256sum.txt`。
+    pub sha256_template: String,
+    /// 官方在发的 GA 大版本(没有 8——微软已停发,官方页面让去用 Temurin 8)。
+    #[serde(default)]
+    pub majors: Vec,
+    /// 国内镜像(实测为空数组:三家云都没有 MS OpenJDK)。
+    #[serde(default)]
+    pub domestic_mirrors: Vec,
+    /// 实测吞吐(展示用)。
+    #[serde(default)]
+    pub measured_speed: String,
+}
+
+/// Zulu(Azul)的元数据表。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ZuluTable {
+    /// 包列表 API 根。
+    pub api: String,
+    /// 包详情 API(`{uuid}` 占位)——`latest=true` 的响应不含 sha256,必须再取一次。
+    pub detail: String,
+    /// CDN host(`cdn.azul.com`,展示用)。
+    #[serde(default)]
+    pub cdn_host: String,
+    /// 实测吞吐(展示用)。
+    #[serde(default)]
+    pub measured_speed: String,
+    /// `size` 字段是否可与真实字节数对拍(实测 `false`,差 133 B)。
+    #[serde(default)]
+    pub size_field_reliable: bool,
+}
+
+impl MirrorTable {
+    /// 启用的 Oracle OpenJDK 镜像(清单顺序 = 默认尝试顺序)。
+    pub fn oracle_mirrors(&self) -> impl Iterator {
+        self.oracle_openjdk.iter().filter(|mirror| mirror.enabled)
+    }
+
+    /// 按 id 找 Oracle 镜像(诊断 / `java vendors` 用;停用的也能查到)。
+    #[must_use]
+    pub fn oracle_mirror(&self, id: &str) -> Option<&OracleMirror> {
+        self.oracle_openjdk
+            .iter()
+            .find(|mirror| mirror.id.eq_ignore_ascii_case(id))
+    }
+
+    /// Microsoft OpenJDK 分发表(v2 清单没有 → `None`)。
+    #[must_use]
+    pub fn microsoft_openjdk(&self) -> Option<&MsOpenJdkTable> {
+        self.microsoft_openjdk.as_ref()
+    }
+
+    /// Zulu 元数据表。
+    #[must_use]
+    pub fn zulu(&self) -> Option<&ZuluTable> {
+        self.zulu.as_ref()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::MirrorTable;
+
+    #[test]
+    fn builtin_table_is_v3_with_all_four_source_families() {
+        let table = MirrorTable::builtin();
+        assert_eq!(table.version, 3);
+        // Temurin:只剩 TUNA 一家启用,且带 latest_only 标记。
+        let enabled: Vec<&str> = table
+            .adoptium
+            .iter()
+            .filter(|mirror| mirror.enabled)
+            .map(|mirror| mirror.id.as_str())
+            .collect();
+        assert_eq!(enabled, vec!["tuna"]);
+        assert!(table
+            .adoptium
+            .iter()
+            .filter(|m| m.enabled)
+            .all(|m| m.latest_only));
+        // 停用的登记项要在(将来恢复时不需要重写清单)。
+        for id in ["ustc", "sjtu", "bfsu", "nju", "zju", "163"] {
+            assert!(
+                table.adoptium.iter().any(|mirror| mirror.id == id),
+                "{id} 应登记为 disabled"
+            );
+        }
+        assert!(table.oracle_mirrors().any(|mirror| mirror.id == "huawei"));
+        let microsoft = table.microsoft_openjdk().expect("v3 应带 MS 表");
+        assert_eq!(microsoft.majors, vec![25, 21, 17, 11]);
+        assert!(microsoft.template.contains("{version}"));
+        assert!(microsoft.sha256_template.ends_with(".sha256sum.txt"));
+        let zulu = table.zulu().expect("v3 应带 Zulu 表");
+        assert!(zulu.detail.contains("{uuid}"));
+        assert!(!zulu.size_field_reliable, "Zulu 的 size 字段不可对拍");
+    }
+
+    #[test]
+    fn v2_table_still_parses() {
+        // v2 清单(没有三张新表)必须照常解析,回退链退化为「只有 Temurin」。
+        let v2 = r#"{
+            "version": 2,
+            "adoptium": [
+                { "id": "tuna", "name": "清华 TUNA",
+                  "base": "https://mirrors.tuna.tsinghua.edu.cn/Adoptium", "enabled": true }
+            ],
+            "gh_proxies": [],
+            "rules": []
+        }"#;
+        let table = MirrorTable::parse(v2).expect("v2 应兼容");
+        assert_eq!(table.version, 2);
+        assert_eq!(table.adoptium.len(), 1);
+        assert!(
+            !table.adoptium[0].latest_only,
+            "v2 没有 latest_only → 默认 false"
+        );
+        assert!(table.microsoft_openjdk().is_none());
+        assert!(table.zulu().is_none());
+        assert_eq!(table.oracle_mirrors().count(), 0);
+    }
+}
diff --git a/crates/core/src/download/mirrors.json b/crates/core/src/download/mirrors.json
new file mode 100644
index 0000000..8075108
--- /dev/null
+++ b/crates/core/src/download/mirrors.json
@@ -0,0 +1,169 @@
+{
+  "version": 3,
+  "notes": [
+    "v3(D23/M36):adoptium 表收敛到清华 TUNA 一家(其余院校实测 404,登记保留但 enabled=false);新增 oracle_openjdk / microsoft_openjdk / zulu 三张表,供 game::java 的三发行版回退链使用。",
+    "华为云 / 腾讯云 / 阿里云没有 Adoptium 镜像:华为云门户对任意不存在的路径都回 200 + 12KB HTML(假阳性,M34 实测),腾讯云 /v1/source.js 与华为云 /v1/repositories 的权威清单里都没有 JDK 项。据此不再登记 adoptium.huaweicloud。",
+    "oracle_openjdk 的目录与文件名与 download.java.net 完全同构,但 hash 段(f2283984656d49d69e91c558476027ac)不可推导——候选 URL 只能来自对镜像目录页的解析,禁止拼装。",
+    "microsoft_openjdk 没有国内镜像,也没有 GitHub 资产(api.github.com 的 microsoft/openjdk releases 里 assets 全空);唯一渠道是 aka.ms 模板 301 → download.visualstudio.microsoft.com(国内约 400 KB/s)。",
+    "zulu 的 API size 字段与真实字节数差 133 B,只能用于展示;校验一律用 sha256_hash。"
+  ],
+  "gh_proxies": [
+    {
+      "id": "gh-proxy",
+      "name": "gh-proxy.com",
+      "prefix": "https://gh-proxy.com/",
+      "enabled": true
+    }
+  ],
+  "adoptium": [
+    {
+      "id": "tuna",
+      "name": "清华 TUNA",
+      "base": "https://mirrors.tuna.tsinghua.edu.cn/Adoptium",
+      "enabled": true,
+      "latest_only": true
+    },
+    {
+      "id": "ustc",
+      "name": "中科大 USTC(无 Adoptium 目录树:只有 deb/rpm/releases,实测 404)",
+      "base": "https://mirrors.ustc.edu.cn/adoptium",
+      "enabled": false
+    },
+    {
+      "id": "sjtu",
+      "name": "上交 SJTU(无 Adoptium 目录树,实测 404)",
+      "base": "https://mirror.sjtu.edu.cn/adoptium",
+      "enabled": false
+    },
+    {
+      "id": "bfsu",
+      "name": "北外 BFSU(无 Adoptium 目录树,实测 404)",
+      "base": "https://mirrors.bfsu.edu.cn/Adoptium",
+      "enabled": false
+    },
+    {
+      "id": "nju",
+      "name": "南大 NJU(adoptium 路径实测 404;/openjdk/ 走 oracle_openjdk 表)",
+      "base": "https://mirror.nju.edu.cn/adoptium",
+      "enabled": false
+    },
+    {
+      "id": "zju",
+      "name": "浙大 ZJU(无 Adoptium 目录树,实测 404 搜索页)",
+      "base": "https://mirrors.zju.edu.cn/adoptium",
+      "enabled": false
+    },
+    {
+      "id": "163",
+      "name": "网易 163(无 Adoptium 目录树,实测 404 且 18 秒才回)",
+      "base": "https://mirrors.163.com/adoptium",
+      "enabled": false
+    }
+  ],
+  "oracle_openjdk": [
+    {
+      "id": "huawei",
+      "name": "华为云",
+      "base": "https://mirrors.huaweicloud.com/openjdk",
+      "enabled": true,
+      "speed": "11.5MB/s",
+      "note": "逐字节忠实镜像(21.0.2 = 201328186 B 与官方一致);目录页是 Apache 列表,带 
;归档旁有 .sha256"
+    },
+    {
+      "id": "nju",
+      "name": "南大 NJU",
+      "base": "https://mirror.nju.edu.cn/openjdk",
+      "enabled": true,
+      "speed": "8.3MB/s",
+      "note": "需先吃一次 bcheck cookie(裸 GET 是 302 回自己);健康度会把它排在华为云之后,失败即降级"
+    }
+  ],
+  "microsoft_openjdk": {
+    "list": "https://learn.microsoft.com/java/openjdk/download",
+    "template": "https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip",
+    "sha256_template": "https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip.sha256sum.txt",
+    "majors": [25, 21, 17, 11],
+    "domestic_mirrors": [],
+    "measured_speed": "398KB/s"
+  },
+  "zulu": {
+    "api": "https://api.azul.com/metadata/v1/zulu/packages/",
+    "detail": "https://api.azul.com/metadata/v1/zulu/packages/{uuid}",
+    "cdn_host": "cdn.azul.com",
+    "measured_speed": "3.3MB/s",
+    "size_field_reliable": false
+  },
+  "rules": [
+    {
+      "id": "bmclapi-launchermeta",
+      "from": "https://launchermeta.mojang.com",
+      "to": "https://bmclapi2.bangbang93.com"
+    },
+    {
+      "id": "bmclapi-piston-meta",
+      "from": "https://piston-meta.mojang.com",
+      "to": "https://bmclapi2.bangbang93.com"
+    },
+    {
+      "id": "bmclapi-piston-data",
+      "from": "https://piston-data.mojang.com",
+      "to": "https://bmclapi2.bangbang93.com"
+    },
+    {
+      "id": "bmclapi-launcher",
+      "from": "https://launcher.mojang.com",
+      "to": "https://bmclapi2.bangbang93.com"
+    },
+    {
+      "id": "bmclapi-libraries",
+      "from": "https://libraries.minecraft.net",
+      "to": "https://bmclapi2.bangbang93.com"
+    },
+    {
+      "id": "bmclapi-assets-objects",
+      "from": "https://resources.download.minecraft.net",
+      "to": "https://bmclapi2.bangbang93.com",
+      "inject": "/assets"
+    },
+    {
+      "id": "bmclapi-forge-maven",
+      "from": "https://files.minecraftforge.net/maven",
+      "to": "https://bmclapi2.bangbang93.com/maven"
+    },
+    {
+      "id": "bmclapi-forge-maven-net",
+      "from": "https://maven.minecraftforge.net",
+      "to": "https://bmclapi2.bangbang93.com/maven"
+    },
+    {
+      "id": "bmclapi-neoforge",
+      "from": "https://maven.neoforged.net/releases",
+      "to": "https://bmclapi2.bangbang93.com/maven"
+    },
+    {
+      "id": "bmclapi-fabric-meta",
+      "from": "https://meta.fabricmc.net",
+      "to": "https://bmclapi2.bangbang93.com/fabric-meta"
+    },
+    {
+      "id": "bmclapi-fabric-maven",
+      "from": "https://maven.fabricmc.net",
+      "to": "https://bmclapi2.bangbang93.com/maven"
+    },
+    {
+      "id": "bmclapi-quilt-meta",
+      "from": "https://meta.quiltmc.org",
+      "to": "https://bmclapi2.bangbang93.com/quilt-meta"
+    },
+    {
+      "id": "bmclapi-liteloader",
+      "from": "https://dl.liteloader.com/versions",
+      "to": "https://bmclapi2.bangbang93.com/maven"
+    },
+    {
+      "id": "bmclapi-authlib-injector",
+      "from": "https://authlib-injector.yushi.moe",
+      "to": "https://bmclapi2.bangbang93.com/mirrors/authlib-injector"
+    }
+  ]
+}
diff --git a/crates/core/src/download/mirrors.rs b/crates/core/src/download/mirrors.rs
new file mode 100644
index 0000000..e16b6cb
--- /dev/null
+++ b/crates/core/src/download/mirrors.rs
@@ -0,0 +1,386 @@
+//! 镜像清单与 URL 前缀重写。
+//!
+//! 内置清单 `mirrors.json` 随源码嵌入(`include_str!`),发行后可由设置层追加自定义规则。
+//! 重写语义承 analyse/03 §5.2 的「前缀替换而非全量重写」——保留子路径,因此 `/libraries`、
+//! `/maven`、`/fabric-meta` 这类子路径天然兼容;`inject` 用于补上路径形状不同的场景
+//! (资源对象:官方 `/`,BMCLAPI `/assets//`)。
+//!
+//! gh-proxy 规则(用户定案):**只有国内模式 + GitHub host** 才套前缀,其它资源不受影响。
+
+use serde::{Deserialize, Serialize};
+
+pub use super::adoptium::AdoptiumMirror;
+use super::jdk_sources::{MsOpenJdkTable, OracleMirror, ZuluTable};
+use super::region::{RegionMode, RegionProfile};
+use super::types::{Candidate, ResourceKind, SOURCE_OFFICIAL};
+
+/// gh-proxy 候选的源标识。
+pub const SOURCE_GH_PROXY: &str = "gh_proxy";
+
+const fn default_true() -> bool {
+    true
+}
+
+/// 一条前缀重写规则。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PrefixRule {
+    /// 规则 id(日志/诊断用)。
+    pub id: String,
+    /// 匹配的 URL 前缀(含 scheme)。
+    pub from: String,
+    /// 替换成的前缀。
+    pub to: String,
+    /// 插在 `to` 与剩余路径之间的内容(默认空)。
+    #[serde(default)]
+    pub inject: String,
+    /// 「仅加速不替代」:镜像优先模式下仍排在官方之后。
+    #[serde(default)]
+    pub fallback: bool,
+    /// 只对这些资源类型生效(空 = 全部)。
+    #[serde(default)]
+    pub kinds: Vec,
+    /// 是否启用(设置层可关)。
+    #[serde(default = "default_true")]
+    pub enabled: bool,
+}
+
+impl PrefixRule {
+    /// 构造一条最简规则。
+    #[must_use]
+    pub fn new(id: impl Into, from: impl Into, to: impl Into) -> Self {
+        Self {
+            id: id.into(),
+            from: from.into(),
+            to: to.into(),
+            inject: String::new(),
+            fallback: false,
+            kinds: Vec::new(),
+            enabled: true,
+        }
+    }
+
+    /// 设一个「仅加速」标记。
+    #[must_use]
+    pub fn fallback(mut self) -> Self {
+        self.fallback = true;
+        self
+    }
+
+    /// 设置注入片段。
+    #[must_use]
+    pub fn with_inject(mut self, inject: impl Into) -> Self {
+        self.inject = inject.into();
+        self
+    }
+
+    /// 是否命中该 URL(前缀匹配 + 资源类型过滤 + 启用位)。
+    #[must_use]
+    pub fn matches(&self, url: &str, kind: ResourceKind) -> bool {
+        self.enabled
+            && url.starts_with(&self.from)
+            && (self.kinds.is_empty() || self.kinds.contains(&kind))
+    }
+
+    /// 生成镜像候选。
+    #[must_use]
+    pub fn apply(&self, url: &str) -> Option {
+        if !url.starts_with(&self.from) {
+            return None;
+        }
+        let rest = &url[self.from.len()..];
+        let rewritten = format!("{}{}{}", self.to, self.inject, rest);
+        Some(Candidate::new(source_id_for(&rewritten), rewritten))
+    }
+}
+
+/// 一个 gh-proxy 类前缀。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct GhProxy {
+    /// 源标识。
+    pub id: String,
+    /// 展示名。
+    pub name: String,
+    /// 前缀(形如 `https://gh-proxy.com/`)。
+    pub prefix: String,
+    /// 是否启用。
+    #[serde(default = "default_true")]
+    pub enabled: bool,
+}
+
+/// 镜像表:内置规则 + 用户自定义规则。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MirrorTable {
+    /// 清单版本(便于诊断页显示「镜像表是哪一版」)。
+    pub version: u32,
+    /// 清单自带的说明(假阳性成因、不可推导项等,给运维看的原地注释)。
+    #[serde(default)]
+    pub notes: Vec,
+    /// 前缀重写规则(前面的优先)。
+    #[serde(default)]
+    pub rules: Vec,
+    /// Adoptium 院校镜像。
+    #[serde(default)]
+    pub adoptium: Vec,
+    /// Oracle OpenJDK 镜像(v3 新增)。
+    #[serde(default)]
+    pub oracle_openjdk: Vec,
+    /// Microsoft OpenJDK 分发模板(v3 新增)。
+    #[serde(default)]
+    pub microsoft_openjdk: Option,
+    /// Zulu 元数据(v3 新增)。
+    #[serde(default)]
+    pub zulu: Option,
+    /// gh-proxy 类前缀。
+    #[serde(default)]
+    pub gh_proxies: Vec,
+}
+
+impl Default for MirrorTable {
+    fn default() -> Self {
+        Self::builtin()
+    }
+}
+
+impl MirrorTable {
+    /// 内置清单(编译期嵌入)。
+    #[must_use]
+    pub fn builtin() -> Self {
+        Self::parse(include_str!("mirrors.json")).unwrap_or_else(|err| {
+            debug_note(&format!("内置 mirrors.json 解析失败(不应发生):{err}"));
+            Self {
+                version: 0,
+                notes: Vec::new(),
+                rules: Vec::new(),
+                adoptium: Vec::new(),
+                oracle_openjdk: Vec::new(),
+                microsoft_openjdk: None,
+                zulu: None,
+                gh_proxies: Vec::new(),
+            }
+        })
+    }
+
+    /// 从 JSON 解析。
+    pub fn parse(json: &str) -> Result {
+        serde_json::from_str(json)
+    }
+
+    /// 序列化成 JSON(诊断页/用户自定义文件回写用)。
+    pub fn to_json_pretty(&self) -> Result {
+        serde_json::to_string_pretty(self)
+    }
+
+    /// 追加用户自定义规则(插到最前,优先于内置规则)。
+    pub fn add_custom_rule(&mut self, rule: PrefixRule) {
+        self.rules.insert(0, rule);
+    }
+
+    /// 覆盖 gh-proxy 前缀(设置项);传 `None` 表示不默认启用。
+    pub fn set_gh_proxy(&mut self, prefix: Option<&str>) {
+        match prefix {
+            Some(prefix) => {
+                if let Some(proxy) = self.gh_proxies.first_mut() {
+                    proxy.prefix = prefix.to_string();
+                    proxy.enabled = true;
+                } else {
+                    self.gh_proxies.push(GhProxy {
+                        id: "gh-proxy".to_string(),
+                        name: "自定义 gh-proxy".to_string(),
+                        prefix: prefix.to_string(),
+                        enabled: true,
+                    });
+                }
+            }
+            None => {
+                for proxy in &mut self.gh_proxies {
+                    proxy.enabled = false;
+                }
+            }
+        }
+    }
+
+    /// 找到第一条命中的前缀规则。
+    #[must_use]
+    pub fn prefix_rule_for(&self, url: &str, kind: ResourceKind) -> Option<&PrefixRule> {
+        self.rules.iter().find(|rule| rule.matches(url, kind))
+    }
+
+    /// 清单里第一个启用的 gh-proxy 前缀(默认值来源;设置层用它初始化地区档案)。
+    #[must_use]
+    pub fn default_gh_proxy(&self) -> Option<&str> {
+        self.gh_proxies
+            .iter()
+            .find(|proxy| proxy.enabled && !proxy.prefix.is_empty())
+            .map(|proxy| proxy.prefix.as_str())
+    }
+
+    /// GitHub host 且允许时套 gh-proxy 前缀。
+    ///
+    /// **生效前缀的唯一真相源是 `region.gh_proxy`**(设置层/地区判定写入,`None` = 用户关闭)。
+    /// 表格里的 `gh_proxies` 只是「已知可用前缀」清单,配合 `default_gh_proxy()` 给设置做默认值。
+    #[must_use]
+    pub fn gh_proxy_candidates(&self, url: &str, region: &RegionProfile) -> Vec {
+        if region.mode != RegionMode::MirrorFirst {
+            return Vec::new();
+        }
+        let Some(prefix) = region
+            .gh_proxy
+            .as_deref()
+            .map(str::trim)
+            .filter(|prefix| !prefix.is_empty())
+        else {
+            return Vec::new();
+        };
+        let Some(host) = host_of(url) else {
+            return Vec::new();
+        };
+        if !is_github_host(host) {
+            return Vec::new();
+        }
+        vec![Candidate::new(SOURCE_GH_PROXY, format!("{prefix}{url}"))]
+    }
+
+    /// 展开候选序列:镜像优先模式下镜像在前、官方兜底;直连模式下官方在前、镜像兜底。
+    #[must_use]
+    pub fn expand(&self, url: &str, kind: ResourceKind, region: &RegionProfile) -> Vec {
+        let official = Candidate::new(direct_source_id(url), url);
+        let mut mirrors: Vec = Vec::new();
+        let mut official_first = false;
+
+        for candidate in self.adoptium_candidates(url) {
+            push_unique(&mut mirrors, url, candidate);
+        }
+        if let Some(rule) = self.prefix_rule_for(url, kind) {
+            if let Some(candidate) = rule.apply(url) {
+                // 「仅加速不替代」的规则在镜像优先模式下也排官方之后。
+                official_first = rule.fallback;
+                push_unique(&mut mirrors, url, candidate);
+            }
+        }
+        for candidate in self.gh_proxy_candidates(url, region) {
+            push_unique(&mut mirrors, url, candidate);
+        }
+
+        if region.mode == RegionMode::MirrorFirst && !official_first {
+            mirrors.push(official);
+            mirrors
+        } else {
+            let mut out = Vec::with_capacity(mirrors.len() + 1);
+            out.push(official);
+            out.append(&mut mirrors);
+            out
+        }
+    }
+}
+
+fn push_unique(list: &mut Vec, official_url: &str, candidate: Candidate) {
+    if candidate.url == official_url || list.iter().any(|known| known.url == candidate.url) {
+        return;
+    }
+    list.push(candidate);
+}
+
+/// 直连候选的源标识。
+///
+/// 绝大多数资源是 Mojang/CDN 的「官方源」,标 `official`;但 JDK 的四家发行版里,
+/// Oracle(国内镜像)/ MS(VS CDN)/ Zulu(Azul CDN)的**直连 URL 本身就是某个厂商的 CDN**,
+/// 标成 `official` 会让用户和健康度记忆都看错(「来源 official」其实来自华为云)。
+/// 所以:认得的厂商/镜像 host 用它的稳定 id,其余一律 `official`。
+#[must_use]
+pub fn direct_source_id(url: &str) -> String {
+    let id = source_id_for(url);
+    if MIRROR_SOURCE_IDS.contains(&id.as_str()) {
+        id
+    } else {
+        SOURCE_OFFICIAL.to_string()
+    }
+}
+
+/// 会被 `direct_source_id` 认出来的厂商/镜像源 id(都是「非官方 CDN/镜像」那一类)。
+pub const MIRROR_SOURCE_IDS: &[&str] = &[
+    "bmclapi",
+    "tuna",
+    "ustc",
+    "sjtu",
+    "bfsu",
+    "nju",
+    "huawei",
+    "azul",
+    "microsoft",
+    SOURCE_GH_PROXY,
+];
+
+/// 从 URL 里抠出 host(无 `url` 依赖)。
+#[must_use]
+pub fn host_of(url: &str) -> Option<&str> {
+    let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
+    let authority = rest.split(['/', '?', '#']).next()?;
+    let host_port = authority.rsplit('@').next()?;
+    let host = host_port.split(':').next()?;
+    if host.is_empty() {
+        None
+    } else {
+        Some(host)
+    }
+}
+
+/// 是否是 GitHub 系 host(gh-proxy 只对这些套前缀)。
+#[must_use]
+pub fn is_github_host(host: &str) -> bool {
+    matches!(
+        host,
+        "github.com"
+            | "raw.githubusercontent.com"
+            | "objects.githubusercontent.com"
+            | "codeload.github.com"
+            | "gist.github.com"
+            | "gist.githubusercontent.com"
+    ) || host.ends_with(".githubusercontent.com")
+}
+
+/// URL 的源标识(健康度按此归并;不知名 host 直接用 host)。
+#[must_use]
+pub fn source_id_for(url: &str) -> String {
+    let Some(host) = host_of(url) else {
+        return SOURCE_OFFICIAL.to_string();
+    };
+    if host.ends_with("bangbang93.com") {
+        return "bmclapi".to_string();
+    }
+    if host.contains("tuna.tsinghua.edu.cn") {
+        return "tuna".to_string();
+    }
+    if host.contains("ustc.edu.cn") {
+        return "ustc".to_string();
+    }
+    if host.contains("sjtu.edu.cn") {
+        return "sjtu".to_string();
+    }
+    if host.contains("bfsu.edu.cn") {
+        return "bfsu".to_string();
+    }
+    if host.contains("nju.edu.cn") {
+        return "nju".to_string();
+    }
+    if host.contains("huaweicloud.com") {
+        return "huawei".to_string();
+    }
+    if host.contains("azul.com") {
+        return "azul".to_string();
+    }
+    if host.contains("visualstudio.microsoft.com") || host == "aka.ms" {
+        return "microsoft".to_string();
+    }
+    if host.contains("gh-proxy") {
+        return SOURCE_GH_PROXY.to_string();
+    }
+    host.to_string()
+}
+
+fn debug_note(message: &str) {
+    if cfg!(debug_assertions) {
+        eprintln!("[ull::download] {message}");
+    } else {
+        let _ = message;
+    }
+}
diff --git a/crates/core/src/download/mod.rs b/crates/core/src/download/mod.rs
new file mode 100644
index 0000000..a66155a
--- /dev/null
+++ b/crates/core/src/download/mod.rs
@@ -0,0 +1,90 @@
+//! 下载引擎:多源健康度、指数退避、分层下载(大文件分块)、边下边算、原子落盘。
+//!
+//! 设计定稿:`DevDocs/PlanDoc/05-download-and-mirrors.md`(决策 D7)。
+//! 镜像清单随更新可刷新(BMCLAPI / TUNA / USTC / SJTU / gh-proxy),
+//! 地区自动判定 = 时区 + 语言 + 网络探测组合信号,可手动覆盖。
+//!
+//! 与其它模块的契约:`DownloadRequest` / 进度事件的类型需与 game、agent 对齐
+//! (`DevDocs/SubAgentsTalk/README.md` 的对齐点)。
+//!
+//! # 公共面
+//!
+//! 调用方只需要三件套:构造 [`DownloadRequest`] → [`DownloadEngine::download`] →
+//! 消费 [`ProgressEvent`]。「用哪个镜像、要不要续传、开几条连接、失败重试几次」
+//! 全部由引擎内部决定,调用方既不拼镜像 URL 也不写重试循环。
+//!
+//! 其余构件(镜像表 / 地区档案 / 健康度 / 内容寻址缓存 / 续传元数据)都作为
+//! 可注入对象暴露出来:设置层要把它们接进 schema,诊断页要直接读它们的状态,
+//! 测试要能把它们换成确定性的假件。
+//!
+//! # 分层
+//!
+//! ```text
+//! engine        编排:缓存直取 → 候选规划 → 限流 → 候选外循环 / 退避内循环
+//! transfer      单连接尝试(续传 + 流式校验)
+//! transfer_chunk 多连接分块编排
+//! chunked       分块规划 / Range 语义 / 单块抓取
+//! mirrors       镜像清单 + URL 前缀重写      adoptium  JDK 院校镜像
+//! region        时区 + 语言 + 探测 → 直连/镜像优先
+//! health        源 × 资源类型的健康度记忆
+//! cache         内容寻址缓存(记录信任)      atomic    落盘守卫 / 原子写
+//! resume        保守续传判定                  verify    流式摘要
+//! progress      事件流                        error     错误分类
+//! ```
+//!
+//! 落盘守卫([`PartFile`])中间文件名是 `.ullpart--`:`session` 是
+//! 进程号,`seq` 是进程内序号。同一数据根跑两个启动器进程时不会互相搬走对方的中间文件
+//! (M35 真机:并行两次 `ull install` 各失败数百个资源,`os error 2`);旧续传材料由
+//! `PartFile::for_dest` 收养,单进程语义不变。
+//!
+//! 依赖纪律:本模块只用 `reqwest` / `tokio` / `futures-util` / `sha1` / `sha2` /
+//! `serde` / `serde_json`,不触碰任何 UI 框架(`crates/core` 的硬约束)。
+
+// 对外可用的构件(设置层 / 诊断页 / 其它模块直接消费)。
+pub mod adoptium;
+pub mod backoff;
+pub mod blocking;
+pub mod cache;
+pub mod chunked;
+pub mod config;
+pub mod engine;
+pub mod error;
+pub mod health;
+pub mod jdk_sources;
+pub mod mirrors;
+pub mod progress;
+pub mod region;
+pub mod region_system;
+pub mod resume;
+pub mod types;
+pub mod verify;
+
+// 内部实现细节:只在 download 模块内(含 crate 内)可见,不承诺兼容性。
+pub(crate) mod atomic;
+pub(crate) mod bytes;
+pub(crate) mod health_store;
+pub(crate) mod http;
+pub(crate) mod transfer;
+pub(crate) mod transfer_chunk;
+
+pub use self::adoptium::{AdoptiumArtifact, JdkFlavor};
+pub use self::atomic::{PartFile, ADOPT_MIN_AGE, META_SUFFIX, ORPHAN_MAX_AGE, PART_SUFFIX};
+pub use self::backoff::{BackoffPolicy, Jitter};
+pub use self::blocking::block_on;
+pub use self::cache::{CacheRecord, CacheStats, ContentCache};
+pub use self::chunked::{ByteRange, ChunkedConfig, RangeSupport};
+pub use self::config::EngineConfig;
+pub use self::engine::DownloadEngine;
+pub use self::error::{DownloadError, DownloadErrorKind};
+pub use self::health::{HealthKey, HealthRegistry, SourceHealth};
+pub use self::jdk_sources::{MsOpenJdkTable, OracleMirror, ZuluTable};
+pub use self::mirrors::{direct_source_id, AdoptiumMirror, GhProxy, MirrorTable, PrefixRule};
+pub use self::progress::{ProgressEmitter, ProgressEvent, ProgressThrottle};
+pub use self::region::{RegionMode, RegionProfile, RegionTrigger, Signals};
+pub use self::region_system::detect_signals;
+pub use self::resume::{RestartReason, ResumeDecision, ResumeMeta};
+pub use self::types::{
+    Candidate, DownloadId, DownloadOutcome, DownloadRequest, HashAlgo, Integrity, ResourceKind,
+    ResumePolicy, SOURCE_OFFICIAL,
+};
+pub use self::verify::Hasher;
diff --git a/crates/core/src/download/progress.rs b/crates/core/src/download/progress.rs
new file mode 100644
index 0000000..4dea5f0
--- /dev/null
+++ b/crates/core/src/download/progress.rs
@@ -0,0 +1,197 @@
+//! 进度事件与发射器。
+//!
+//! 事件是「字节级 + 文件级」双层的载体(设计文档 §5):文件级由 `Started/Completed/Failed`
+//! 表达,字节级由节流后的 `Progress` 表达。`total_bytes` 未知时给 `None`——不做假进度。
+
+use std::time::{Duration, Instant};
+
+use serde::{Deserialize, Serialize};
+use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
+
+use super::error::DownloadErrorKind;
+use super::types::{DownloadId, HashAlgo, ResourceKind};
+
+/// 一条下载进度事件。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "event", rename_all = "snake_case")]
+pub enum ProgressEvent {
+    /// 开始一次下载(含候选数量与总字节预算)。
+    Started {
+        /// 关联 id。
+        id: DownloadId,
+        /// 资源类型。
+        kind: ResourceKind,
+        /// 逻辑 URL。
+        url: String,
+        /// 落盘路径。
+        dest: String,
+        /// 候选源数量。
+        candidates: usize,
+        /// 总字节数(未知为 `None`)。
+        total_bytes: Option,
+    },
+    /// 字节级进度(节流后)。
+    Progress {
+        /// 关联 id。
+        id: DownloadId,
+        /// 已下载字节。
+        downloaded: u64,
+        /// 总字节数(未知为 `None`)。
+        total_bytes: Option,
+    },
+    /// 某个源失败(单次尝试)。
+    SourceFailed {
+        /// 关联 id。
+        id: DownloadId,
+        /// 源标识。
+        source: String,
+        /// 失败原因(短描述)。
+        reason: String,
+        /// 第几次尝试(从 0 起)。
+        attempt: u32,
+        /// 是否还会在同源重试。
+        will_retry: bool,
+        /// 下次重试的等待毫秒数。
+        retry_in_ms: u64,
+    },
+    /// 切换到下一个候选源。
+    SourceSwitched {
+        /// 关联 id。
+        id: DownloadId,
+        /// 原源。
+        from: String,
+        /// 新源。
+        to: String,
+    },
+    /// 进入校验阶段。
+    Verifying {
+        /// 关联 id。
+        id: DownloadId,
+        /// 算法。
+        algo: HashAlgo,
+        /// 期望摘要前缀(8 位,够定位又不太啰嗦)。
+        expected_prefix: String,
+    },
+    /// 下载成功。
+    Completed {
+        /// 关联 id。
+        id: DownloadId,
+        /// 字节数。
+        bytes: u64,
+        /// 是否命中内容寻址缓存。
+        from_cache: bool,
+        /// 成功的源。
+        source: String,
+        /// 耗时毫秒。
+        elapsed_ms: u64,
+    },
+    /// 下载失败(所有候选源都失败或不可重试)。
+    Failed {
+        /// 关联 id。
+        id: DownloadId,
+        /// 错误分类。
+        kind: DownloadErrorKind,
+        /// 错误描述。
+        message: String,
+    },
+}
+
+impl ProgressEvent {
+    /// 事件归属的下载 id。
+    #[must_use]
+    pub const fn id(&self) -> DownloadId {
+        match self {
+            Self::Started { id, .. }
+            | Self::Progress { id, .. }
+            | Self::SourceFailed { id, .. }
+            | Self::SourceSwitched { id, .. }
+            | Self::Verifying { id, .. }
+            | Self::Completed { id, .. }
+            | Self::Failed { id, .. } => *id,
+        }
+    }
+}
+
+/// 进度发射器:没有接收端时退化为空操作(core 不关心谁在听)。
+#[derive(Debug, Clone, Default)]
+pub struct ProgressEmitter {
+    tx: Option>,
+}
+
+impl ProgressEmitter {
+    /// 静默发射器(测试、后台静默下载用)。
+    #[must_use]
+    pub const fn silent() -> Self {
+        Self { tx: None }
+    }
+
+    /// 建一对(发射器, 接收端);接收端由 app 层转成 IPC DTO。
+    #[must_use]
+    pub fn channel() -> (Self, UnboundedReceiver) {
+        let (tx, rx) = mpsc::unbounded_channel();
+        (Self { tx: Some(tx) }, rx)
+    }
+
+    /// 发一条事件;接收端已关闭则静默丢弃。
+    pub fn emit(&self, event: ProgressEvent) {
+        if let Some(tx) = &self.tx {
+            let _ = tx.send(event);
+        }
+    }
+
+    /// 是否没有接收端。
+    #[must_use]
+    pub const fn is_silent(&self) -> bool {
+        self.tx.is_none()
+    }
+}
+
+/// 进度节流器:距上次发射不足 `interval` 且未完成时压掉本次事件。
+#[derive(Debug, Clone)]
+pub struct ProgressThrottle {
+    interval: Duration,
+    last_emit: Option,
+}
+
+impl ProgressThrottle {
+    /// 构造节流器。
+    #[must_use]
+    pub const fn new(interval: Duration) -> Self {
+        Self {
+            interval,
+            last_emit: None,
+        }
+    }
+
+    /// 节流间隔。
+    #[must_use]
+    pub const fn interval(&self) -> Duration {
+        self.interval
+    }
+
+    /// 是否应该发射本次进度(`finished = true` 时无条件放行)。
+    pub fn should_emit(&mut self, now: Instant, finished: bool) -> bool {
+        if finished {
+            self.last_emit = Some(now);
+            return true;
+        }
+        match self.last_emit {
+            Some(last) if now.duration_since(last) < self.interval => false,
+            _ => {
+                self.last_emit = Some(now);
+                true
+            }
+        }
+    }
+
+    /// 复位(换源/重试时重新计时)。
+    pub fn reset(&mut self) {
+        self.last_emit = None;
+    }
+}
+
+impl Default for ProgressThrottle {
+    fn default() -> Self {
+        Self::new(Duration::from_millis(250))
+    }
+}
diff --git a/crates/core/src/download/region.rs b/crates/core/src/download/region.rs
new file mode 100644
index 0000000..5dc7c09
--- /dev/null
+++ b/crates/core/src/download/region.rs
@@ -0,0 +1,343 @@
+//! 地区自动判定:时区 + 系统语言 + 网络探测 → 直连 / 镜像优先。
+//!
+//! 设计文档 05 §3.2(用户 Q19 定案):
+//!
+//! * 信号①时区 ∈ 大中华区列表,或 信号②系统语言 zh-* → 「大中华区候选」;
+//! * 信号③网络探测(google/baidu,各 3s 超时,结果缓存 24h,可关);
+//! * 判定:大中华区 && google 不可达 → **镜像优先**(并启用 gh-proxy);
+//!   google 可达(已有全局代理)→ **直连**(尊重用户代理);非大中华区 → 直连;
+//! * 探测被关闭时,中文环境默认镜像优先(用户拍板的默认值)。
+//!
+//! 判定结果可由用户在设置里钉死(`RegionTrigger::UserPinned`),此时不参与自动重判。
+
+use std::path::Path;
+use std::time::Duration;
+
+use serde::{Deserialize, Serialize};
+
+use super::atomic::write_atomic;
+
+/// 大中华区时区(IANA 名)。
+pub const GREATER_CHINA_TIMEZONES: &[&str] = &[
+    "Asia/Shanghai",
+    "Asia/Urumqi",
+    "Asia/Chongqing",
+    "Asia/Harbin",
+    "Asia/Hong_Kong",
+    "Asia/Macau",
+    "Asia/Taipei",
+];
+
+/// 中文语言前缀(大小写不敏感的前缀匹配)。
+pub const GREATER_CHINA_LANGUAGE_PREFIXES: &[&str] = &["zh", "wuu", "yue", "lzh"];
+
+/// 默认 gh-proxy 前缀(2026-10 实测可用;设置可改,`None` 表示不默认启用)。
+pub const DEFAULT_GH_PROXY: &str = "https://gh-proxy.com/";
+
+/// 探测超时(设计文档定死 3s)。
+pub const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
+
+/// 自动判定的有效期(24h)。
+pub const DECISION_TTL_MS: u64 = 24 * 60 * 60 * 1000;
+
+/// google 可达性探测点(204 短响应)。
+pub const GOOGLE_PROBE_URL: &str = "https://www.gstatic.com/generate_204";
+
+/// 百度可达性探测点。
+pub const BAIDU_PROBE_URL: &str = "https://www.baidu.com/robots.txt";
+
+/// 源选择模式。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RegionMode {
+    /// 直连:官方在前,镜像只做兜底。
+    Direct,
+    /// 镜像优先:镜像在前,官方兜底;GitHub 资源套 gh-proxy。
+    MirrorFirst,
+}
+
+impl RegionMode {
+    /// 稳定字符串 id。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Direct => "direct",
+            Self::MirrorFirst => "mirror_first",
+        }
+    }
+}
+
+/// 判定来源:自动 or 用户钉死。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RegionTrigger {
+    /// 自动判定(24h 后可重判)。
+    Auto,
+    /// 用户手动钉死(不参与自动重判)。
+    UserPinned,
+}
+
+/// 地区档案:引擎唯一需要知道的「用哪套源顺序」。
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct RegionProfile {
+    /// 直连 / 镜像优先。
+    pub mode: RegionMode,
+    /// 判定来源。
+    pub trigger: RegionTrigger,
+    /// 是否落在「大中华区候选」。
+    pub greater_china: bool,
+    /// gh-proxy 前缀(`None` = 不启用)。
+    pub gh_proxy: Option,
+    /// 是否做过网络探测(设置里可关)。
+    pub probes_enabled: bool,
+    /// 判定时间(unix 毫秒)。
+    pub decided_at_ms: u64,
+}
+
+impl Default for RegionProfile {
+    fn default() -> Self {
+        Self::direct(0)
+    }
+}
+
+impl RegionProfile {
+    /// 显式直连。
+    #[must_use]
+    pub fn direct(now_ms: u64) -> Self {
+        Self {
+            mode: RegionMode::Direct,
+            trigger: RegionTrigger::Auto,
+            greater_china: false,
+            gh_proxy: None,
+            probes_enabled: false,
+            decided_at_ms: now_ms,
+        }
+    }
+
+    /// 镜像优先(并启用默认 gh-proxy)。
+    #[must_use]
+    pub fn mirror_first(now_ms: u64) -> Self {
+        Self {
+            mode: RegionMode::MirrorFirst,
+            trigger: RegionTrigger::Auto,
+            greater_china: true,
+            gh_proxy: Some(DEFAULT_GH_PROXY.to_string()),
+            probes_enabled: false,
+            decided_at_ms: now_ms,
+        }
+    }
+
+    /// 用户钉死某个模式(设置页「直连 / 镜像优先」)。
+    #[must_use]
+    pub fn pinned(mode: RegionMode, now_ms: u64) -> Self {
+        let mut profile = match mode {
+            RegionMode::Direct => Self::direct(now_ms),
+            RegionMode::MirrorFirst => Self::mirror_first(now_ms),
+        };
+        profile.trigger = RegionTrigger::UserPinned;
+        profile
+    }
+
+    /// gh-proxy 是否真的会生效(镜像优先 + 配了前缀)。
+    #[must_use]
+    pub fn gh_proxy_enabled(&self) -> bool {
+        self.mode == RegionMode::MirrorFirst
+            && self
+                .gh_proxy
+                .as_deref()
+                .is_some_and(|prefix| !prefix.is_empty())
+    }
+
+    /// 是否按「国内」口径选源(镜像优先,或判定落在大中华区)。
+    ///
+    /// 给 JDK 发行版默认优先级用(D23:国内 Temurin→Oracle→MS,国外 Temurin→MS→Oracle):
+    /// 用户把模式钉成直连但机器仍在国内时,仍然应该吃国内镜像更快的那个顺序。
+    #[must_use]
+    pub fn is_domestic(&self) -> bool {
+        self.mode == RegionMode::MirrorFirst || self.greater_china
+    }
+
+    /// 覆盖 gh-proxy 前缀。
+    #[must_use]
+    pub fn with_gh_proxy(mut self, prefix: Option<&str>) -> Self {
+        self.gh_proxy = prefix
+            .map(str::trim)
+            .filter(|prefix| !prefix.is_empty())
+            .map(str::to_string);
+        self
+    }
+
+    /// 标记是否允许网络探测。
+    #[must_use]
+    pub fn with_probes(mut self, enabled: bool) -> Self {
+        self.probes_enabled = enabled;
+        self
+    }
+
+    /// 自动判定是否已过期(用户钉死永不过期)。
+    #[must_use]
+    pub fn is_stale(&self, now_ms: u64) -> bool {
+        self.trigger == RegionTrigger::Auto
+            && now_ms.saturating_sub(self.decided_at_ms) > DECISION_TTL_MS
+    }
+
+    /// 设置页展示用的中文标签。
+    #[must_use]
+    pub const fn label(&self) -> &'static str {
+        match self.mode {
+            RegionMode::Direct => "直连",
+            RegionMode::MirrorFirst => "镜像优先",
+        }
+    }
+}
+
+/// 判定输入信号。`None` 表示「未知/未探测」。
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct Signals {
+    /// 时区名(IANA)。
+    pub timezone: Option,
+    /// 系统语言列表(BCP-47 或 POSIX locale,取首个非空即可)。
+    pub languages: Vec,
+    /// google 可达性(`None` = 未探测/探测被关)。
+    pub google_reachable: Option,
+    /// baidu 可达性(诊断信息,暂不参与判定)。
+    pub baidu_reachable: Option,
+}
+
+impl Signals {
+    /// 从环境变量读(尽力而为:Windows 上通常没有 `TZ`,此时由 platform 层补精确值)。
+    #[must_use]
+    pub fn from_env() -> Self {
+        let timezone = std::env::var("TZ").ok().filter(|tz| !tz.trim().is_empty());
+        let mut languages = Vec::new();
+        for key in ["LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"] {
+            if let Ok(value) = std::env::var(key) {
+                for part in value.split([':', ';']) {
+                    let part = part.trim();
+                    if !part.is_empty() && !languages.iter().any(|known: &String| known == part) {
+                        languages.push(part.to_string());
+                    }
+                }
+            }
+        }
+        Self {
+            timezone,
+            languages,
+            google_reachable: None,
+            baidu_reachable: None,
+        }
+    }
+
+    /// 时区是否落在大中华区。
+    #[must_use]
+    pub fn timezone_in_greater_china(&self) -> bool {
+        self.timezone.as_deref().is_some_and(|tz| {
+            GREATER_CHINA_TIMEZONES
+                .iter()
+                .any(|known| known.eq_ignore_ascii_case(tz.trim()))
+        })
+    }
+
+    /// 系统语言是否是中文(含 `zh_CN.UTF-8` 这类 POSIX 形态)。
+    #[must_use]
+    pub fn language_in_greater_china(&self) -> bool {
+        self.languages.iter().any(|language| {
+            let lower = language.to_ascii_lowercase().replace('_', "-");
+            GREATER_CHINA_LANGUAGE_PREFIXES
+                .iter()
+                .any(|prefix| lower.starts_with(prefix))
+        })
+    }
+
+    /// 是否是大中华区候选(信号① 或 信号②)。
+    #[must_use]
+    pub fn is_greater_china(&self) -> bool {
+        self.timezone_in_greater_china() || self.language_in_greater_china()
+    }
+}
+
+/// 纯判定函数(不发请求 → 好测)。
+#[must_use]
+pub fn decide_from(signals: &Signals, now_ms: u64) -> RegionProfile {
+    let greater_china = signals.is_greater_china();
+    let mode = match (greater_china, signals.google_reachable) {
+        // 非大中华区:一律直连。
+        (false, _) => RegionMode::Direct,
+        // 大中华区但 google 可达:用户已有全局代理,尊重直连。
+        (true, Some(true)) => RegionMode::Direct,
+        // 大中华区且 google 不可达:镜像优先。
+        (true, Some(false)) => RegionMode::MirrorFirst,
+        // 探测被关:中文环境默认镜像优先(用户拍板)。
+        (true, None) => RegionMode::MirrorFirst,
+    };
+    let mut profile = match mode {
+        RegionMode::Direct => RegionProfile::direct(now_ms),
+        RegionMode::MirrorFirst => RegionProfile::mirror_first(now_ms),
+    };
+    profile.greater_china = greater_china;
+    profile.probes_enabled = signals.google_reachable.is_some();
+    if mode == RegionMode::Direct {
+        profile.gh_proxy = None;
+    }
+    profile
+}
+
+/// 单点可达性探测(3s 超时,任何错误都算不可达)。
+pub async fn probe_reachable(client: &reqwest::Client, url: &str) -> bool {
+    let request = client.get(url).header(reqwest::header::RANGE, "bytes=0-0");
+    match tokio::time::timeout(PROBE_TIMEOUT, request.send()).await {
+        Ok(Ok(response)) => {
+            let status = response.status();
+            status.is_success() || status.is_redirection() || status.as_u16() == 204
+        }
+        _ => false,
+    }
+}
+
+/// 并发探测 google 与 baidu(两个都 3s 上限,总耗时约 3s)。
+pub async fn probe_signals(client: &reqwest::Client) -> (Option, Option) {
+    let (google, baidu) = tokio::join!(
+        probe_reachable(client, GOOGLE_PROBE_URL),
+        probe_reachable(client, BAIDU_PROBE_URL)
+    );
+    (Some(google), Some(baidu))
+}
+
+/// 完整判定:读信号 →(可选)探测 → 决定。
+pub async fn detect(client: &reqwest::Client, probes_enabled: bool, now_ms: u64) -> RegionProfile {
+    let mut signals = Signals::from_env();
+    if probes_enabled {
+        let (google, baidu) = probe_signals(client).await;
+        signals.google_reachable = google;
+        signals.baidu_reachable = baidu;
+    }
+    decide_from(&signals, now_ms)
+}
+
+/// 判定结果的落盘形态。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct PersistedProfile {
+    version: u32,
+    profile: RegionProfile,
+}
+
+/// 读 24h 缓存(过期或损坏返回 `None`)。
+#[must_use]
+pub fn load_cached(path: &Path, now_ms: u64) -> Option {
+    let bytes = std::fs::read(path).ok()?;
+    let persisted: PersistedProfile = serde_json::from_slice(&bytes).ok()?;
+    if persisted.profile.is_stale(now_ms) {
+        return None;
+    }
+    Some(persisted.profile)
+}
+
+/// 写 24h 缓存(原子写)。
+pub fn store_cached(path: &Path, profile: &RegionProfile) -> std::io::Result<()> {
+    let persisted = PersistedProfile {
+        version: 1,
+        profile: profile.clone(),
+    };
+    let json = serde_json::to_vec_pretty(&persisted).map_err(std::io::Error::other)?;
+    write_atomic(path, &json)
+}
diff --git a/crates/core/src/download/region_system.rs b/crates/core/src/download/region_system.rs
new file mode 100644
index 0000000..eb60e48
--- /dev/null
+++ b/crates/core/src/download/region_system.rs
@@ -0,0 +1,158 @@
+//! 地区判定的**系统级**信号补充:环境变量之外的那一半(D23「国内用户默认都走镜像」)。
+//!
+//! 为什么需要它:`Signals::from_env` 只读 `TZ` / `LANG` / `LC_*`。Windows 上这三个通常**全是空的**
+//! ——中文版 Windows 既不设 `TZ` 也不设 `LANG`,于是「国内机器」被误判成国外,
+//! D23 定下的国内默认序(Temurin→Oracle→MS)就拿不到了。本机实测就是这个现象。
+//!
+//! 补法(只在环境变量没给出答案时才做,且读不到就静默跳过):
+//!
+//! | 平台 | 时区 | 语言 |
+//! | --- | --- | --- |
+//! | Windows | `tzutil /g` → `China Standard Time` | `reg query` 的 `LocaleName` → `zh-CN` |
+//! | 其它 | 不补(Unix 一般都有 `LANG`,`/etc/localtime` 解析成本不划算) | 同左 |
+//!
+//! 两个纪律:①**不引依赖**——`tzutil` / `reg` 都是系统自带,`discover.rs` 早就有 `reg query`
+//! 的先例;②**不做网络探测**——探测(google 可达性)仍由调用方决定开不开。
+
+use std::process::Command;
+
+use super::region::{Signals, GREATER_CHINA_TIMEZONES};
+
+/// Windows 时区显示名里能判成「大中华区」的关键词(`tzutil /g` 的输出形态)。
+const GREATER_CHINA_ZONE_HINTS: &[&str] = &[
+    "China Standard Time",
+    "Taipei Standard Time",
+    "Hong Kong Standard Time",
+    "Ulaanbaatar",
+];
+
+/// 系统级信号(环境变量之外;读不到就是空信号)。
+#[must_use]
+pub fn system_signals() -> Signals {
+    let mut signals = Signals::default();
+    if !cfg!(windows) {
+        return signals;
+    }
+    if let Some(zone) = windows_timezone() {
+        if zone_in_greater_china(&zone) {
+            // 折成 IANA 名,和 `Signals::from_env` 的取值空间保持一致。
+            signals.timezone = Some("Asia/Shanghai".to_string());
+        }
+    }
+    if let Some(locale) = windows_locale() {
+        signals.languages.push(locale);
+    }
+    signals
+}
+
+/// 环境变量信号 + 系统级信号(系统级只补空缺,不覆盖环境变量)。
+#[must_use]
+pub fn detect_signals() -> Signals {
+    let mut signals = Signals::from_env();
+    let system = system_signals();
+    if signals.timezone.is_none() {
+        signals.timezone = system.timezone;
+    }
+    for language in system.languages {
+        if !signals.languages.contains(&language) {
+            signals.languages.push(language);
+        }
+    }
+    signals
+}
+
+/// `tzutil /g` 的输出(失败为 `None`)。
+#[must_use]
+pub fn windows_timezone() -> Option {
+    let output = Command::new("tzutil").arg("/g").output().ok()?;
+    if !output.status.success() {
+        return None;
+    }
+    let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
+    (!text.is_empty()).then_some(text)
+}
+
+/// 系统的 locale 名(`HKCU\Control Panel\International` 的 `LocaleName`)。
+#[must_use]
+pub fn windows_locale() -> Option {
+    let output = Command::new("reg")
+        .args([
+            "query",
+            "HKCU\\Control Panel\\International",
+            "/v",
+            "LocaleName",
+        ])
+        .output()
+        .ok()?;
+    if !output.status.success() {
+        return None;
+    }
+    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
+    parse_reg_locale_name(&stdout)
+}
+
+/// 从 `reg query` 的输出里取 `LocaleName` 的值(`    LocaleName    REG_SZ    zh-CN`)。
+#[must_use]
+pub fn parse_reg_locale_name(stdout: &str) -> Option {
+    for line in stdout.lines() {
+        let line = line.trim();
+        if !line.starts_with("LocaleName") {
+            continue;
+        }
+        let value = line.rsplit("REG_SZ").next()?.trim();
+        if !value.is_empty() {
+            return Some(value.to_string());
+        }
+    }
+    None
+}
+
+/// Windows 时区显示名是否落在大中华区。
+#[must_use]
+pub fn zone_in_greater_china(zone: &str) -> bool {
+    let zone = zone.trim();
+    GREATER_CHINA_ZONE_HINTS
+        .iter()
+        .any(|hint| zone.eq_ignore_ascii_case(hint))
+        || GREATER_CHINA_TIMEZONES
+            .iter()
+            .any(|known| known.eq_ignore_ascii_case(zone))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{detect_signals, parse_reg_locale_name, zone_in_greater_china};
+
+    #[test]
+    fn windows_zone_names_map_to_the_greater_china_list() {
+        assert!(zone_in_greater_china("China Standard Time"));
+        assert!(zone_in_greater_china("Taipei Standard Time"));
+        assert!(zone_in_greater_china("Asia/Shanghai"), "IANA 名同样要认");
+        assert!(!zone_in_greater_china("Pacific Standard Time"));
+        assert!(!zone_in_greater_china(""));
+    }
+
+    #[test]
+    fn reg_output_yields_the_locale_name() {
+        // 真机 `reg query "HKCU\Control Panel\International" /v LocaleName` 的形态。
+        let stdout = "\r\nHKEY_CURRENT_USER\\Control Panel\\International\r\n    \
+                      LocaleName    REG_SZ    zh-CN\r\n\r\n";
+        assert_eq!(parse_reg_locale_name(stdout).as_deref(), Some("zh-CN"));
+        assert_eq!(
+            parse_reg_locale_name("    LocaleName    REG_SZ    \r\n"),
+            None
+        );
+        assert_eq!(parse_reg_locale_name("没有这一行"), None);
+    }
+
+    #[test]
+    fn detect_layers_system_signals_under_the_environment() {
+        // 环境变量优先:这里能断言的只有「不 panic 且返回一个 Signals」,
+        // 具体取值取决于跑测试的机器(CI 上没有 tzutil/reg 也要安静退化)。
+        let signals = detect_signals();
+        // 无论哪种情况,都不该凭空造出「大中华区」以外的时区值。
+        if let Some(zone) = &signals.timezone {
+            assert!(!zone.trim().is_empty());
+        }
+    }
+}
diff --git a/crates/core/src/download/resume.rs b/crates/core/src/download/resume.rs
new file mode 100644
index 0000000..976cf57
--- /dev/null
+++ b/crates/core/src/download/resume.rs
@@ -0,0 +1,235 @@
+//! 断点续传的**保守判定**。
+//!
+//! 承 analyse/03 §5.4 与设计文档 §4 最后一行:「拒绝『能续传但会坏』场景」是 HMCL 做对的事。
+//! 我们把它写成纯函数,判定条件全部显式:
+//!
+//! 1. 策略不是 `Never`;
+//! 2. 中间文件存在且长度 > 0;
+//! 3. 有上一次的元数据(URL、摘要、总长、校验器);
+//! 4. URL 与期望摘要都没变;
+//! 5. 总长已知且 `0 < 已下 < 总长`;
+//! 6. 有 ETag 或 Last-Modified 作为校验器(否则服务器换了内容我们也发现不了)。
+//!
+//! 任何一条不满足就**从头下**——重下 100MB 也比交付一个坏文件便宜。
+//! 另外单独处理 416(`Requested Range Not Satisfiable`):已下满则直接进入校验,
+//! 否则丢弃中间文件重来。
+
+use std::io;
+use std::path::Path;
+
+use serde::{Deserialize, Serialize};
+
+use super::atomic::write_atomic;
+use super::types::{HashAlgo, ResumePolicy};
+
+/// 上一次下载留下的续传元数据。
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ResumeMeta {
+    /// 当时请求的 URL(镜像可能换掉,URL 变了就不续传)。
+    pub url: String,
+    /// 响应 `ETag`。
+    pub etag: Option,
+    /// 响应 `Last-Modified`。
+    pub last_modified: Option,
+    /// 响应告知的总长度。
+    pub total: Option,
+    /// 摘要算法。
+    pub algo: HashAlgo,
+    /// 期望摘要。
+    pub hash: String,
+    /// 保存时间(unix 毫秒)。
+    pub saved_at_ms: u64,
+}
+
+impl ResumeMeta {
+    /// 从磁盘读(不存在/损坏 → `None`)。
+    #[must_use]
+    pub fn load(path: &Path) -> Option {
+        let bytes = std::fs::read(path).ok()?;
+        serde_json::from_slice(&bytes).ok()
+    }
+
+    /// 原子写。
+    pub fn save(&self, path: &Path) -> io::Result<()> {
+        let json = serde_json::to_vec_pretty(self).map_err(io::Error::other)?;
+        write_atomic(path, &json)
+    }
+
+    /// 删除(校验通过或放弃续传时)。
+    pub fn remove(path: &Path) {
+        let _ = std::fs::remove_file(path);
+    }
+
+    /// 本记录可用的校验器。
+    #[must_use]
+    pub fn validator(&self) -> Validator {
+        Validator {
+            etag: self.etag.clone(),
+            last_modified: self.last_modified.clone(),
+        }
+    }
+}
+
+/// 续传用的校验器(`If-Range` 头的取值来源)。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Validator {
+    /// 强校验器。
+    pub etag: Option,
+    /// 弱校验器。
+    pub last_modified: Option,
+}
+
+impl Validator {
+    /// `If-Range` 头的值(ETag 优先,其次 Last-Modified)。
+    #[must_use]
+    pub fn if_range_value(&self) -> Option<&str> {
+        self.etag.as_deref().or(self.last_modified.as_deref())
+    }
+}
+
+/// 不复续传的原因(日志与诊断页用)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RestartReason {
+    /// 策略要求从不续传。
+    PolicyNever,
+    /// 没有中间文件。
+    NoPart,
+    /// 中间文件是 0 字节。
+    EmptyPart,
+    /// 没有元数据(无法证明中间文件是同一次下载的产物)。
+    NoMeta,
+    /// URL 变了(换源后偏移不可信)。
+    UrlChanged,
+    /// 期望摘要变了(目标文件已经不是同一个东西)。
+    HashChanged,
+    /// 总长未知(无法判断是否已下满)。
+    TotalUnknown,
+    /// 已下字节超过总长(中间文件被污染)。
+    Oversize,
+    /// 没有 ETag/Last-Modified,无法证明远端内容没变。
+    NoValidator,
+    /// 服务器 416 且未下满。
+    RangeUnsatisfiable,
+}
+
+impl RestartReason {
+    /// 稳定字符串 id。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::PolicyNever => "policy_never",
+            Self::NoPart => "no_part",
+            Self::EmptyPart => "empty_part",
+            Self::NoMeta => "no_meta",
+            Self::UrlChanged => "url_changed",
+            Self::HashChanged => "hash_changed",
+            Self::TotalUnknown => "total_unknown",
+            Self::Oversize => "oversize",
+            Self::NoValidator => "no_validator",
+            Self::RangeUnsatisfiable => "range_unsatisfiable",
+        }
+    }
+}
+
+/// 续传决策。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ResumeDecision {
+    /// 可以续传:从 `offset` 起发 `Range` + `If-Range`。
+    Resume {
+        /// 已下字节数。
+        offset: u64,
+        /// 校验器。
+        validator: Validator,
+    },
+    /// 从头下载。
+    Restart {
+        /// 原因。
+        reason: RestartReason,
+    },
+    /// 中间文件已经是完整长度:跳过传输,直接校验。
+    Complete {
+        /// 文件长度。
+        size: u64,
+    },
+}
+
+impl ResumeDecision {
+    /// 是否要丢掉中间文件(`Complete` 不能丢)。
+    #[must_use]
+    pub const fn should_drop_part(&self) -> bool {
+        matches!(self, Self::Restart { .. })
+    }
+}
+
+/// 判定是否续传(纯函数)。
+#[must_use]
+pub fn decide(
+    policy: ResumePolicy,
+    part_len: u64,
+    meta: Option<&ResumeMeta>,
+    url: &str,
+    integrity: Option<(HashAlgo, &str)>,
+    expected_size: Option,
+) -> ResumeDecision {
+    if policy == ResumePolicy::Never {
+        return ResumeDecision::Restart {
+            reason: RestartReason::PolicyNever,
+        };
+    }
+    if part_len == 0 {
+        return ResumeDecision::Restart {
+            reason: RestartReason::EmptyPart,
+        };
+    }
+    let Some(meta) = meta else {
+        return ResumeDecision::Restart {
+            reason: RestartReason::NoMeta,
+        };
+    };
+    if meta.url != url {
+        return ResumeDecision::Restart {
+            reason: RestartReason::UrlChanged,
+        };
+    }
+    if let Some((algo, hash)) = integrity {
+        if meta.algo != algo || !meta.hash.eq_ignore_ascii_case(hash) {
+            return ResumeDecision::Restart {
+                reason: RestartReason::HashChanged,
+            };
+        }
+    }
+    let Some(total) = meta.total.or(expected_size) else {
+        return ResumeDecision::Restart {
+            reason: RestartReason::TotalUnknown,
+        };
+    };
+    if part_len > total {
+        return ResumeDecision::Restart {
+            reason: RestartReason::Oversize,
+        };
+    }
+    if part_len == total {
+        return ResumeDecision::Complete { size: total };
+    }
+    let validator = meta.validator();
+    if validator.if_range_value().is_none() {
+        return ResumeDecision::Restart {
+            reason: RestartReason::NoValidator,
+        };
+    }
+    ResumeDecision::Resume {
+        offset: part_len,
+        validator,
+    }
+}
+
+/// 收到 416 后的决策。
+#[must_use]
+pub fn on_range_unsatisfiable(part_len: u64, total: Option) -> ResumeDecision {
+    match total {
+        Some(total) if part_len == total => ResumeDecision::Complete { size: total },
+        _ => ResumeDecision::Restart {
+            reason: RestartReason::RangeUnsatisfiable,
+        },
+    }
+}
diff --git a/crates/core/src/download/transfer.rs b/crates/core/src/download/transfer.rs
new file mode 100644
index 0000000..42f27ee
--- /dev/null
+++ b/crates/core/src/download/transfer.rs
@@ -0,0 +1,305 @@
+//! 传输细节:单连接(含续传)+ 多连接分块,产出「已校验的完整中间文件」。
+//!
+//! 本文件只管「一个候选源的一次尝试」——换源、退避、限流在 `engine.rs`。
+
+use std::time::Instant;
+
+use tokio::io::AsyncWriteExt;
+
+use super::atomic::PartFile;
+use super::cache::now_ms;
+use super::chunked;
+use super::engine::{AttemptOk, DownloadEngine};
+use super::error::{DownloadError, DownloadErrorKind};
+use super::http;
+use super::progress::{ProgressEvent, ProgressThrottle};
+use super::resume::{self, RestartReason, ResumeDecision, ResumeMeta};
+use super::types::{Candidate, DownloadId, DownloadRequest, HashAlgo};
+use super::verify;
+
+/// 续传起点(无续传时全为默认值)。
+struct ResumeCut {
+    /// 已下字节数。
+    offset: u64,
+    /// 续传起点(事件字段)。
+    resumed_from: u64,
+    /// `If-Range` 的取值。
+    if_range: Option,
+}
+
+impl ResumeCut {
+    /// 从头下载。
+    const fn none() -> Self {
+        Self {
+            offset: 0,
+            resumed_from: 0,
+            if_range: None,
+        }
+    }
+}
+
+impl DownloadEngine {
+    /// 一次尝试:续传判定 → 分块 or 单连接 → 流式校验。
+    pub(crate) async fn attempt(
+        &self,
+        id: DownloadId,
+        req: &DownloadRequest,
+        candidate: &Candidate,
+        part: &mut PartFile,
+    ) -> Result {
+        let integrity = req.integrity.as_ref();
+        let meta_path = part.meta_path();
+        let previous = ResumeMeta::load(&meta_path);
+        // 「没有中间文件」和「有但 0 字节」是两种不同的原因,分开报出来便于诊断。
+        let decision = if !part.exists() {
+            ResumeDecision::Restart {
+                reason: RestartReason::NoPart,
+            }
+        } else if part.is_empty() {
+            ResumeDecision::Restart {
+                reason: RestartReason::EmptyPart,
+            }
+        } else {
+            resume::decide(
+                req.resume,
+                part.len(),
+                previous.as_ref(),
+                &candidate.url,
+                integrity.map(|item| (item.algo, item.hex.as_str())),
+                req.expected_size,
+            )
+        };
+
+        let mut cut = ResumeCut::none();
+        match decision {
+            ResumeDecision::Complete { size } => {
+                self.verify_part(part, integrity, id)?;
+                return Ok(AttemptOk {
+                    bytes: size,
+                    resumed_from: size,
+                });
+            }
+            ResumeDecision::Resume { offset, validator } => {
+                if let Some(value) = validator.if_range_value() {
+                    cut = ResumeCut {
+                        offset,
+                        resumed_from: offset,
+                        if_range: Some(value.to_string()),
+                    };
+                }
+            }
+            ResumeDecision::Restart { .. } => {}
+        }
+
+        if cut.offset == 0 && self.cfg.chunked.applies_to(req.kind, req.expected_size) {
+            match self
+                .attempt_chunked(id, req, candidate, part, req.expected_size)
+                .await
+            {
+                Ok(ok) => return Ok(ok),
+                // Range 不支持/切不出足够段:静默退化为单连接(不算源失败)。
+                Err(err) if err.kind == DownloadErrorKind::RangeUnsupported => {}
+                Err(err) => return Err(err),
+            }
+        }
+
+        self.attempt_single(id, req, candidate, part, cut).await
+    }
+
+    async fn attempt_single(
+        &self,
+        id: DownloadId,
+        req: &DownloadRequest,
+        candidate: &Candidate,
+        part: &mut PartFile,
+        cut: ResumeCut,
+    ) -> Result {
+        let integrity = req.integrity.as_ref();
+        let mut offset = cut.offset;
+        let mut resumed_from = cut.resumed_from;
+        let mut request = self.http.get(&candidate.url);
+        if offset > 0 {
+            match cut.if_range.as_deref() {
+                Some(value) => {
+                    request = request
+                        .header(reqwest::header::RANGE, format!("bytes={offset}-"))
+                        .header(reqwest::header::IF_RANGE, value);
+                }
+                // 没有校验器就不能保证远端内容没变,直接从头下。
+                None => {
+                    offset = 0;
+                    resumed_from = 0;
+                }
+            }
+        }
+
+        let response = request.send().await.map_err(|err| {
+            http::classify_reqwest(&err, &candidate.url).with_source(candidate.source.as_str())
+        })?;
+        let status = response.status();
+
+        if status.as_u16() == 416 {
+            return match resume::on_range_unsatisfiable(part.len(), req.expected_size) {
+                ResumeDecision::Complete { size } => {
+                    self.verify_part(part, integrity, id)?;
+                    Ok(AttemptOk {
+                        bytes: size,
+                        resumed_from: size,
+                    })
+                }
+                _ => {
+                    part.discard();
+                    Err(DownloadError::new(
+                        DownloadErrorKind::RangeUnsupported,
+                        "416:丢弃中间文件重下",
+                    )
+                    .with_source(candidate.source.as_str()))
+                }
+            };
+        }
+        if !status.is_success() {
+            return Err(http::classify_status(status.as_u16(), &candidate.url)
+                .with_source(candidate.source.as_str()));
+        }
+
+        // 2xx 不等于「拿到归档」:镜像门户对任意路径都回 200 + HTML(华为云实测)。
+        // 归到 NotFound(换源,不算源坏不进冷却),否则假阳性源会一直挡在队首。
+        if let Some(reason) =
+            http::html_mirror_reason(response.headers(), req.expected_size, offset)
+        {
+            return Err(DownloadError::new(DownloadErrorKind::NotFound, reason)
+                .with_source(candidate.source.as_str()));
+        }
+
+        // 服务器无视了 Range(200 而非 206)→ 从头写。
+        if offset > 0 && status.as_u16() != 206 {
+            offset = 0;
+            resumed_from = 0;
+        }
+
+        let content_range = http::header_str(response.headers(), reqwest::header::CONTENT_RANGE)
+            .and_then(chunked::parse_content_range);
+        let etag = http::etag(response.headers());
+        let last_modified = http::last_modified(response.headers());
+        let content_length = http::content_length(response.headers());
+
+        let mut total = req.expected_size;
+        if let Some(content_range) = content_range {
+            if content_range.start != offset {
+                // 服务器给的起点和我们以为的不一样:偏移不可信,从头来。
+                offset = 0;
+                resumed_from = 0;
+            }
+            total = content_range.total.or(total);
+        }
+        total = total.or_else(|| content_length.map(|len| len + offset));
+
+        // 记下续传元数据(跨进程续传的凭据)。
+        let meta = ResumeMeta {
+            url: candidate.url.clone(),
+            etag,
+            last_modified,
+            total,
+            algo: integrity.map_or(HashAlgo::Sha1, |item| item.algo),
+            hash: integrity.map_or_else(String::new, |item| item.hex.clone()),
+            saved_at_ms: now_ms(),
+        };
+        let _ = meta.save(&part.meta_path());
+
+        let std_file = if offset > 0 {
+            part.open_append()
+        } else {
+            part.open_truncate()
+        }
+        .map_err(|err| io_err("打开中间文件失败", &err))?;
+        let mut file = tokio::fs::File::from_std(std_file);
+
+        let mut hasher = integrity.map(|item| verify::Hasher::new(item.algo));
+        if offset > 0 {
+            if let Some(hasher) = hasher.as_mut() {
+                verify::update_hasher_from_file(hasher, part.path(), offset)
+                    .map_err(|err| io_err("读取续传前缀失败", &err))?;
+            }
+        }
+
+        let mut throttle = ProgressThrottle::new(self.cfg.progress_throttle);
+        let mut written = offset;
+        let mut stream = response.bytes_stream();
+        loop {
+            let next = tokio::time::timeout(
+                self.cfg.read_timeout,
+                futures_util::StreamExt::next(&mut stream),
+            )
+            .await
+            .map_err(|_| {
+                DownloadError::new(
+                    DownloadErrorKind::Timeout,
+                    format!("读取超时({})", candidate.url),
+                )
+                .with_source(candidate.source.as_str())
+            })?;
+            let Some(chunk) = next else { break };
+            let chunk = chunk.map_err(|err| http::classify_reqwest(&err, &candidate.url))?;
+            if let Some(hasher) = hasher.as_mut() {
+                hasher.update(&chunk);
+            }
+            file.write_all(&chunk)
+                .await
+                .map_err(|err| io_err("写入中间文件失败", &err))?;
+            written += chunk.len() as u64;
+            if throttle.should_emit(Instant::now(), false) {
+                self.emit(ProgressEvent::Progress {
+                    id,
+                    downloaded: written,
+                    total_bytes: total,
+                });
+            }
+        }
+        file.flush()
+            .await
+            .map_err(|err| io_err("刷新中间文件失败", &err))?;
+        // Windows 上打开中的文件不能被 rename,必须先关。
+        drop(file);
+
+        if let Some(total) = total {
+            if written != total {
+                return Err(DownloadError::new(
+                    DownloadErrorKind::Network,
+                    format!("长度不符:期望 {total} 实得 {written}({})", candidate.url),
+                )
+                .with_source(candidate.source.as_str()));
+            }
+        }
+
+        if let (Some(integrity), Some(hasher)) = (integrity, hasher) {
+            self.emit(ProgressEvent::Verifying {
+                id,
+                algo: integrity.algo,
+                expected_prefix: integrity.short_prefix().to_string(),
+            });
+            let actual = hasher.finish();
+            if !verify::matches(&integrity.hex, &actual) {
+                part.discard();
+                return Err(DownloadError::new(
+                    DownloadErrorKind::Integrity,
+                    format!("校验不符:期望 {} 实得 {actual}", integrity.hex),
+                )
+                .with_source(candidate.source.as_str()));
+            }
+        }
+
+        self.emit(ProgressEvent::Progress {
+            id,
+            downloaded: written,
+            total_bytes: total,
+        });
+        Ok(AttemptOk {
+            bytes: written,
+            resumed_from,
+        })
+    }
+}
+
+fn io_err(context: &str, err: &std::io::Error) -> DownloadError {
+    DownloadError::new(DownloadErrorKind::Io, format!("{context}: {err}"))
+}
diff --git a/crates/core/src/download/transfer_chunk.rs b/crates/core/src/download/transfer_chunk.rs
new file mode 100644
index 0000000..c9bbe8a
--- /dev/null
+++ b/crates/core/src/download/transfer_chunk.rs
@@ -0,0 +1,163 @@
+//! 大文件多连接分块的编排与校验。
+//!
+//! 分块本身由 `chunked.rs` 负责(纯规划 + 单块抓取),这里负责并发、进度上报、
+//! 失败清理与「整文件重算一次摘要」(分块是乱序写,只能整文件校验)。
+
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::Arc;
+use std::time::Duration;
+
+use super::atomic::PartFile;
+use super::chunked;
+use super::engine::{AttemptOk, DownloadEngine};
+use super::error::{DownloadError, DownloadErrorKind};
+use super::progress::ProgressEvent;
+use super::types::{Candidate, DownloadId, DownloadRequest, Integrity};
+use super::verify;
+
+impl DownloadEngine {
+    pub(crate) async fn attempt_chunked(
+        &self,
+        id: DownloadId,
+        req: &DownloadRequest,
+        candidate: &Candidate,
+        part: &mut PartFile,
+        total: Option,
+    ) -> Result {
+        let Some(total) = total.filter(|total| *total > 0) else {
+            return Err(DownloadError::new(
+                DownloadErrorKind::RangeUnsupported,
+                "总长未知,无法分块",
+            ));
+        };
+        let ranges = chunked::plan(total, self.cfg.chunked.effective_connections());
+        if ranges.len() < 2 {
+            return Err(DownloadError::new(
+                DownloadErrorKind::RangeUnsupported,
+                "可切分段不足,退化为单连接",
+            ));
+        }
+
+        part.discard();
+        part.ensure_parent_async()
+            .await
+            .map_err(|err| io_err("创建下载目录失败", &err))?;
+        {
+            let file = tokio::fs::OpenOptions::new()
+                .create(true)
+                .write(true)
+                .truncate(true)
+                .open(part.path())
+                .await
+                .map_err(|err| io_err("预分配中间文件失败", &err))?;
+            file.set_len(total)
+                .await
+                .map_err(|err| io_err("预分配长度失败", &err))?;
+        }
+
+        let counter = Arc::new(AtomicU64::new(0));
+        let reporter = self.spawn_reporter(id, Arc::clone(&counter), total);
+
+        let mut set = tokio::task::JoinSet::new();
+        for range in ranges {
+            let client = self.http.clone();
+            let url = candidate.url.clone();
+            let path = part.path().to_path_buf();
+            let read_timeout = self.cfg.read_timeout;
+            let progress = Arc::clone(&counter);
+            set.spawn(async move {
+                chunked::fetch_chunk(&client, &url, range, &path, read_timeout, &progress).await
+            });
+        }
+
+        let mut failure: Option = None;
+        while let Some(joined) = set.join_next().await {
+            match joined {
+                Ok(Ok(_)) => {}
+                Ok(Err(err)) => {
+                    failure = Some(err.with_source(candidate.source.as_str()));
+                    set.abort_all();
+                    break;
+                }
+                Err(err) => {
+                    failure = Some(DownloadError::new(
+                        DownloadErrorKind::Network,
+                        format!("分块任务异常:{err}"),
+                    ));
+                    set.abort_all();
+                    break;
+                }
+            }
+        }
+        reporter.abort();
+
+        if let Some(err) = failure {
+            part.discard();
+            return Err(err);
+        }
+
+        // 分块是乱序写,只能整文件重算一次摘要。
+        self.verify_part(part, req.integrity.as_ref(), id)?;
+        self.emit(ProgressEvent::Progress {
+            id,
+            downloaded: total,
+            total_bytes: Some(total),
+        });
+        Ok(AttemptOk {
+            bytes: total,
+            resumed_from: 0,
+        })
+    }
+
+    fn spawn_reporter(
+        &self,
+        id: DownloadId,
+        counter: Arc,
+        total: u64,
+    ) -> tokio::task::JoinHandle<()> {
+        let emitter = self.progress.clone();
+        let interval = self.cfg.progress_throttle.max(Duration::from_millis(50));
+        tokio::spawn(async move {
+            let mut ticker = tokio::time::interval(interval);
+            loop {
+                ticker.tick().await;
+                let downloaded = counter.load(Ordering::Relaxed).min(total);
+                emitter.emit(ProgressEvent::Progress {
+                    id,
+                    downloaded,
+                    total_bytes: Some(total),
+                });
+            }
+        })
+    }
+
+    pub(crate) fn verify_part(
+        &self,
+        part: &mut PartFile,
+        integrity: Option<&Integrity>,
+        id: DownloadId,
+    ) -> Result<(), DownloadError> {
+        let Some(integrity) = integrity else {
+            return Ok(());
+        };
+        self.emit(ProgressEvent::Verifying {
+            id,
+            algo: integrity.algo,
+            expected_prefix: integrity.short_prefix().to_string(),
+        });
+        let actual = verify::digest_file(part.path(), integrity.algo)
+            .map_err(|err| io_err("读取中间文件失败", &err))?;
+        if verify::matches(&integrity.hex, &actual) {
+            return Ok(());
+        }
+        part.discard();
+        Err(DownloadError::new(
+            DownloadErrorKind::Integrity,
+            format!("校验不符:期望 {} 实得 {actual}", integrity.hex),
+        ))
+    }
+}
+
+fn io_err(context: &str, err: &std::io::Error) -> DownloadError {
+    DownloadError::new(DownloadErrorKind::Io, format!("{context}: {err}"))
+}
diff --git a/crates/core/src/download/types.rs b/crates/core/src/download/types.rs
new file mode 100644
index 0000000..722d2a0
--- /dev/null
+++ b/crates/core/src/download/types.rs
@@ -0,0 +1,307 @@
+//! 下载引擎的请求/结果类型:资源类型、摘要、请求、候选源、结果。
+//!
+//! 调用方(game / launch / agent)只需要描述「逻辑资源 + 落盘路径 + 期望校验」,
+//! 镜像展开、地区模式、健康度排序、重试、续传、分块全部由引擎内部完成。
+
+use std::fmt;
+use std::path::PathBuf;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::Duration;
+
+use serde::{Deserialize, Serialize};
+
+/// 资源类型。
+///
+/// 同时用作三件事:健康度统计的第二维度(`源 × 资源类型`)、分块下载的准入条件、
+/// 进度事件的分类标签。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ResourceKind {
+    /// 资源对象(`assets/objects//`),海量小文件。
+    Asset,
+    /// 库文件(MB 级)。
+    Library,
+    /// 客户端 jar(MB~几十 MB)。
+    ClientJar,
+    /// 版本清单 / 版本 JSON(强时效,短 TTL)。
+    VersionManifest,
+    /// 资源索引 `assets/indexes/.json`。
+    AssetIndex,
+    /// JDK 运行时(~180MB),分块下载的主力场景。
+    JavaRuntime,
+    /// GitHub release 产物(自更新 / mod)。
+    GitHubRelease,
+    /// mod 文件(CurseForge / Modrinth)。
+    ModFile,
+    /// 其它。
+    Other,
+}
+
+impl ResourceKind {
+    /// 稳定字符串 id(持久化与日志用,**不要改名**)。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Asset => "asset",
+            Self::Library => "library",
+            Self::ClientJar => "client_jar",
+            Self::VersionManifest => "version_manifest",
+            Self::AssetIndex => "asset_index",
+            Self::JavaRuntime => "java_runtime",
+            Self::GitHubRelease => "github_release",
+            Self::ModFile => "mod_file",
+            Self::Other => "other",
+        }
+    }
+
+    /// 是否默认走多连接分块下载(大文件)。
+    #[must_use]
+    pub const fn prefers_chunked(self) -> bool {
+        matches!(self, Self::JavaRuntime | Self::ModFile)
+    }
+
+    /// 是否属于「小文件」:小文件不开分块(续传开销大于收益)。
+    #[must_use]
+    pub const fn is_small(self) -> bool {
+        matches!(
+            self,
+            Self::Asset | Self::VersionManifest | Self::AssetIndex | Self::Other
+        )
+    }
+}
+
+/// 摘要算法。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum HashAlgo {
+    /// SHA-1(Mojang 全线使用)。
+    Sha1,
+    /// SHA-256(自更新产物与部分镜像)。
+    Sha256,
+}
+
+impl HashAlgo {
+    /// 稳定字符串 id(缓存目录名与索引键用)。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Sha1 => "sha1",
+            Self::Sha256 => "sha256",
+        }
+    }
+
+    /// 十六进制摘要的字符数。
+    #[must_use]
+    pub const fn hex_len(self) -> usize {
+        match self {
+            Self::Sha1 => 40,
+            Self::Sha256 => 64,
+        }
+    }
+}
+
+/// 期望校验值。构造时自动归一化为小写十六进制。
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct Integrity {
+    /// 算法。
+    pub algo: HashAlgo,
+    /// 小写十六进制摘要。
+    pub hex: String,
+}
+
+impl Integrity {
+    /// 构造 SHA-1 校验值。
+    #[must_use]
+    pub fn sha1(hex: impl Into) -> Self {
+        Self {
+            algo: HashAlgo::Sha1,
+            hex: hex.into().trim().to_ascii_lowercase(),
+        }
+    }
+
+    /// 构造 SHA-256 校验值。
+    #[must_use]
+    pub fn sha256(hex: impl Into) -> Self {
+        Self {
+            algo: HashAlgo::Sha256,
+            hex: hex.into().trim().to_ascii_lowercase(),
+        }
+    }
+
+    /// 长度与字符集是否合法(不合法时调用方应显式传 `None`,别让引擎白跑一趟)。
+    #[must_use]
+    pub fn is_well_formed(&self) -> bool {
+        self.hex.len() == self.algo.hex_len() && self.hex.bytes().all(|b| b.is_ascii_hexdigit())
+    }
+
+    /// 给日志/事件用的短前缀(前 8 位)。
+    #[must_use]
+    pub fn short_prefix(&self) -> &str {
+        let end = self.hex.len().min(8);
+        &self.hex[..end]
+    }
+}
+
+/// 断点续传策略。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ResumePolicy {
+    /// 保守判定(默认):长度、ETag/Last-Modified 都对得上才续传(承 HMCL `HttpResumeContext`)。
+    #[default]
+    Conservative,
+    /// 从不续传(强时效资源、或镜像对 Range 支持可疑时)。
+    Never,
+}
+
+/// 单次下载的关联 id(进度事件靠它归并)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+pub struct DownloadId(pub u64);
+
+impl DownloadId {
+    /// 进程内单调递增的 id。
+    #[must_use]
+    pub fn next() -> Self {
+        static NEXT: AtomicU64 = AtomicU64::new(1);
+        Self(NEXT.fetch_add(1, Ordering::Relaxed))
+    }
+
+    /// 原始数值。
+    #[must_use]
+    pub const fn value(self) -> u64 {
+        self.0
+    }
+}
+
+impl fmt::Display for DownloadId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "#{}", self.0)
+    }
+}
+
+/// 一个下载请求:逻辑资源描述,不含任何镜像/重试策略。
+#[derive(Debug, Clone)]
+pub struct DownloadRequest {
+    /// 资源类型。
+    pub kind: ResourceKind,
+    /// **逻辑 URL(官方源)**。镜像按镜像表 + 地区模式展开成候选序列。
+    pub url: String,
+    /// 最终落盘路径(父目录自动创建)。
+    pub dest: PathBuf,
+    /// 期望校验值;`None` 表示只能靠长度兜底(不推荐,清单类资源必须带)。
+    pub integrity: Option,
+    /// 期望大小(进度预算 + 分块规划)。
+    pub expected_size: Option,
+    /// 续传策略。
+    pub resume: ResumePolicy,
+}
+
+impl DownloadRequest {
+    /// 构造请求(默认保守续传、无校验)。
+    #[must_use]
+    pub fn new(kind: ResourceKind, url: impl Into, dest: impl Into) -> Self {
+        Self {
+            kind,
+            url: url.into(),
+            dest: dest.into(),
+            integrity: None,
+            expected_size: None,
+            resume: ResumePolicy::Conservative,
+        }
+    }
+
+    /// 设置 SHA-1 校验。
+    #[must_use]
+    pub fn with_sha1(mut self, hex: impl Into) -> Self {
+        self.integrity = Some(Integrity::sha1(hex));
+        self
+    }
+
+    /// 设置 SHA-256 校验。
+    #[must_use]
+    pub fn with_sha256(mut self, hex: impl Into) -> Self {
+        self.integrity = Some(Integrity::sha256(hex));
+        self
+    }
+
+    /// 设置期望大小。
+    #[must_use]
+    pub fn with_size(mut self, size: u64) -> Self {
+        self.expected_size = Some(size);
+        self
+    }
+
+    /// 设置续传策略。
+    #[must_use]
+    pub fn with_resume(mut self, resume: ResumePolicy) -> Self {
+        self.resume = resume;
+        self
+    }
+
+    /// 请求本身是否自洽(运行期挡住明显错误的请求)。
+    pub fn validate(&self) -> Result<(), super::error::DownloadError> {
+        use super::error::{DownloadError, DownloadErrorKind};
+        if self.url.is_empty() {
+            return Err(DownloadError::new(
+                DownloadErrorKind::InvalidRequest,
+                "url 为空",
+            ));
+        }
+        if !(self.url.starts_with("http://") || self.url.starts_with("https://")) {
+            return Err(DownloadError::new(
+                DownloadErrorKind::InvalidRequest,
+                format!("仅支持 http(s) URL:{}", self.url),
+            ));
+        }
+        if let Some(integrity) = &self.integrity {
+            if !integrity.is_well_formed() {
+                return Err(DownloadError::new(
+                    DownloadErrorKind::InvalidRequest,
+                    format!("校验值不合法:{}", integrity.hex),
+                ));
+            }
+        }
+        Ok(())
+    }
+}
+
+/// 一个候选源(镜像展开产物)。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Candidate {
+    /// 真正要请求的 URL。
+    pub url: String,
+    /// 源标识(`official` / `bmclapi` / `tuna` / `gh_proxy` …),健康度按此归并。
+    pub source: String,
+}
+
+impl Candidate {
+    /// 构造候选。
+    #[must_use]
+    pub fn new(source: impl Into, url: impl Into) -> Self {
+        Self {
+            source: source.into(),
+            url: url.into(),
+        }
+    }
+}
+
+/// 官方源候选的固定 `source` 标识。
+pub const SOURCE_OFFICIAL: &str = "official";
+
+/// 下载成功的结果。
+#[derive(Debug, Clone)]
+pub struct DownloadOutcome {
+    /// 关联 id。
+    pub id: DownloadId,
+    /// 落盘路径。
+    pub path: PathBuf,
+    /// 实际字节数(续传时含已存在的部分)。
+    pub bytes: u64,
+    /// 是否零网络命中内容寻址缓存。
+    pub from_cache: bool,
+    /// 实际成功的源。
+    pub source: String,
+    /// 续传起点(0 = 从头下载)。
+    pub resumed_from: u64,
+    /// 耗时。
+    pub elapsed: Duration,
+}
diff --git a/crates/core/src/download/verify.rs b/crates/core/src/download/verify.rs
new file mode 100644
index 0000000..3eb2859
--- /dev/null
+++ b/crates/core/src/download/verify.rs
@@ -0,0 +1,123 @@
+//! 流式摘要:边下边算,不额外读一遍盘。
+//!
+//! 承 analyse/03 §5.6 的「先校验后落盘」:`MessageDigest` 在写流的同时喂数据,
+//! 流结束时比对,不符就丢掉中间文件。
+
+use std::fs::File;
+use std::io::{self, Read};
+use std::path::Path;
+
+use sha1::Digest;
+
+use super::types::HashAlgo;
+
+/// 小写十六进制编码(不引 `hex` 依赖)。
+#[must_use]
+pub fn to_hex(bytes: &[u8]) -> String {
+    const HEX: &[u8; 16] = b"0123456789abcdef";
+    let mut out = String::with_capacity(bytes.len() * 2);
+    for byte in bytes {
+        out.push(char::from(HEX[usize::from(byte >> 4)]));
+        out.push(char::from(HEX[usize::from(byte & 0x0f)]));
+    }
+    out
+}
+
+/// 增量摘要器。
+#[derive(Debug)]
+pub enum Hasher {
+    /// SHA-1。
+    Sha1(sha1::Sha1),
+    /// SHA-256。
+    Sha256(sha2::Sha256),
+}
+
+impl Hasher {
+    /// 按算法新建。
+    #[must_use]
+    pub fn new(algo: HashAlgo) -> Self {
+        match algo {
+            HashAlgo::Sha1 => Self::Sha1(sha1::Sha1::new()),
+            HashAlgo::Sha256 => Self::Sha256(sha2::Sha256::new()),
+        }
+    }
+
+    /// 算法。
+    #[must_use]
+    pub const fn algo(&self) -> HashAlgo {
+        match self {
+            Self::Sha1(_) => HashAlgo::Sha1,
+            Self::Sha256(_) => HashAlgo::Sha256,
+        }
+    }
+
+    /// 喂一段数据。
+    pub fn update(&mut self, bytes: &[u8]) {
+        match self {
+            Self::Sha1(hasher) => Digest::update(hasher, bytes),
+            Self::Sha256(hasher) => Digest::update(hasher, bytes),
+        }
+    }
+
+    /// 收尾成小写十六进制。
+    #[must_use]
+    pub fn finish(self) -> String {
+        match self {
+            Self::Sha1(hasher) => to_hex(&hasher.finalize()),
+            Self::Sha256(hasher) => to_hex(&hasher.finalize()),
+        }
+    }
+}
+
+/// 一次性算内存里的摘要。
+#[must_use]
+pub fn digest_bytes(algo: HashAlgo, bytes: &[u8]) -> String {
+    let mut hasher = Hasher::new(algo);
+    hasher.update(bytes);
+    hasher.finish()
+}
+
+/// 算整个文件的摘要(`cache` 的抽样校验与 `register` 路径用)。
+pub fn digest_file(path: &Path, algo: HashAlgo) -> io::Result {
+    let mut hasher = Hasher::new(algo);
+    let mut buffer = vec![0_u8; 64 * 1024];
+    let mut file = File::open(path)?;
+    loop {
+        let read = file.read(&mut buffer)?;
+        if read == 0 {
+            break;
+        }
+        hasher.update(&buffer[..read]);
+    }
+    Ok(hasher.finish())
+}
+
+/// 把文件前 `limit` 字节喂进已有摘要器(续传时让「边下边算」接得上)。
+pub fn update_hasher_from_file(hasher: &mut Hasher, path: &Path, limit: u64) -> io::Result<()> {
+    if limit == 0 {
+        return Ok(());
+    }
+    let mut buffer = vec![0_u8; 64 * 1024];
+    let mut reader = File::open(path)?.take(limit);
+    loop {
+        let read = reader.read(&mut buffer)?;
+        if read == 0 {
+            break;
+        }
+        hasher.update(&buffer[..read]);
+    }
+    Ok(())
+}
+
+/// 对已有文件的前 `prefix_len` 字节补算摘要(诊断/抽样校验用)。
+pub fn digest_prefix(path: &Path, algo: HashAlgo, prefix_len: u64) -> io::Result {
+    let mut hasher = Hasher::new(algo);
+    update_hasher_from_file(&mut hasher, path, prefix_len)?;
+    Ok(hasher.finish())
+}
+
+/// 摘要比较(大小写不敏感,避免上游给了大写 hex 就白跑一趟)。
+#[must_use]
+pub fn matches(expected_hex: &str, actual_hex: &str) -> bool {
+    expected_hex.len() == actual_hex.len() && expected_hex.eq_ignore_ascii_case(actual_hex)
+}
diff --git a/crates/core/src/game/arguments.rs b/crates/core/src/game/arguments.rs
new file mode 100644
index 0000000..5317069
--- /dev/null
+++ b/crates/core/src/game/arguments.rs
@@ -0,0 +1,331 @@
+//! 版本 JSON 的 `arguments` 模型:规则(rules)求值与参数展开。
+//!
+//! 依据:`DevDocs/analyse/03-version-download.md` §4.4(`arguments` 的合并语义)、
+//! §4.6(`minecraftArguments` 单串形式与新版 `arguments` 并存)。
+//!
+//! 规则求值语义照抄 Mojang/HMCL(`CompatibilityRule.appliesToCurrentEnvironment`):
+//! 规则列表为空 → 生效;否则**从 DISALLOW 开始**逐条覆盖,最后是 ALLOW 才算生效。
+//! 这是"白名单式"语义,与"最后一条匹配者胜"不同,抄错会漏掉/多带启动参数。
+
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+
+use crate::platform::os_info::OsInfo;
+
+/// 规则动作。
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum RuleAction {
+    /// 允许(命中后该参数/库生效)。
+    #[default]
+    Allow,
+    /// 禁止(命中后该参数/库失效)。
+    Disallow,
+}
+
+/// 一条规则。
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Rule {
+    /// 动作;缺省按 `allow` 处理(Mojang 老写法里 rules 只写 os 的情况)。
+    #[serde(default)]
+    pub action: RuleAction,
+    /// 操作系统限制。
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub os: Option,
+    /// 特性开关限制(`has_custom_resolution`、`is_demo_user`…)。
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub features: Option>,
+}
+
+/// 规则的 OS 限制。`version`/`arch` 在官方 JSON 里是**正则**,见 [`matches_simple_pattern`]。
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct OsRestriction {
+    /// `windows` / `osx` / `linux`;也接受模组包里的 `win-x86` 这种合并写法。
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub name: Option,
+    /// OS 版本模式。
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub version: Option,
+    /// 架构模式(`x86`、`x86_64`、`arm64`)。
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub arch: Option,
+}
+
+/// 求值时的运行环境(OS / 架构 / 特性开关)。
+///
+/// 现由 [`Environment::host`] 从 `core::platform::os_info` 的探测快照构造(同 crate 依赖),
+/// 因此 `os_version` 是真实系统版本而不是空串;单测可以注入假快照做到不碰真机。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Environment {
+    /// Mojang 口径的 OS 名:`windows` / `osx` / `linux`。
+    pub os: String,
+    /// OS 版本字符串(Windows 上是 `10.0.19045` 之类;探测失败时为空串)。
+    pub os_version: String,
+    /// Mojang 口径的架构:`x86` / `x86_64` / `arm64`。
+    pub arch: String,
+    /// 特性开关,缺省全部视为 `false`。
+    pub features: BTreeMap,
+}
+
+impl Default for Environment {
+    fn default() -> Self {
+        Self::host()
+    }
+}
+
+impl Environment {
+    /// 当前主机环境。
+    ///
+    /// `os` / `arch` / `os_version` 全部取自 [`crate::platform::os_info::current`]
+    /// (进程内缓存,探测只做一次):`os_version` 不再是空串,因此带 `os.version` 限制的
+    /// 规则(官方 JSON 里用正则写系统版本)能按真实值求值。
+    pub fn host() -> Self {
+        Self::from_os_info(crate::platform::os_info::current())
+    }
+
+    /// 从一份系统信息快照构造环境(单测注入假快照,不碰真机;也让 app/CLI 能复用已探测结果)。
+    pub fn from_os_info(info: &OsInfo) -> Self {
+        Self {
+            os: info.family.manifest_name().to_owned(),
+            os_version: info.os_version().to_owned(),
+            arch: info.arch.clone(),
+            features: BTreeMap::new(),
+        }
+    }
+
+    /// 打开/关闭一个特性开关(构建器风格,便于 launch 侧串起来)。
+    pub fn with_feature(mut self, name: impl Into, value: bool) -> Self {
+        self.features.insert(name.into(), value);
+        self
+    }
+
+    /// 读一个特性开关;未声明视为 `false`。
+    pub fn feature(&self, name: &str) -> bool {
+        self.features.get(name).copied().unwrap_or(false)
+    }
+}
+
+impl OsRestriction {
+    /// 该限制是否匹配当前环境。
+    pub fn allows(&self, env: &Environment) -> bool {
+        if let Some(name) = &self.name {
+            // 模组包常见 `{ "name": "win-x86" }`:OS 名与架构合写。
+            let (os_name, arch) = match name.split_once('-') {
+                Some((os_part, arch_part)) if is_known_os(os_part) && is_known_arch(arch_part) => {
+                    (os_part, Some(arch_part))
+                }
+                _ => (name.as_str(), None),
+            };
+            if is_known_os(os_name) && os_name != env.os {
+                return false;
+            }
+            if let Some(arch) = arch {
+                if arch != env.arch {
+                    return false;
+                }
+            }
+        }
+        if let Some(version) = &self.version {
+            if !matches_simple_pattern(version, &env.os_version) {
+                return false;
+            }
+        }
+        if let Some(arch) = &self.arch {
+            if !matches_simple_pattern(arch, &env.arch) {
+                return false;
+            }
+        }
+        true
+    }
+}
+
+fn is_known_os(name: &str) -> bool {
+    matches!(name, "windows" | "osx" | "linux")
+}
+
+fn is_known_arch(name: &str) -> bool {
+    matches!(name, "x86" | "x86_64" | "arm64")
+}
+
+/// 极简正则匹配(官方 JSON 里 `os.version`/`os.arch` 用的是正则)。
+///
+/// 只覆盖实际出现过的形态:`^字面量`(前缀)、`字面量`(全等)、`字面量$`、
+/// 以及里面的 `\.`/`.*` 展开。**遇到其它元字符一律放宽为匹配**——
+/// 宁可多带一个参数(例如 Windows 专用 `-Dos.name`),也不要因为不会解析正则而漏掉参数。
+/// 阶段 B 若真有 JSON 用到复杂模式,再引入 `regex` 依赖替换本函数。
+pub fn matches_simple_pattern(pattern: &str, actual: &str) -> bool {
+    let anchored_start = pattern.starts_with('^');
+    let body = pattern.strip_prefix('^').unwrap_or(pattern);
+    let anchored_end = body.ends_with('$');
+    let body = body.strip_suffix('$').unwrap_or(body);
+
+    let mut literal = String::with_capacity(body.len());
+    let mut chars = body.chars();
+    while let Some(c) = chars.next() {
+        match c {
+            // `.*` 视作通配:丢掉两个字符即可(前后锚点仍然生效)。
+            '.' if chars.as_str().starts_with('*') => {
+                chars.next();
+            }
+            // `\.` 展开成字面点号。
+            '\\' => match chars.next() {
+                Some('.') => literal.push('.'),
+                _ => return true,
+            },
+            '.' => literal.push('.'),
+            '|' | '[' | ']' | '(' | ')' | '+' | '?' | '{' | '}' | '^' | '$' => return true,
+            other => literal.push(other),
+        }
+    }
+
+    match (anchored_start, anchored_end) {
+        (true, true) => actual == literal,
+        (true, false) => actual.starts_with(&literal),
+        (false, true) => actual.ends_with(&literal),
+        (false, false) => actual == literal || actual.starts_with(&literal),
+    }
+}
+
+impl Rule {
+    /// 该规则在当前环境下的动作;不匹配环境时返回 `None`。
+    pub fn action_in(&self, env: &Environment) -> Option {
+        if let Some(os) = &self.os {
+            if !os.allows(env) {
+                return None;
+            }
+        }
+        if let Some(features) = &self.features {
+            for (name, expected) in features {
+                if env.feature(name) != *expected {
+                    return None;
+                }
+            }
+        }
+        Some(self.action)
+    }
+}
+
+/// 一组规则的求值(Mojang 白名单语义)。
+pub fn rules_allow(rules: &[Rule], env: &Environment) -> bool {
+    if rules.is_empty() {
+        return true;
+    }
+    let mut action = RuleAction::Disallow;
+    for rule in rules {
+        if let Some(this) = rule.action_in(env) {
+            action = this;
+        }
+    }
+    action == RuleAction::Allow
+}
+
+/// `arguments` 里的一项:纯字符串参数,或带规则的条件参数。
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum Argument {
+    /// 无条件参数。
+    Plain(String),
+    /// 带规则的条件参数。
+    Conditional(ConditionalArgument),
+}
+
+/// 带规则的条件参数。
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ConditionalArgument {
+    /// 规则列表(白名单语义,见 [`rules_allow`])。
+    #[serde(default)]
+    pub rules: Vec,
+    /// 命中后的参数值(单个或多个)。
+    pub value: ArgumentValue,
+}
+
+/// 参数值:单个字符串或多个字符串。
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum ArgumentValue {
+    /// 单个参数。
+    One(String),
+    /// 多个参数。
+    Many(Vec),
+}
+
+impl ArgumentValue {
+    /// 逐个参数借用(`One` 给一个,`Many` 给若干个)。
+    pub fn strings(&self) -> Vec<&str> {
+        match self {
+            Self::One(value) => vec![value.as_str()],
+            Self::Many(values) => values.iter().map(String::as_str).collect(),
+        }
+    }
+}
+
+impl Argument {
+    /// 在当前环境下展开为具体参数(占位符替换归 launch 负责)。
+    pub fn expand_into(&self, env: &Environment, out: &mut Vec) {
+        match self {
+            Self::Plain(value) => out.push(value.clone()),
+            Self::Conditional(cond) => {
+                if rules_allow(&cond.rules, env) {
+                    match &cond.value {
+                        ArgumentValue::One(value) => out.push(value.clone()),
+                        ArgumentValue::Many(values) => out.extend(values.iter().cloned()),
+                    }
+                }
+            }
+        }
+    }
+}
+
+/// 版本 JSON 的 `arguments` 对象。
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Arguments {
+    /// 游戏参数(`mainClass` 之后)。
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub game: Vec,
+    /// JVM 参数(`-Xmx` 之类之外的那部分)。
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub jvm: Vec,
+}
+
+impl Arguments {
+    /// 合并两层参数:**父在前、子在后**(analyse/03 §4.4)。
+    pub fn merge(parent: Option<&Arguments>, child: Option<&Arguments>) -> Option {
+        match (parent, child) {
+            (None, None) => None,
+            (Some(parent), None) => Some(parent.clone()),
+            (None, Some(child)) => Some(child.clone()),
+            (Some(parent), Some(child)) => Some(Arguments {
+                game: parent
+                    .game
+                    .iter()
+                    .chain(child.game.iter())
+                    .cloned()
+                    .collect(),
+                jvm: parent.jvm.iter().chain(child.jvm.iter()).cloned().collect(),
+            }),
+        }
+    }
+
+    /// 按环境展开成扁平参数表(占位符 `--username ${auth_player_name}` 不在这里替换)。
+    pub fn expand(&self, env: &Environment) -> ExpandedArguments {
+        let mut game = Vec::new();
+        let mut jvm = Vec::new();
+        for argument in &self.game {
+            argument.expand_into(env, &mut game);
+        }
+        for argument in &self.jvm {
+            argument.expand_into(env, &mut jvm);
+        }
+        ExpandedArguments { game, jvm }
+    }
+}
+
+/// [`Arguments::expand`] 的结果。
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct ExpandedArguments {
+    /// 展开后的游戏参数。
+    pub game: Vec,
+    /// 展开后的 JVM 参数。
+    pub jvm: Vec,
+}
diff --git a/crates/core/src/game/capability.rs b/crates/core/src/game/capability.rs
new file mode 100644
index 0000000..7247555
--- /dev/null
+++ b/crates/core/src/game/capability.rs
@@ -0,0 +1,83 @@
+//! 版本能力位:从版本 JSON 的**库表**推断「这个版本带不带某个渲染后端」。
+//!
+//! 依据 `DevDocs/PlanDoc/research/05-mc-vulkan.md`(M26)§2.2 与 §4.2:
+//! Vulkan 的三件套(`lwjgl-vulkan` / `lwjgl-shaderc` / `lwjgl-spvc` / `lwjgl-vma`)从
+//! **`26.2-snapshot-1`(2026-04-07)** 起进入官方库列表,26.1 全线(含 `26w14a` 这类周快照)没有。
+//!
+//! # 为什么用库表而不是版本号
+//!
+//! 硬编码 `is_at_least("26.2-snapshot-2")` 对官方主线是对的,但周快照(`26w14a`)、
+//! 继承版本、第三方改版都拿不到可靠的比较结果(`GameVersion::is_known() == false` 时
+//! 门限恒假)。库表是版本自己声明的**事实**,比版本号串稳。
+//!
+//! # ⚠️ 「库存在」≠「参数可用」
+//!
+//! `26.2-snapshot-1` 已经有 `lwjgl-vulkan`,但 `--graphicsBackend` 要到
+//! **`26.2-snapshot-2`(2026-04-09)** 才进 `Main.class`,两者差两天(实测结论)。
+//! 因此能力位只适合做:
+//! - **必要条件的快速否定**(没有库 → 一定没有 Vulkan 后端);
+//! - UI 可用性提示(渲染设置里出不出「图形 API」三选一);
+//! - **无法排序的版本**(周快照)的兜底判据。
+//!
+//! 可解析版本的下发门限仍以 `26.2-snapshot-2` 为准,见
+//! [`crate::launch::game_args::graphics_backend_allowed`]。
+
+use super::manifest::VersionJson;
+
+/// Vulkan 后端的 Maven 坐标(`group:artifact`)。
+pub const VULKAN_LIBRARY_GROUP: &str = "org.lwjgl";
+/// Vulkan 后端的 Maven 坐标(artifact)。
+pub const VULKAN_LIBRARY_ARTIFACT: &str = "lwjgl-vulkan";
+
+/// 一条库名是否是 LWJGL 的 Vulkan 绑定。
+///
+/// 吃 `group:artifact:version[:classifier][@ext]` 全形态;只比对前两段,因此
+/// `org.lwjgl:lwjgl-vulkan:3.4.3`、`org.lwjgl:lwjgl-vulkan:3.4.3:natives-macos`
+/// 与 `org.lwjgl:lwjgl-vulkan:3.4.3@jar` 都算命中。
+#[must_use]
+pub fn is_vulkan_library(name: &str) -> bool {
+    let mut parts = name.split(':');
+    let (Some(group), Some(artifact)) = (parts.next(), parts.next()) else {
+        return false;
+    };
+    // classifier / `@ext` 可能粘在 artifact 上(`lwjgl-vulkan@jar`)。
+    let artifact = artifact.split('@').next().unwrap_or(artifact);
+    group.trim() == VULKAN_LIBRARY_GROUP && artifact.trim() == VULKAN_LIBRARY_ARTIFACT
+}
+
+/// 从一份版本 JSON 推出的能力位(目前只有 Vulkan 一项,后续可加)。
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct VersionCapabilities {
+    /// `libraries` 里存在 `org.lwjgl:lwjgl-vulkan`。
+    ///
+    /// **注意语义**:这是「版本带 Vulkan 后端实现」的**必要条件**,不是
+    /// 「`--graphicsBackend` 可下发」的充分条件(见模块文档)。
+    pub has_vulkan_backend: bool,
+}
+
+impl VersionCapabilities {
+    /// 从版本 JSON 解析能力位(只读 `libraries[].name`,不看下载信息与规则)。
+    #[must_use]
+    pub fn from_json(json: &VersionJson) -> Self {
+        Self {
+            has_vulkan_backend: json
+                .libraries
+                .as_deref()
+                .unwrap_or_default()
+                .iter()
+                .any(|library| is_vulkan_library(&library.name)),
+        }
+    }
+
+    /// 是否带 Vulkan 后端。
+    #[must_use]
+    pub const fn has_vulkan_backend(&self) -> bool {
+        self.has_vulkan_backend
+    }
+}
+
+/// 快捷方式:库名列表里是否有 Vulkan 绑定(launch 侧拿到的库名表也能直接用)。
+#[must_use]
+pub fn library_names_have_vulkan<'a>(names: impl IntoIterator) -> bool {
+    names.into_iter().any(is_vulkan_library)
+}
diff --git a/crates/core/src/game/data/unlisted-versions.json b/crates/core/src/game/data/unlisted-versions.json
new file mode 100644
index 0000000..0a62625
--- /dev/null
+++ b/crates/core/src/game/data/unlisted-versions.json
@@ -0,0 +1,209 @@
+{
+  "versions": [
+    {"id": "1.21.11_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/327be7759157b04495c591dbb721875e341877af/1.21.11_unobfuscated.json", "time": "2025-12-09T12:43:15+00:00", "releaseTime": "2025-12-09T12:43:15+00:00"},
+    {"id": "1.21.11-rc3_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/ce3f7ac6d0e9d23ea4e5f0354b91ff15039d9931/1.21.11-rc3_unobfuscated.json", "time": "2025-12-08T13:59:34+00:00", "releaseTime": "2025-12-08T13:59:34+00:00"},
+    {"id": "1.21.11-rc2_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/9282a3fb154d2a425086c62c11827281308bf93b/1.21.11-rc2_unobfuscated.json", "time": "2025-12-05T11:57:45+00:00", "releaseTime": "2025-12-05T11:57:45+00:00"},
+    {"id": "1.21.11-rc1_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/5d3ee0ef1f0251cf7e073354ca9e085a884a643d/1.21.11-rc1_unobfuscated.json", "time": "2025-12-04T15:56:55+00:00", "releaseTime": "2025-12-04T15:56:55+00:00"},
+    {"id": "1.21.11-pre5_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/1028441ca6d288bbf2103e773196bf524f7260fd/1.21.11-pre5_unobfuscated.json", "time": "2025-12-03T13:34:06+00:00", "releaseTime": "2025-12-03T13:34:06+00:00"},
+    {"id": "1.21.11-pre4_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/410ce37a2506adcfd54ef7d89168cfbe89cac4cb/1.21.11-pre4_unobfuscated.json", "time": "2025-12-01T13:40:12+00:00", "releaseTime": "2025-12-01T13:40:12+00:00"},
+    {"id": "1.21.11-pre3_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/579bf3428f72b5ea04883d202e4831bfdcb2aa8d/1.21.11-pre3_unobfuscated.json", "time": "2025-11-25T14:14:30+00:00", "releaseTime": "2025-11-25T14:14:30+00:00"},
+    {"id": "1.21.11-pre2_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/2955ce0af0512fdfe53ff0740b017344acf6f397/1.21.11-pre2_unobfuscated.json", "time": "2025-11-21T12:07:21+00:00", "releaseTime": "2025-11-21T12:07:21+00:00"},
+    {"id": "1.21.11-pre1_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/9c267f8dda2728bae55201a753cdd07b584709f1/1.21.11-pre1_unobfuscated.json", "time": "2025-11-19T08:30:46+00:00", "releaseTime": "2025-11-19T08:30:46+00:00"},
+    {"id": "25w46a_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/314ade2afeada364047798e163ef8e82427c69e1/25w46a_unobfuscated.json", "time": "2025-11-11T13:20:54+00:00", "releaseTime": "2025-11-11T13:20:54+00:00"},
+    {"id": "25w45a_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/7a3c149f148b6aa5ac3af48c4f701adea7e5b615/25w45a_unobfuscated.json", "time": "2025-11-04T14:07:08+00:00", "releaseTime": "2025-11-04T14:07:08+00:00"},
+    {"id": "1.19_deep_dark_experimental_snapshot-1", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_19_deep_dark_experimental_snapshot-1/1_19_deep_dark_experimental_snapshot-1.json", "time": "2022-02-17T13:55:59+00:00", "releaseTime": "2022-02-17T13:55:59+00:00"},
+    {"id": "1.18_experimental-snapshot-7", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-7/1_18_experimental-snapshot-7.json", "time": "2021-09-08T12:33:23+00:00", "releaseTime": "2021-09-08T12:33:23+00:00"},
+    {"id": "1.18_experimental-snapshot-6", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-6/1_18_experimental-snapshot-6.json", "time": "2021-09-01T12:53:14+00:00", "releaseTime": "2021-09-01T12:53:14+00:00"},
+    {"id": "1.18_experimental-snapshot-5", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-5/1_18_experimental-snapshot-5.json", "time": "2021-08-25T14:41:57+00:00", "releaseTime": "2021-08-25T14:41:57+00:00"},
+    {"id": "1.18_experimental-snapshot-4", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-4/1_18_experimental-snapshot-4.json", "time": "2021-08-17T16:10:25+00:00", "releaseTime": "2021-08-17T16:10:25+00:00"},
+    {"id": "1.18_experimental-snapshot-3", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-3/1_18_experimental-snapshot-3.json", "time": "2021-08-10T12:42:45+00:00", "releaseTime": "2021-08-10T12:42:45+00:00"},
+    {"id": "1.18_experimental-snapshot-2", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-2/1_18_experimental-snapshot-2.json", "time": "2021-07-20T13:35:08+00:00", "releaseTime": "2021-07-20T13:35:08+00:00"},
+    {"id": "1.18_experimental-snapshot-1", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-1/1_18_experimental-snapshot-1.json", "time": "2021-07-13T12:54:19+00:00", "releaseTime": "2021-07-13T12:54:19+00:00"},
+    {"id": "1.16_combat-6", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-6/1_16_combat-6.json", "time": "2020-08-26T06:24:28+00:00", "releaseTime": "2020-08-26T06:24:28+00:00"},
+    {"id": "1.16_combat-5", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-5/1_16_combat-5.json", "time": "2020-08-21T09:23:13+00:00", "releaseTime": "2020-08-21T09:23:13+00:00"},
+    {"id": "1.16_combat-4", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-4/1_16_combat-4.json", "time": "2020-08-19T11:14:58+00:00", "releaseTime": "2020-08-19T11:14:58+00:00"},
+    {"id": "1.16_combat-3", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-3/1_16_combat-3.json", "time": "2020-08-14T09:02:15+00:00", "releaseTime": "2020-08-14T09:02:15+00:00"},
+    {"id": "1.16_combat-2", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-2/1_16_combat-2.json", "time": "2020-08-13T11:56:30+00:00", "releaseTime": "2020-08-13T11:56:30+00:00"},
+    {"id": "1.16_combat-1", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-1/1_16_combat-1.json", "time": "2020-08-12T14:07:25+00:00", "releaseTime": "2020-08-12T14:07:25+00:00"},
+    {"id": "1.16_combat-0", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-0/1_16_combat-0.json", "time": "2020-08-07T10:44:47+00:00", "releaseTime": "2020-08-07T10:44:47+00:00"},
+    {"id": "1.15_combat-6", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_15_combat-6/1_15_combat-6.json", "time": "2020-01-15T09:46:35+00:00", "releaseTime": "2020-01-15T09:46:35+00:00"},
+    {"id": "1.15_combat-1", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_15_combat-1/1_15_combat-1.json", "time": "2019-11-29T15:41:39+00:00", "releaseTime": "2019-11-29T15:41:39+00:00"},
+    {"id": "1.14_combat-3", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_14_combat-3/1_14_combat-3.json", "time": "2019-10-31T14:31:38+00:00", "releaseTime": "2019-10-31T14:31:38+00:00"},
+    {"id": "1.14_combat-0", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_14_combat-0/1_14_combat-0.json", "time": "2019-08-13T07:33:42+00:00", "releaseTime": "2019-08-13T07:33:42+00:00"},
+    {"id": "1.14_combat-212796", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_14_combat-212796/1_14_combat-212796.json", "time": "2019-06-20T13:23:44+00:00", "releaseTime": "2019-06-20T13:23:44+00:00"},
+    {"id": "2.0_blue", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/2point0_blue/2point0_blue.json", "time": "2013-08-06T06:00:02-05:00", "releaseTime": "2013-03-20T05:00:02-05:00"},
+    {"id": "2.0_red", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/2point0_red/2point0_red.json", "time": "2013-08-06T06:00:01-05:00", "releaseTime": "2013-03-20T05:00:01-05:00"},
+    {"id": "2.0_purple", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/2point0_purple/2point0_purple.json", "time": "2013-08-06T06:00:00-05:00", "releaseTime": "2013-03-20T05:00:00-05:00"},
+    {"id": "13w12~", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w12-/13w12-.json", "time": "2013-03-19T00:00:00+00:00", "releaseTime": "2013-03-19T00:00:00+00:00"},
+    {"id": "13w10b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w10b/13w10b.json", "time": "2013-03-06T00:00:00+00:00", "releaseTime": "2013-03-06T00:00:00+00:00"},
+    {"id": "13w10a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w10a/13w10a.json", "time": "2013-03-04T00:00:00+00:00", "releaseTime": "2013-03-04T00:00:00+00:00"},
+    {"id": "13w09c", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w09c/13w09c.json", "time": "2013-03-01T00:00:00+00:00", "releaseTime": "2013-03-01T00:00:00+00:00"},
+    {"id": "13w09b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w09b/13w09b.json", "time": "2013-02-27T00:00:00+00:00", "releaseTime": "2013-02-27T00:00:00+00:00"},
+    {"id": "13w09a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w09a/13w09a.json", "time": "2013-02-26T00:00:00+00:00", "releaseTime": "2013-02-26T00:00:00+00:00"},
+    {"id": "13w07a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w07a/13w07a.json", "time": "2013-02-14T00:00:00+00:00", "releaseTime": "2013-02-14T00:00:00+00:00"},
+    {"id": "13w11a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w11a/13w11a.json", "time": "2013-02-14T00:00:00+00:00", "releaseTime": "2013-02-14T00:00:00+00:00"},
+    {"id": "13w06a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w06a/13w06a.json", "time": "2013-02-07T00:00:00+00:00", "releaseTime": "2013-02-07T00:00:00+00:00"},
+    {"id": "13w05b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w05b/13w05b.json", "time": "2013-02-01T00:00:00+00:00", "releaseTime": "2013-02-01T00:00:00+00:00"},
+    {"id": "13w05a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w05a/13w05a.json", "time": "2013-01-31T00:00:00+00:00", "releaseTime": "2013-01-31T00:00:00+00:00"},
+    {"id": "13w04a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w04a/13w04a.json", "time": "2013-01-24T00:00:00+00:00", "releaseTime": "2013-01-24T00:00:00+00:00"},
+    {"id": "13w03a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w03a/13w03a.json", "time": "2013-01-17T00:00:00+00:00", "releaseTime": "2013-01-17T00:00:00+00:00"},
+    {"id": "13w02b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w02b/13w02b.json", "time": "2013-01-11T00:00:00+00:00", "releaseTime": "2013-01-11T00:00:00+00:00"},
+    {"id": "13w02a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w02a/13w02a.json", "time": "2013-01-10T00:00:00+00:00", "releaseTime": "2013-01-10T00:00:00+00:00"},
+    {"id": "13w01b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w01b/13w01b.json", "time": "2013-01-04T00:00:00+00:00", "releaseTime": "2013-01-04T00:00:00+00:00"},
+    {"id": "13w01a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w01a/13w01a.json", "time": "2013-01-03T00:00:00+00:00", "releaseTime": "2013-01-03T00:00:00+00:00"},
+    {"id": "12w50b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w50b/12w50b.json", "time": "2012-12-14T00:00:00+00:00", "releaseTime": "2012-12-14T00:00:00+00:00"},
+    {"id": "12w50a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w50a/12w50a.json", "time": "2012-12-13T00:00:00+00:00", "releaseTime": "2012-12-13T00:00:00+00:00"},
+    {"id": "12w49a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w49a/12w49a.json", "time": "2012-12-07T00:00:00+00:00", "releaseTime": "2012-12-07T00:00:00+00:00"},
+    {"id": "12w42b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w42b/12w42b.json", "time": "2012-10-18T00:00:00+00:00", "releaseTime": "2012-10-18T00:00:00+00:00"},
+    {"id": "12w42a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w42a/12w42a.json", "time": "2012-10-17T00:00:00+00:00", "releaseTime": "2012-10-17T00:00:00+00:00"},
+    {"id": "12w41b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w41b/12w41b.json", "time": "2012-10-12T00:00:00+00:00", "releaseTime": "2012-10-12T00:00:00+00:00"},
+    {"id": "12w41a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w41a/12w41a.json", "time": "2012-10-11T00:00:00+00:00", "releaseTime": "2012-10-11T00:00:00+00:00"},
+    {"id": "12w40b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w40b/12w40b.json", "time": "2012-10-05T00:00:00+00:00", "releaseTime": "2012-10-05T00:00:00+00:00"},
+    {"id": "12w40a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w40a/12w40a.json", "time": "2012-10-04T00:00:00+00:00", "releaseTime": "2012-10-04T00:00:00+00:00"},
+    {"id": "12w39b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w39b/12w39b.json", "time": "2012-09-28T00:00:00+00:00", "releaseTime": "2012-09-28T00:00:00+00:00"},
+    {"id": "12w39a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w39a/12w39a.json", "time": "2012-09-27T00:00:00+00:00", "releaseTime": "2012-09-27T00:00:00+00:00"},
+    {"id": "12w38b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w38b/12w38b.json", "time": "2012-09-21T00:00:00+00:00", "releaseTime": "2012-09-21T00:00:00+00:00"},
+    {"id": "12w38a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w38a/12w38a.json", "time": "2012-09-20T00:00:00+00:00", "releaseTime": "2012-09-20T00:00:00+00:00"},
+    {"id": "12w37a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w37a/12w37a.json", "time": "2012-09-13T00:00:00+00:00", "releaseTime": "2012-09-13T00:00:00+00:00"},
+    {"id": "12w36a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w36a/12w36a.json", "time": "2012-09-06T00:00:00+00:00", "releaseTime": "2012-09-06T00:00:00+00:00"},
+    {"id": "12w34b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w34b/12w34b.json", "time": "2012-08-24T00:00:00+00:00", "releaseTime": "2012-08-24T00:00:00+00:00"},
+    {"id": "12w34a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w34a/12w34a.json", "time": "2012-08-23T00:00:00+00:00", "releaseTime": "2012-08-23T00:00:00+00:00"},
+    {"id": "12w32a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w32a/12w32a.json", "time": "2012-08-09T00:00:00+00:00", "releaseTime": "2012-08-09T00:00:00+00:00"},
+    {"id": "12w30d", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w30d/12w30d.json", "time": "2012-07-25T00:00:00+00:00", "releaseTime": "2012-07-25T00:00:00+00:00"},
+    {"id": "12w30e", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w30e/12w30e.json", "time": "2012-07-25T00:00:00+00:00", "releaseTime": "2012-07-25T00:00:00+00:00"},
+    {"id": "12w30c", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w30c/12w30c.json", "time": "2012-07-24T00:00:00+00:00", "releaseTime": "2012-07-24T00:00:00+00:00"},
+    {"id": "12w30a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w30a/12w30a.json", "time": "2012-07-23T00:00:00+00:00", "releaseTime": "2012-07-23T00:00:00+00:00"},
+    {"id": "12w30b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w30b/12w30b.json", "time": "2012-07-23T00:00:00+00:00", "releaseTime": "2012-07-23T00:00:00+00:00"},
+    {"id": "12w27a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w27a/12w27a.json", "time": "2012-07-05T00:00:00+00:00", "releaseTime": "2012-07-05T00:00:00+00:00"},
+    {"id": "12w26a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w26a/12w26a.json", "time": "2012-06-27T00:00:00+00:00", "releaseTime": "2012-06-27T00:00:00+00:00"},
+    {"id": "12w25a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w25a/12w25a.json", "time": "2012-06-21T00:00:00+00:00", "releaseTime": "2012-06-21T00:00:00+00:00"},
+    {"id": "12w24a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w24a/12w24a.json", "time": "2012-06-14T00:00:00+00:00", "releaseTime": "2012-06-14T00:00:00+00:00"},
+    {"id": "12w23a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w23a/12w23a.json", "time": "2012-06-07T00:00:00+00:00", "releaseTime": "2012-06-07T00:00:00+00:00"},
+    {"id": "12w23b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w23b/12w23b.json", "time": "2012-06-07T00:00:00+00:00", "releaseTime": "2012-06-07T00:00:00+00:00"},
+    {"id": "12w22a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w22a/12w22a.json", "time": "2012-05-31T00:00:00+00:00", "releaseTime": "2012-05-31T00:00:00+00:00"},
+    {"id": "12w21b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w21b/12w21b.json", "time": "2012-05-25T00:00:00+00:00", "releaseTime": "2012-05-25T00:00:00+00:00"},
+    {"id": "12w21a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w21a/12w21a.json", "time": "2012-05-24T00:00:00+00:00", "releaseTime": "2012-05-24T00:00:00+00:00"},
+    {"id": "12w19a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w19a/12w19a.json", "time": "2012-05-10T00:00:00+00:00", "releaseTime": "2012-05-10T00:00:00+00:00"},
+    {"id": "12w18a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w18a/12w18a.json", "time": "2012-05-03T00:00:00+00:00", "releaseTime": "2012-05-03T00:00:00+00:00"},
+    {"id": "12w17a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w17a/12w17a.json", "time": "2012-04-26T00:00:00+00:00", "releaseTime": "2012-04-26T00:00:00+00:00"},
+    {"id": "12w16a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w16a/12w16a.json", "time": "2012-04-19T00:00:00+00:00", "releaseTime": "2012-04-19T00:00:00+00:00"},
+    {"id": "1.2", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_2/1_2.json", "time": "2012-02-29T00:00:00+00:00", "releaseTime": "2012-02-29T00:00:00+00:00"},
+    {"id": "12w08a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w08a/12w08a.json", "time": "2012-02-23T00:00:00+00:00", "releaseTime": "2012-02-23T00:00:00+00:00"},
+    {"id": "12w07a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w07a/12w07a.json", "time": "2012-02-15T00:00:00+00:00", "releaseTime": "2012-02-15T00:00:00+00:00"},
+    {"id": "12w07b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w07b/12w07b.json", "time": "2012-02-15T00:00:00+00:00", "releaseTime": "2012-02-15T00:00:00+00:00"},
+    {"id": "12w06a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w06a/12w06a.json", "time": "2012-02-09T00:00:00+00:00", "releaseTime": "2012-02-09T00:00:00+00:00"},
+    {"id": "12w05b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w05b/12w05b.json", "time": "2012-02-03T00:00:00+00:00", "releaseTime": "2012-02-03T00:00:00+00:00"},
+    {"id": "12w05a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w05a/12w05a.json", "time": "2012-02-02T00:00:00+00:00", "releaseTime": "2012-02-02T00:00:00+00:00"},
+    {"id": "12w04a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w04a/12w04a.json", "time": "2012-01-26T00:00:00+00:00", "releaseTime": "2012-01-26T00:00:00+00:00"},
+    {"id": "12w03a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w03a/12w03a.json", "time": "2012-01-19T00:00:00+00:00", "releaseTime": "2012-01-19T00:00:00+00:00"},
+    {"id": "12w01a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w01a/12w01a.json", "time": "2012-01-05T00:00:00+00:00", "releaseTime": "2012-01-05T00:00:00+00:00"},
+    {"id": "11w50a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/11w50a/11w50a.json", "time": "2011-12-15T00:00:00+00:00", "releaseTime": "2011-12-15T00:00:00+00:00"},
+    {"id": "11w49a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/11w49a/11w49a.json", "time": "2011-12-08T00:00:00+00:00", "releaseTime": "2011-12-08T00:00:00+00:00"},
+    {"id": "11w48a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/11w48a/11w48a.json", "time": "2011-12-01T00:00:00+00:00", "releaseTime": "2011-12-01T00:00:00+00:00"},
+    {"id": "11w47a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/11w47a/11w47a.json", "time": "2011-11-24T00:00:00+00:00", "releaseTime": "2011-11-24T00:00:00+00:00"},
+    {"id": "1.0.0-rc2-3", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1.0.0-rc2-3/1.0.0-rc2-3.json", "time": "2013-08-06T07:33:30-07:00", "releaseTime": "2011-11-14T15:00:00-07:00"},
+    {"id": "1.0.0-rc2-2", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1.0.0-rc2-2/1.0.0-rc2-2.json", "time": "2019-04-21T21:04:24+00:00", "releaseTime": "2011-11-14T00:00:00+00:00"},
+    {"id": "1.0.0-rc2-1", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1.0.0-rc2-1/1.0.0-rc2-1.json", "time": "2019-11-21T22:37:45+00:00", "releaseTime": "2011-11-13T17:33:00+00:00"},
+    {"id": "1.0.0-rc1", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1.0.0-rc1/1.0.0-rc1.json", "time": "2013-08-06T07:33:30-07:00", "releaseTime": "2011-11-13T15:00:00-07:00"},
+    {"id": "b1.9-pre6", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_9-pre6/b1_9-pre6.json", "time": "2011-11-11T00:00:00+00:00", "releaseTime": "2011-11-11T00:00:00+00:00"},
+    {"id": "b1.9-pre5", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_9-pre5/b1_9-pre5.json", "time": "2011-10-27T00:00:00+00:00", "releaseTime": "2011-10-27T00:00:00+00:00"},
+    {"id": "b1.9-pre4", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_9-pre4/b1_9-pre4.json", "time": "2011-10-13T00:00:00+00:00", "releaseTime": "2011-10-13T00:00:00+00:00"},
+    {"id": "b1.9-pre3", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_9-pre3/b1_9-pre3.json", "time": "2011-10-06T00:00:00+00:00", "releaseTime": "2011-10-06T00:00:00+00:00"},
+    {"id": "b1.9-pre2", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_9-pre2/b1_9-pre2.json", "time": "2011-09-29T00:00:00+00:00", "releaseTime": "2011-09-29T00:00:00+00:00"},
+    {"id": "b1.9-pre1", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_9-pre1/b1_9-pre1.json", "time": "2011-09-22T00:00:00+00:00", "releaseTime": "2011-09-22T00:00:00+00:00"},
+    {"id": "b1.8-pre2", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_8-pre2/b1_8-pre2.json", "time": "2011-09-13T00:00:00+00:00", "releaseTime": "2011-09-13T00:00:00+00:00"},
+    {"id": "b1.8-pre1-2", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_8-pre1/b1_8-pre1.json", "time": "2011-09-09T00:00:00+00:00", "releaseTime": "2011-09-09T00:00:00+00:00"},
+    {"id": "b1.8-pre1-1", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1.8-pre1-1/b1.8-pre1-1.json", "time": "2011-09-09T00:00:00+00:00", "releaseTime": "2011-09-09T00:00:00+00:00"},
+    {"id": "b1.6-tb3", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1.6-tb3/b1.6-tb3.json", "time": "2013-08-06T07:33:30-07:00", "releaseTime": "2011-05-24T15:00:00-07:00"},
+    {"id": "b1.1-1", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1.1-1/b1.1-1.json", "time": "2016-02-02T15:37:47+00:00", "releaseTime": "2010-12-21T22:00:00+00:00"},
+    {"id": "b1.1-2", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1.1-2/b1.1-2.json", "time": "2016-02-02T15:37:47+00:00", "releaseTime": "2010-12-21T22:00:00+00:00"},
+    {"id": "a1.1.1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.1.1/a1.1.1.json", "time": "2010-09-18T19:29:56+02:00", "releaseTime": "2010-09-18T19:29:56+02:00"},
+    {"id": "a1.1.0-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.1.0-2/a1.1.0-2.json", "time": "2016-02-02T15:37:47+00:00", "releaseTime": "2010-09-12T22:00:00+00:00"},
+    {"id": "a1.1.0-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.1.0-1/a1.1.0-1.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-09-10T00:00:00+00:00"},
+    {"id": "a1.0.17_03", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.17_03/a1.0.17_03.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-08-23T00:00:00+00:00"},
+    {"id": "a1.0.16_02", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.16_02/a1.0.16_02.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-08-13T00:00:00+00:00"},
+    {"id": "a1.0.16_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.16_01/a1.0.16_01.json", "time": "2013-08-06T04:00:00-07:00", "releaseTime": "2010-08-12T15:00:00-07:00"},
+    {"id": "a1.0.14-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.14-1/a1.0.14-1.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-30T00:00:00+00:00"},
+    {"id": "a1.0.14-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.14-2/a1.0.14-2.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-30T00:00:00+00:00"},
+    {"id": "a1.0.13_01-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.13_01-1/a1.0.13_01-1.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-29T00:00:00+00:00"},
+    {"id": "a1.0.13_01-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.13_01-2/a1.0.13_01-2.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-29T00:00:00+00:00"},
+    {"id": "a1.0.13", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.13/a1.0.13.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-28T00:00:00+00:00"},
+    {"id": "a1.0.12", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.12/a1.0.12.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-26T00:00:00+00:00"},
+    {"id": "a1.0.10", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.10/a1.0.10.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-22T00:00:00+00:00"},
+    {"id": "a1.0.9", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.9/a1.0.9.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-21T00:00:00+00:00"},
+    {"id": "a1.0.7", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.7/a1.0.7.json", "time": "2013-08-06T04:00:00-07:00", "releaseTime": "2010-07-19T15:00:00-07:00"},
+    {"id": "a1.0.8_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.8_01/a1.0.8_01.json", "time": "2013-08-06T04:00:00-07:00", "releaseTime": "2010-07-19T15:00:00-07:00"},
+    {"id": "a1.0.6_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.6_01/a1.0.6_01.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-17T00:00:00+00:00"},
+    {"id": "a1.0.6_03", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.6_03/a1.0.6_03.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-17T00:00:00+00:00"},
+    {"id": "a1.0.6", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.6/a1.0.6.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-16T00:00:00+00:00"},
+    {"id": "a1.0.5", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.5/a1.0.5.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-13T00:00:00+00:00"},
+    {"id": "a1.0.3", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.3/a1.0.3.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-07T00:00:00+00:00"},
+    {"id": "a1.0.2_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.2_01/a1.0.2_01.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-06T00:00:00+00:00"},
+    {"id": "a1.0.2_02", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.2_02/a1.0.2_02.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-06T00:00:00+00:00"},
+    {"id": "a1.0.1_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.1_01/a1.0.1_01.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-03T22:00:00+00:00"},
+    {"id": "inf-20100630-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100630-1/inf-20100630-1.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-30T00:00:00-00:00"},
+    {"id": "inf-20100630-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100630-2/inf-20100630-2.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-30T00:00:00-00:00"},
+    {"id": "inf-20100629", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100629/inf-20100629.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-29T00:00:00-00:00"},
+    {"id": "inf-20100627", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100627/inf-20100627.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-27T15:00:00-07:00"},
+    {"id": "inf-20100625-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100625-1/inf-20100625-1.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-25T00:00:00-00:00"},
+    {"id": "inf-20100625-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100625-2/inf-20100625-2.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-25T00:00:00-00:00"},
+    {"id": "inf-20100624", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100624/inf-20100624.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-24T00:00:00-00:00"},
+    {"id": "inf-20100617-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100617-2/inf-20100617-2.json", "time": "2010-06-17T00:00:00-00:00", "releaseTime": "2010-06-17T00:00:00-00:00"},
+    {"id": "inf-20100617-3", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100617-3/inf-20100617-3.json", "time": "2010-06-17T00:00:00-00:00", "releaseTime": "2010-06-17T00:00:00-00:00"},
+    {"id": "inf-20100615", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100615/inf-20100615.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-15T00:00:00-00:00"},
+    {"id": "inf-20100611", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100611/inf-20100611.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-11T00:00:00-00:00"},
+    {"id": "inf-20100608", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100608/inf-20100608.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-08T15:00:00-07:00"},
+    {"id": "inf-20100607", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100607/inf-20100607.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-07T00:00:00-00:00"},
+    {"id": "inf-20100420", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100420/inf-20100420.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-04-20T15:00:00-07:00"},
+    {"id": "inf-20100415", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100415/inf-20100415.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-04-15T15:00:00-07:00"},
+    {"id": "inf-20100414", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100414/inf-20100414.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-04-14T00:00:00-00:00"},
+    {"id": "inf-20100413", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100413/inf-20100413.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-04-13T00:00:00-00:00"},
+    {"id": "inf-20100330", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100330/inf-20100330.json", "time": "2010-03-30T00:00:00-00:00", "releaseTime": "2010-03-30T00:00:00-00:00"},
+    {"id": "inf-20100327", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100327/inf-20100327.json", "time": "2010-03-27T00:00:00-00:00", "releaseTime": "2010-03-27T00:00:00-00:00"},
+    {"id": "inf-20100325", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100325/inf-20100325.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-03-25T00:00:00-00:00"},
+    {"id": "inf-20100321", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100321/inf-20100321.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-03-21T00:00:00-00:00"},
+    {"id": "inf-20100320", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100320/inf-20100320.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-03-20T00:00:00-00:00"},
+    {"id": "inf-20100316", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100316/inf-20100316.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-03-16T00:00:00-00:00"},
+    {"id": "inf-20100313", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100313/inf-20100313.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-03-13T00:00:00-00:00"},
+    {"id": "in-20100223", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100223/in-20100223.json", "time": "2013-08-06T07:33:30-07:00", "releaseTime": "2010-02-23T15:00:00-07:00"},
+    {"id": "in-20100219", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100219/in-20100219.json", "time": "2017-10-24T00:00:00+00:00", "releaseTime": "2010-02-19T00:00:00+00:00"},
+    {"id": "in-20100218", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100218/in-20100218.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-18T00:00:00+00:00"},
+    {"id": "in-20100214-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100214-2/in-20100214-2.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-14T00:00:01+00:00"},
+    {"id": "in-20100214-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100214-1/in-20100214-1.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-14T00:00:00+00:00"},
+    {"id": "in-20100212-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100212-2/in-20100212-2.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-12T00:00:01+00:00"},
+    {"id": "in-20100212-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100212-1/in-20100212-1.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-12T00:00:00+00:00"},
+    {"id": "in-20100207-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100207-2/in-20100207-2.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-07T00:00:01+00:00"},
+    {"id": "in-20100207-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100207-1/in-20100207-1.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-07T00:00:00+00:00"},
+    {"id": "in-20100206-2103", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100206-2103/in-20100206-2103.json", "time": "2020-10-14T18:51:02+00:00", "releaseTime": "2010-02-06T21:03:00+00:00"},
+    {"id": "in-20100203", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100203/in-20100203.json", "time": "2017-09-20T19:21:48+02:00", "releaseTime": "2010-02-03T00:30:00+02:00"},
+    {"id": "in-20100201-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100201-2/in-20100201-2.json", "time": "2017-09-20T19:21:48+02:00", "releaseTime": "2010-02-01T00:00:00+00:00"},
+    {"id": "in-20100201-3", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100201-3/in-20100201-3.json", "time": "2017-09-20T19:21:48+02:00", "releaseTime": "2010-02-01T00:00:00+00:00"},
+    {"id": "in-20100130", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100130/in-20100130.json", "time": "2013-08-06T07:33:30-07:00", "releaseTime": "2010-01-30T00:00:00-00:00"},
+    {"id": "in-20100129", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100129/in-20100129.json", "time": "2010-01-29T00:00:00-00:00", "releaseTime": "2010-01-29T00:00:00-00:00"},
+    {"id": "in-20100128-2304", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100128-2304/in-20100128-2304.json", "time": "2010-01-28T23:04:00-00:00", "releaseTime": "2010-01-28T23:04:00-00:00"},
+    {"id": "in-20100125-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100125-2/in-20100125-2.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-01-25T15:00:01-07:00"},
+    {"id": "in-20100125-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100125-1/in-20100125-1.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-01-25T15:00:00-07:00"},
+    {"id": "in-20100111-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100111-1/in-20100111-1.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-01-11T15:00:00-07:00"},
+    {"id": "in-20100105", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100105/in-20100105.json", "time": "2013-08-06T07:33:30-07:00", "releaseTime": "2010-01-05T00:00:00-00:00"},
+    {"id": "in-20091231-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20091231-2/in-20091231-2.json", "time": "2021-06-26T22:48:05+00:00", "releaseTime": "2009-12-31T23:55:00+00:00"},
+    {"id": "in-20091223-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20091223-2/in-20091223-2.json", "time": "2009-12-23T00:00:00-00:00", "releaseTime": "2009-12-23T00:00:00-00:00"},
+    {"id": "c0.30-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.30-2/c0.30-2.json", "time": "2013-08-06T17:33:30+03:00", "releaseTime": "2009-11-10T00:00:01+02:00"},
+    {"id": "c0.30-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.30-1/c0.30-1.json", "time": "2013-08-06T17:33:30+03:00", "releaseTime": "2009-11-10T00:00:00+02:00"},
+    {"id": "c0.29_02", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.29_02/c0.29_02.json", "time": "2017-09-20T03:05:00+01:00", "releaseTime": "2009-10-30T00:00:00+00:00"},
+    {"id": "c0.29_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.29_01/c0.29_01.json", "time": "2021-06-26T12:16:50+00:00", "releaseTime": "2009-10-29T00:00:00+00:00"},
+    {"id": "c0.28_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.28_01/c0.28_01.json", "time": "2017-09-20T03:19:48+01:00", "releaseTime": "2009-10-27T00:00:00+00:00"},
+    {"id": "c0.27_st", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.27_st/c0.27_st.json", "time": "2017-09-17T16:33:30+02:00", "releaseTime": "2009-10-24T24:00:00+00:00"},
+    {"id": "c0.25_05_st", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.25_05_st/c0.25_05_st.json", "time": "2009-09-03T00:00:00+00:00", "releaseTime": "2009-09-03T00:00:00+00:00"},
+    {"id": "c0.24_st_03", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.24_st_03/c0.24_st_03.json", "time": "2009-09-01T00:00:00+00:00", "releaseTime": "2009-09-01T00:00:00+00:00"},
+    {"id": "c0.0.22a_05", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.22a_05/c0.0.22a_05.json", "time": "2009-06-30T00:00:00+00:00", "releaseTime": "2009-06-30T00:00:00+00:00"},
+    {"id": "c0.0.21a", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.21a/c0.0.21a.json", "time": "2017-09-20T03:12:34+01:00", "releaseTime": "2009-06-22T23:11:11+01:00"},
+    {"id": "c0.0.20a_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.20a_01/c0.0.20a_01.json", "time": "2020-10-14T23:51:10+00:00", "releaseTime": "2009-06-20T23:10:00+00:00"},
+    {"id": "c0.0.19a_06-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.19a_06-2/c0.0.19a_06-2.json", "time": "2020-10-17T19:47:58-00:00", "releaseTime": "2009-06-20T01:37:00-00:00"},
+    {"id": "c0.0.18a_02", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.18a_02/c0.0.18a_02.json", "time": "2020-10-15T18:45:15-00:00", "releaseTime": "2009-06-14T10:53:00-00:00"},
+    {"id": "c0.0.17a", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.17a/c0.0.17a.json", "time": "2021-04-17T21:49:29-00:00", "releaseTime": "2009-06-10T20:14:00-00:00"},
+    {"id": "c0.0.16a_02", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.16a_02/c0.0.16a_02.json", "time": "2019-10-20T21:42:41+00:00", "releaseTime": "2009-06-08T12:47:00+00:00"},
+    {"id": "c0.0.23a_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.23a_01/c0.0.23a_01.json", "time": "2013-08-06T15:33:30+01:00", "releaseTime": "2009-05-28T23:11:11+01:00"},
+    {"id": "c0.0.14a_08", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.14a_08/c0.0.14a_08.json", "time": "2019-10-20T21:41:28+00:00", "releaseTime": "2009-05-28T22:37:00+00:00"},
+    {"id": "c0.0.12a_03", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.12a_03/c0.0.12a_03.json", "time": "2021-04-26T19:18:00-00:00", "releaseTime": "2009-05-20T00:00:00-00:00"}
+  ]
+}
diff --git a/crates/core/src/game/error.rs b/crates/core/src/game/error.rs
new file mode 100644
index 0000000..f2601e1
--- /dev/null
+++ b/crates/core/src/game/error.rs
@@ -0,0 +1,124 @@
+//! `core::game` 的错误类型。
+//!
+//! 采用手写 `Display`/`Error` 而不用 `thiserror`:core 的依赖面越窄越好(D3 模块纪律),
+//! 且这里的变体数量固定、不需要派生宏的收益。
+
+use std::fmt;
+use std::path::PathBuf;
+
+/// `core::game` 的失败集合。
+#[derive(Debug)]
+pub enum GameError {
+    /// JSON 解析/序列化失败(版本清单、版本 JSON、profiles)。
+    Json {
+        /// 出错的文档种类,用于给用户看上下文(如 `version_manifest_v2`)。
+        context: String,
+        /// 底层 serde_json 错误。
+        source: serde_json::Error,
+    },
+    /// 清单或版本 JSON 缺少必需的 `id`。
+    MissingVersionId,
+    /// `inheritsFrom` 指向的父版本在本仓库中不存在。
+    MissingParent {
+        /// 缺失的父版本 id。
+        parent: String,
+        /// 发起继承的子版本 id。
+        required_by: String,
+    },
+    /// `inheritsFrom` 成环。
+    CircularInheritance {
+        /// 从根到环的 id 链(首尾为同一个 id)。
+        chain: Vec,
+    },
+    /// Maven 坐标(`group:artifact:version[:classifier][@ext]`)不合法。
+    InvalidCoordinate {
+        /// 原始字符串。
+        raw: String,
+        /// 不合法的原因。
+        reason: &'static str,
+    },
+    /// 资源对象哈希不合法(长度不足以做两级分片)。
+    InvalidAssetHash {
+        /// 原始哈希。
+        hash: String,
+    },
+    /// 文件系统错误。
+    Io {
+        /// 出错的路径。
+        path: PathBuf,
+        /// 底层 IO 错误。
+        source: std::io::Error,
+    },
+    /// 已有草稿持有写权,同一仓库同时只允许一个写会话。
+    DraftBusy,
+    /// 实例隔离档位是 `custom`,但没有可用的自定义目录(不猜默认值)。
+    CustomIsolationWithoutDir {
+        /// 实例 id(可能为空:调用方没给实例上下文)。
+        instance: String,
+    },
+}
+
+impl fmt::Display for GameError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::Json { context, source } => write!(f, "解析 {context} 失败: {source}"),
+            Self::MissingVersionId => write!(f, "版本 JSON 缺少 id"),
+            Self::MissingParent {
+                parent,
+                required_by,
+            } => write!(
+                f,
+                "版本 {required_by} 继承的父版本 {parent} 不存在(缺失前置版本)"
+            ),
+            Self::CircularInheritance { chain } => {
+                write!(f, "版本继承成环: {}", chain.join(" -> "))
+            }
+            Self::InvalidCoordinate { raw, reason } => {
+                write!(f, "Maven 坐标不合法({reason}): {raw}")
+            }
+            Self::InvalidAssetHash { hash } => {
+                write!(f, "资源哈希不合法(短于两个字符): {hash}")
+            }
+            Self::Io { path, source } => write!(f, "IO 失败 {}: {source}", path.display()),
+            Self::DraftBusy => write!(f, "已有草稿持有写权(单写者约束)"),
+            Self::CustomIsolationWithoutDir { instance } => {
+                if instance.is_empty() {
+                    write!(f, "隔离档位为 custom,但没有指定自定义运行目录")
+                } else {
+                    write!(
+                        f,
+                        "实例 {instance} 的隔离档位为 custom,但没有指定自定义运行目录"
+                    )
+                }
+            }
+        }
+    }
+}
+
+impl std::error::Error for GameError {
+    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+        match self {
+            Self::Json { source, .. } => Some(source),
+            Self::Io { source, .. } => Some(source),
+            _ => None,
+        }
+    }
+}
+
+impl GameError {
+    /// 包装 serde_json 错误并带上文档上下文。
+    pub fn json(context: impl Into, source: serde_json::Error) -> Self {
+        Self::Json {
+            context: context.into(),
+            source,
+        }
+    }
+
+    /// 包装 IO 错误并带上路径。
+    pub fn io(path: impl Into, source: std::io::Error) -> Self {
+        Self::Io {
+            path: path.into(),
+            source,
+        }
+    }
+}
diff --git a/crates/core/src/game/inherit.rs b/crates/core/src/game/inherit.rs
new file mode 100644
index 0000000..2760ac4
--- /dev/null
+++ b/crates/core/src/game/inherit.rs
@@ -0,0 +1,198 @@
+//! `inheritsFrom` 继承解析、逐字段合并语义、patch 优先级排序。
+//!
+//! 依据:`DevDocs/analyse/03-version-download.md` §4.3(`resolve()`)、§4.4(merge 语义表)、
+//! §4.5(patch 生命周期)。
+//!
+//! 与 HMCL 的三处**有意差异**(都在 §4.3 的对照里):
+//! 1. **成环即报错**([`GameError::CircularInheritance`])而不是"断链后降级启动":
+//!    静默降级会让用户拿到"能启动但缺库"的实例,宁可明确告知清单坏了。
+//! 2. **缺失父版本报错**([`GameError::MissingParent`]),与 HMCL 的
+//!    `NoSuchGameInstanceException` 一致。
+//! 3. 不实现 `toPatch()` 的"把已解析父层固化进 patches"优化——P0 实例数量小,
+//!    每次启动重算继承链(毫秒级);等有性能证据再加缓存。
+
+use std::collections::BTreeMap;
+
+use super::arguments::Arguments;
+use super::error::GameError;
+use super::manifest::VersionJson;
+
+/// 父版本来源(仓库快照或测试里的内存表)。
+pub trait VersionSource {
+    /// 按 id 取一份版本 JSON;不存在返回 `None`。
+    ///
+    /// 实现方应自行处理 IO 错误——把失败转成 `None` 会让"父版本缺失"和"读盘失败"
+    /// 混为一谈,因此读盘失败的实现应当在调用 [`resolve`] 前就把它暴露给用户。
+    fn load(&self, id: &str) -> Option;
+}
+
+impl VersionSource for BTreeMap {
+    fn load(&self, id: &str) -> Option {
+        self.get(id).cloned()
+    }
+}
+
+impl VersionSource for F
+where
+    F: Fn(&str) -> Option,
+{
+    fn load(&self, id: &str) -> Option {
+        self(id)
+    }
+}
+
+/// 解析继承链,返回可交给 launch/download 的扁平清单。
+///
+/// 返回的清单保证:`inherits_from == None`、`patches == None`、`jar` 已回退为实例 id、
+/// `root == Some(true)`(见 [`VersionJson::is_resolved`])。
+///
+/// # Errors
+///
+/// - 清单缺 `id` → [`GameError::MissingVersionId`]
+/// - 父版本缺失 → [`GameError::MissingParent`]
+/// - 继承成环 → [`GameError::CircularInheritance`]
+pub fn resolve(
+    source: &S,
+    manifest: &VersionJson,
+) -> Result {
+    let root_id = manifest.require_id()?.to_owned();
+
+    // 1. 自根向祖先收集层(迭代而非递归:深层整合包的链可以很长)。
+    let mut chain = vec![root_id.clone()];
+    let mut layers = vec![manifest.clone()];
+    while let Some(parent_id) = layers.last().and_then(|l| l.inherits_from.clone()) {
+        if chain.iter().any(|id| id == &parent_id) {
+            chain.push(parent_id);
+            return Err(GameError::CircularInheritance { chain });
+        }
+        let required_by = layers
+            .last()
+            .and_then(|l| l.id.clone())
+            .unwrap_or_else(|| root_id.clone());
+        let parent = source
+            .load(&parent_id)
+            .ok_or_else(|| GameError::MissingParent {
+                parent: parent_id.clone(),
+                required_by,
+            })?;
+        chain.push(parent_id);
+        layers.push(parent);
+    }
+
+    // 2. 自最祖先向根折叠:每一层先与父层合并,再合并它自己的 patches。
+    let mut folded: Option = None;
+    for layer in layers.into_iter().rev() {
+        let merged = match &folded {
+            Some(parent) => merge(&layer, parent),
+            None => merge(&layer, &VersionJson::default()),
+        };
+        folded = Some(apply_patches(
+            merged,
+            &layer.patches.clone().unwrap_or_default(),
+        ));
+    }
+
+    // 3. 收尾:清继承标记、jar 兜底、补 id。
+    let mut resolved = folded.unwrap_or_default();
+    resolved.inherits_from = None;
+    resolved.patches = None;
+    resolved.priority = None;
+    resolved.root = Some(true);
+    if resolved.id.is_none() {
+        resolved.id = Some(root_id.clone());
+    }
+    if resolved.jar.is_none() {
+        resolved.jar = Some(root_id);
+    }
+    Ok(resolved)
+}
+
+/// 按 `priority` 升序排好的 patch 列表(`None` 视作最小值,稳定排序保持插入顺序)。
+pub fn sorted_patches(patches: &[VersionJson]) -> Vec<&VersionJson> {
+    let mut sorted: Vec<&VersionJson> = patches.iter().collect();
+    sorted.sort_by_key(|p| p.priority.unwrap_or(i32::MIN));
+    sorted
+}
+
+/// 把 patch 层按优先级依次叠加到基底清单上(先原版、后 loader:`0 < 30000`)。
+pub fn apply_patches(base: VersionJson, patches: &[VersionJson]) -> VersionJson {
+    let mut out = base;
+    for patch in sorted_patches(patches) {
+        out = merge(patch, &out);
+    }
+    out
+}
+
+/// 合并两层清单:**子(或后合并的层)覆盖父**,语义逐字段照 analyse/03 §4.4。
+///
+/// 非直觉的两点,测试里各有一条守门:
+/// - `downloads`/`logging` **整体覆盖**:子层只写 `server` 会让父层的 `client` 消失;
+/// - `compliance_level` **只取子**:父层的值被丢弃(缺则是 `None`)。
+///
+/// 这两点**与 analyse/03 §8.3 第 15 条建议相左**(该条主张逐字段/逐 key 合并)。
+/// 保留现状的理由是生态兼容:现实 modpack/loader 的版本 JSON 按 HMCL/原版语义编写,
+/// 逐 key 合并会让"子层想整份换掉 downloads"的场景语义漂移。经 tower 裁决 2026-10-29 维持。
+pub fn merge(child: &VersionJson, parent: &VersionJson) -> VersionJson {
+    let mut extra = parent.extra.clone();
+    extra.extend(child.extra.iter().map(|(k, v)| (k.clone(), v.clone())));
+
+    VersionJson {
+        id: child.id.clone().or_else(|| parent.id.clone()),
+        name: child.name.clone().or_else(|| parent.name.clone()),
+        // 解析后不再带继承。
+        inherits_from: None,
+        jar: child.jar.clone().or_else(|| parent.jar.clone()),
+        main_class: child
+            .main_class
+            .clone()
+            .or_else(|| parent.main_class.clone()),
+        minecraft_arguments: child
+            .minecraft_arguments
+            .clone()
+            .or_else(|| parent.minecraft_arguments.clone()),
+        arguments: Arguments::merge(parent.arguments.as_ref(), child.arguments.as_ref()),
+        assets: child.assets.clone().or_else(|| parent.assets.clone()),
+        asset_index: child
+            .asset_index
+            .clone()
+            .or_else(|| parent.asset_index.clone()),
+        compliance_level: child.compliance_level,
+        java_version: child
+            .java_version
+            .clone()
+            .or_else(|| parent.java_version.clone()),
+        libraries: match (&parent.libraries, &child.libraries) {
+            (None, None) => None,
+            (Some(parent), None) => Some(parent.clone()),
+            (None, Some(child)) => Some(child.clone()),
+            // 父在前、子在后追加(重复项由使用方按需去重)。
+            (Some(parent), Some(child)) => {
+                Some(parent.iter().chain(child.iter()).cloned().collect())
+            }
+        },
+        // 整体覆盖,不逐 key 合并。
+        downloads: child.downloads.clone().or_else(|| parent.downloads.clone()),
+        logging: child.logging.clone().or_else(|| parent.logging.clone()),
+        version_type: child.version_type.or(parent.version_type),
+        time: child.time.clone().or_else(|| parent.time.clone()),
+        release_time: child
+            .release_time
+            .clone()
+            .or_else(|| parent.release_time.clone()),
+        minimum_launcher_version: match (
+            parent.minimum_launcher_version,
+            child.minimum_launcher_version,
+        ) {
+            (Some(a), Some(b)) => Some(a.max(b)),
+            (a, None) => a,
+            (None, b) => b,
+        },
+        // 合并结果一律是"根清单"。
+        root: Some(true),
+        hidden: child.hidden,
+        // patch 生命周期由 resolve/apply_patches 管,合并结果不带 patches。
+        patches: None,
+        priority: None,
+        extra,
+    }
+}
diff --git a/crates/core/src/game/install/assets.rs b/crates/core/src/game/install/assets.rs
new file mode 100644
index 0000000..fefcd83
--- /dev/null
+++ b/crates/core/src/game/install/assets.rs
@@ -0,0 +1,190 @@
+//! 资源索引(`assets/indexes/.json`)的模型与逐对象展开。
+//!
+//! 依据:`DevDocs/analyse/03-version-download.md` §6.5(索引下载)、§6.6(逐对象下载)。
+//!
+//! 对象是**内容寻址**的:路径 `assets/objects//`、URL
+//! `https://resources.download.minecraft.net//`,校验值就是哈希本身。
+//! 因此索引里不需要 `sha1` 字段——文件名即摘要,这也是"两个实例共用同一资源"零成本的原因。
+//!
+//! 本模块只做"索引 → 待下载对象"的纯计算,不碰网络;落盘路径一律问
+//! [`GameLayout`],不在本地拼字符串。
+
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+
+use super::super::error::GameError;
+use super::super::layout::GameLayout;
+use super::plan::{FileRole, PlanFile};
+use crate::download::ResourceKind;
+
+/// 资源对象的官方源基址。
+pub const ASSET_OBJECT_BASE: &str = "https://resources.download.minecraft.net";
+
+/// 一个资源对象。
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AssetObject {
+    /// 内容 SHA-1(同时是文件名)。
+    #[serde(default)]
+    pub hash: String,
+    /// 字节数。
+    #[serde(default)]
+    pub size: u64,
+}
+
+impl AssetObject {
+    /// 相对 `assets/objects/` 的路径 `xx/`。
+    #[must_use]
+    pub fn location(&self) -> String {
+        let shard = self.hash.get(0..2).unwrap_or_default();
+        format!("{shard}/{}", self.hash)
+    }
+
+    /// 官方下载地址(镜像由下载引擎按前缀规则展开)。
+    #[must_use]
+    pub fn url(&self) -> String {
+        format!("{ASSET_OBJECT_BASE}/{}", self.location())
+    }
+
+    /// 哈希是否够做两级分片(短于两个字符说明索引坏了)。
+    #[must_use]
+    pub fn is_well_formed(&self) -> bool {
+        self.hash.len() >= 2 && self.hash.bytes().all(|b| b.is_ascii_hexdigit())
+    }
+}
+
+/// 一份资源索引。
+///
+/// `virtual` / `map_to_resources` 只影响**启动期**的目录映射(`assets/virtual/`、
+/// `resources/`),不影响下载,这里保真存下但不消费。
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AssetIndex {
+    /// 对象表:键是资源名(`minecraft/sounds/...ogg`),值是内容描述。
+    #[serde(default)]
+    pub objects: BTreeMap,
+    /// 旧版:是否要在启动期铺到 `assets/virtual//`。
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub r#virtual: Option,
+    /// 旧版:是否要映射到存档目录旁的 `resources/`。
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub map_to_resources: Option,
+}
+
+impl AssetIndex {
+    /// 解析索引 JSON。
+    ///
+    /// # Errors
+    ///
+    /// JSON 非法(例如镜像返回了 HTML 错误页)时返回 [`GameError::Json`]。
+    pub fn from_json_slice(bytes: &[u8]) -> Result {
+        serde_json::from_slice(bytes).map_err(|e| GameError::json("asset_index", e))
+    }
+
+    /// 对象数量。
+    #[must_use]
+    pub fn len(&self) -> usize {
+        self.objects.len()
+    }
+
+    /// 是否没有对象。
+    #[must_use]
+    pub fn is_empty(&self) -> bool {
+        self.objects.is_empty()
+    }
+
+    /// 全部对象的总字节数(进度预算与 `--dry-run` 总大小用)。
+    #[must_use]
+    pub fn total_bytes(&self) -> u64 {
+        self.objects.values().map(|object| object.size).sum()
+    }
+}
+
+/// 把索引展开成待下载文件清单(按资源名排序,保证计划可复现)。
+///
+/// # Errors
+///
+/// 某个对象哈希不合法(短于两字符 / 非十六进制)时返回 [`GameError::InvalidAssetHash`]。
+pub fn plan(layout: &GameLayout, index: &AssetIndex) -> Result, GameError> {
+    let mut files = Vec::with_capacity(index.objects.len());
+    for (name, object) in &index.objects {
+        if !object.is_well_formed() {
+            return Err(GameError::InvalidAssetHash {
+                hash: object.hash.clone(),
+            });
+        }
+        files.push(PlanFile {
+            role: FileRole::Asset,
+            name: name.clone(),
+            url: object.url(),
+            sha1: Some(object.hash.clone()),
+            size: Some(object.size),
+            dest: layout.asset_object(&object.hash)?,
+            resource: ResourceKind::Asset,
+        });
+    }
+    Ok(files)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{plan, AssetIndex, AssetObject};
+    use crate::game::layout::GameLayout;
+
+    const HASH: &str = "0f9d6b1c0d5f4e3b2a1908070605040302010f0e";
+
+    #[test]
+    fn location_and_url_use_two_char_shard() {
+        let object = AssetObject {
+            hash: HASH.to_string(),
+            size: 7,
+        };
+        assert_eq!(object.location(), format!("0f/{HASH}"));
+        assert_eq!(
+            object.url(),
+            format!("https://resources.download.minecraft.net/0f/{HASH}")
+        );
+    }
+
+    #[test]
+    fn parses_index_and_plans_objects() {
+        let json = format!(
+            r#"{{"objects":{{"minecraft/lang/zh_cn.json":{{"hash":"{HASH}","size":42}}}}}}"#
+        );
+        let index = AssetIndex::from_json_slice(json.as_bytes()).expect("索引应可解析");
+        assert_eq!(index.len(), 1);
+        assert_eq!(index.total_bytes(), 42);
+
+        let layout = GameLayout::new("/games/.minecraft");
+        let files = plan(&layout, &index).expect("展开应成功");
+        assert_eq!(files.len(), 1);
+        let file = &files[0];
+        assert_eq!(file.sha1.as_deref(), Some(HASH));
+        assert_eq!(file.size, Some(42));
+        assert!(file.dest.ends_with(format!("objects/0f/{HASH}")));
+    }
+
+    #[test]
+    fn rejects_short_hash() {
+        let mut index = AssetIndex::default();
+        index.objects.insert(
+            "a".to_string(),
+            AssetObject {
+                hash: "a".to_string(),
+                size: 1,
+            },
+        );
+        let layout = GameLayout::new("/games/.minecraft");
+        assert!(plan(&layout, &index).is_err());
+    }
+
+    #[test]
+    fn keeps_asset_keys_as_names() {
+        let json = format!(
+            r#"{{"objects":{{"icons/icon_16x16.png":{{"hash":"{HASH}","size":1}},"lang/en_us.json":{{"hash":"{HASH}","size":2}}}}}}"#
+        );
+        let index = AssetIndex::from_json_slice(json.as_bytes()).expect("索引应可解析");
+        let files = plan(&GameLayout::new("/g"), &index).expect("展开应成功");
+        let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
+        assert_eq!(names, vec!["icons/icon_16x16.png", "lang/en_us.json"]);
+    }
+}
diff --git a/crates/core/src/game/install/execute.rs b/crates/core/src/game/install/execute.rs
new file mode 100644
index 0000000..ad8586c
--- /dev/null
+++ b/crates/core/src/game/install/execute.rs
@@ -0,0 +1,361 @@
+//! 落盘执行:跳过判定 → 并发下载(引擎负责镜像/退避/续传/校验)→ 原子写版本 JSON。
+//!
+//! 依据:`DevDocs/analyse/03-version-download.md` §6.9(完整性修复路径的"存在即信"分级)、
+//! `DevDocs/PlanDoc/05-download-and-mirrors.md` §2(按资源类型分层)、§5(静态展开并行度)。
+//!
+//! # 跳过判定(幂等的地基)
+//!
+//! 判据分三级,与 analyse/03 §6.9 的思路一致:
+//! - 有 sha1 → **重算摘要比对**(唯一可信判据;代价是读一遍盘);
+//! - 无 sha1 有大小 → 只比大小(清单没给摘要时退一步);
+//! - 两者都没有 → 存在即信(HMCL 同类做法,留给后续"一键修复"补校验)。
+//!
+//! 跳过不写盘、不进网络,因此"重复 install 同版本"第二次几乎零成本。
+//!
+//! # 失败策略
+//!
+//! **不整体回滚**:已下好的共享层文件(libraries/assets)留在原地复用,失败信息逐文件结构化
+//! 返回,由调用方决定是重试、还是只提示"缺哪几个文件"。这与 analyse/03 §6.3 的"宁可给出
+//! 可修复的骨架"一致,但**版本清单与实例登记只在全量成功后才写**,不会出现半实例。
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use futures_util::stream::{self, StreamExt};
+
+use super::plan::{FileRole, PlanFile};
+use super::InstallPlan;
+use crate::download::verify;
+use crate::download::{DownloadEngine, DownloadError, DownloadErrorKind, HashAlgo};
+use crate::platform::atomic::write_atomic;
+
+/// 单个文件的结局。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FileStatus {
+    /// 本次真的下载(或从缓存直取)并落盘。
+    Downloaded,
+    /// 已存在且校验通过,跳过。
+    Skipped,
+    /// 失败(原因在 [`FileOutcome::failure`])。
+    Failed,
+}
+
+impl FileStatus {
+    /// 稳定字符串 id。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Downloaded => "downloaded",
+            Self::Skipped => "skipped",
+            Self::Failed => "failed",
+        }
+    }
+}
+
+/// 一次失败的分类信息(调用方据此给 `install_network` / `install_integrity` 等稳定码)。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct FileFailure {
+    /// 下载错误分类。
+    pub kind: DownloadErrorKind,
+    /// 人读说明。
+    pub message: String,
+}
+
+/// 一个文件的执行结果。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct FileOutcome {
+    /// 角色。
+    pub role: FileRole,
+    /// 人读标识。
+    pub name: String,
+    /// 落盘路径。
+    pub dest: std::path::PathBuf,
+    /// 结局。
+    pub status: FileStatus,
+    /// 实际字节数(跳过时为盘上大小)。
+    pub bytes: u64,
+    /// 成功的源标识(`bmclapi` / `official` / `cache` / `metadata`)。
+    pub source: Option,
+    /// 失败详情。
+    pub failure: Option,
+}
+
+impl FileOutcome {
+    /// 是否失败。
+    #[must_use]
+    pub fn failed(&self) -> bool {
+        self.status == FileStatus::Failed
+    }
+}
+
+/// 进度回调:每完成一个文件调用一次(可跨线程,由执行器持有 `Arc`)。
+///
+/// CLI 用它往 stderr 打实时进度;GUI 侧直接消费 `ProgressEvent` 事件流即可,不必用这个。
+pub type ProgressSink = Arc;
+
+/// 一次安装的执行结果。
+#[derive(Debug, Clone)]
+pub struct InstallReport {
+    /// 逐文件结果(顺序不保证,按角色/大小汇总即可)。
+    pub files: Vec,
+    /// 版本清单落盘路径;`None` = 有文件失败,故意没写(见 [`execute`] 的说明)。
+    pub version_json: Option,
+    /// 计划阶段带来的告警(缺件降级说明)。
+    pub warnings: Vec,
+}
+
+impl InstallReport {
+    /// 真正下载的文件数。
+    #[must_use]
+    pub fn downloaded(&self) -> usize {
+        self.files
+            .iter()
+            .filter(|file| file.status == FileStatus::Downloaded)
+            .count()
+    }
+
+    /// 跳过(已存在且校验通过)的文件数。
+    #[must_use]
+    pub fn skipped(&self) -> usize {
+        self.files
+            .iter()
+            .filter(|file| file.status == FileStatus::Skipped)
+            .count()
+    }
+
+    /// 失败的文件数。
+    #[must_use]
+    pub fn failed(&self) -> usize {
+        self.files.iter().filter(|file| file.failed()).count()
+    }
+
+    /// 本次写入的字节数(只算真正下载的)。
+    #[must_use]
+    pub fn bytes(&self) -> u64 {
+        self.files
+            .iter()
+            .filter(|file| file.status == FileStatus::Downloaded)
+            .map(|file| file.bytes)
+            .sum()
+    }
+
+    /// 是否全部就绪(无失败)。
+    #[must_use]
+    pub fn is_complete(&self) -> bool {
+        self.failed() == 0
+    }
+
+    /// 失败文件列表(最多取前 `limit` 条,给 CLI 拼提示用)。
+    #[must_use]
+    pub fn failures(&self, limit: usize) -> Vec<&FileOutcome> {
+        self.files
+            .iter()
+            .filter(|file| file.failed())
+            .take(limit)
+            .collect()
+    }
+
+    /// 失败里最"严重"的大类:有校验不符就先报它,其余按网络/IO 归。
+    #[must_use]
+    pub fn failure_kind(&self) -> Option {
+        let mut fallback = None;
+        for file in self.files.iter().filter(|file| file.failed()) {
+            let Some(failure) = &file.failure else {
+                continue;
+            };
+            if failure.kind == DownloadErrorKind::Integrity {
+                return Some(DownloadErrorKind::Integrity);
+            }
+            fallback.get_or_insert(failure.kind);
+        }
+        fallback
+    }
+}
+
+/// 执行安装计划。
+///
+/// 具体步骤:
+/// 1. 规划期已取到的元数据(资源索引,见 [`InstallPlan::prefetch`])先原子落盘,零网络;
+/// 2. 其余文件并发下载(`concurrency` 上限,引擎内部另有信号量),每个文件带进度回调;
+/// 3. **全量就绪**才写 `versions//.json`:这份清单是"已安装"的判据
+///    ([`GameLayout::is_version_installed`]),半成品写进去会把缺件的版本报成已安装。
+///    共享层里已下好的文件留着复用,重跑 `ull install` 会补齐剩下的。
+///
+/// # Errors
+///
+/// 版本 JSON 落盘失败时返回 IO 错误;文件下载失败**不**返回错误,而是进
+/// [`InstallReport`](调用方按 [`InstallReport::is_complete`] 决策)。
+pub async fn execute(
+    engine: &DownloadEngine,
+    plan: &InstallPlan,
+    concurrency: usize,
+    sink: Option,
+) -> Result {
+    let mut outcomes: Vec = Vec::with_capacity(plan.files.len());
+    let mut prefetched: Vec<&PlanFile> = Vec::new();
+
+    for payload in &plan.prefetched {
+        let Some(file) = plan.files.iter().find(|file| file.dest == payload.dest) else {
+            continue;
+        };
+        // 盘上已是同一份内容就不再重写(规划期为了枚举对象必须联网取索引,
+        // 但那不等于"需要重新落盘"——重装的"下载 0 个"要名副其实)。
+        if let Some(bytes) = current_bytes(file) {
+            let outcome = FileOutcome {
+                role: file.role,
+                name: file.name.clone(),
+                dest: file.dest.clone(),
+                status: FileStatus::Skipped,
+                bytes,
+                source: None,
+                failure: None,
+            };
+            if let Some(sink) = &sink {
+                sink(&outcome);
+            }
+            outcomes.push(outcome);
+            prefetched.push(file);
+            continue;
+        }
+        let written = write_atomic(&payload.dest, &payload.bytes).map_err(|error| {
+            super::InstallError::new(super::InstallErrorKind::Io, error.to_string())
+        });
+        let outcome = match written {
+            Ok(()) => FileOutcome {
+                role: file.role,
+                name: file.name.clone(),
+                dest: file.dest.clone(),
+                status: FileStatus::Downloaded,
+                bytes: payload.bytes.len() as u64,
+                source: Some("metadata".to_string()),
+                failure: None,
+            },
+            Err(error) => FileOutcome {
+                role: file.role,
+                name: file.name.clone(),
+                dest: file.dest.clone(),
+                status: FileStatus::Failed,
+                bytes: 0,
+                source: None,
+                failure: Some(FileFailure {
+                    kind: DownloadErrorKind::Io,
+                    message: error.to_string(),
+                }),
+            },
+        };
+        if let Some(sink) = &sink {
+            sink(&outcome);
+        }
+        outcomes.push(outcome);
+        prefetched.push(file);
+    }
+
+    let pending: Vec<&PlanFile> = plan
+        .files
+        .iter()
+        .filter(|file| !prefetched.iter().any(|done| done.dest == file.dest))
+        .collect();
+
+    let downloaded: Vec = stream::iter(pending)
+        .map(|file| {
+            let sink = sink.clone();
+            async move {
+                let outcome = install_one(engine, file).await;
+                if let Some(sink) = &sink {
+                    sink(&outcome);
+                }
+                outcome
+            }
+        })
+        .buffer_unordered(concurrency.max(1))
+        .collect()
+        .await;
+    outcomes.extend(downloaded);
+
+    // 版本清单只在**全量就绪**时落盘:`is_version_installed()` 以清单存在为判据,
+    // 半成品写进去会让 `instances list` 把"缺 jar 的版本"报成已安装。
+    let complete = outcomes.iter().all(|outcome| !outcome.failed());
+    let version_json = if complete {
+        write_version_json(&plan.version_json_path, plan)?;
+        Some(plan.version_json_path.clone())
+    } else {
+        None
+    };
+
+    Ok(InstallReport {
+        files: outcomes,
+        version_json,
+        warnings: plan.warnings.clone(),
+    })
+}
+
+/// 单文件:先判"已就绪",否则走引擎下载。
+async fn install_one(engine: &DownloadEngine, file: &PlanFile) -> FileOutcome {
+    if let Some(bytes) = current_bytes(file) {
+        return FileOutcome {
+            role: file.role,
+            name: file.name.clone(),
+            dest: file.dest.clone(),
+            status: FileStatus::Skipped,
+            bytes,
+            source: None,
+            failure: None,
+        };
+    }
+    match engine.download(file.request()).await {
+        Ok(outcome) => FileOutcome {
+            role: file.role,
+            name: file.name.clone(),
+            dest: file.dest.clone(),
+            status: FileStatus::Downloaded,
+            bytes: outcome.bytes,
+            source: Some(outcome.source),
+            failure: None,
+        },
+        Err(error) => FileOutcome {
+            role: file.role,
+            name: file.name.clone(),
+            dest: file.dest.clone(),
+            status: FileStatus::Failed,
+            bytes: 0,
+            source: None,
+            failure: Some(describe(&error)),
+        },
+    }
+}
+
+/// 文件已就绪则返回盘上大小(跳过判定,见模块文档的三级分级)。
+#[must_use]
+pub fn current_bytes(file: &PlanFile) -> Option {
+    if !file.dest.is_file() {
+        return None;
+    }
+    let size = std::fs::metadata(&file.dest).ok()?.len();
+    match file.sha1.as_deref().filter(|hex| !hex.is_empty()) {
+        Some(expected) => {
+            let actual = verify::digest_file(&file.dest, HashAlgo::Sha1).ok()?;
+            verify::matches(expected, &actual).then_some(size)
+        }
+        None => match file.size {
+            Some(expected) => (size == expected).then_some(size),
+            // 没有摘要也没有大小:存在即信(后续"一键修复"再补校验)。
+            None => Some(size),
+        },
+    }
+}
+
+/// 写解析后的版本清单(原子写)。
+fn write_version_json(path: &Path, plan: &InstallPlan) -> Result<(), super::InstallError> {
+    let text = plan.version_json.to_json_string().map_err(|error| {
+        super::InstallError::new(super::InstallErrorKind::Metadata, error.to_string())
+    })?;
+    write_atomic(path, text.as_bytes())
+        .map_err(|error| super::InstallError::new(super::InstallErrorKind::Io, error.to_string()))
+}
+
+fn describe(error: &DownloadError) -> FileFailure {
+    FileFailure {
+        kind: error.kind,
+        message: error.message.clone(),
+    }
+}
diff --git a/crates/core/src/game/install/instance.rs b/crates/core/src/game/install/instance.rs
new file mode 100644
index 0000000..8fa0aa3
--- /dev/null
+++ b/crates/core/src/game/install/instance.rs
@@ -0,0 +1,351 @@
+//! 实例登记:`config/profiles.json` + `config/instances/.json`(稀疏、幂等)。
+//!
+//! 依据:`DevDocs/PlanDoc/04-data-and-config.md` §4(D20 实例模型)、§2(`.ULL/config/`)、
+//! `DevDocs/PlanDoc/research/04-launcher-configs.md` §5(实例键草案)。
+//!
+//! # 为什么是两份文件
+//!
+//! - `profiles.json` = **档案列表**(UI 顺序、展示名、绑定版本),是"有哪些实例"的真相源;
+//! - `instances/.json` = **每实例设置**,稀疏:只写用户显式改过的键。
+//!
+//! §5 的关键结论:`isolation` 默认 `inherit` 且**不落盘**——这样改全局模式对存量实例立即生效,
+//! 直接避开 PCL2"默认值被固化成实例值"的缺陷。因此 [`InstanceConfig::new`] 的
+//! `isolation` 是 `None`(序列化时整个键消失);档位的**派生与切换检测**在
+//! [`crate::game::isolation`](M30 落地),本模块只管结构与落盘。
+//!
+//! # 幂等
+//!
+//! 同一版本重复 `install`:按 `version_id` + 展示名复用已有档案,返回 `created = false`,
+//! 不产生第二个实例、不改已有实例的 id(id 一旦写进档案就是稳定的)。
+
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+
+use serde::{Deserialize, Serialize};
+
+use super::super::error::GameError;
+use super::super::isolation::{IsolationMode, ModeChange, ModeChangeWarning};
+use super::super::profiles::{InstanceProfile, ProfileList};
+use crate::platform::atomic::write_atomic;
+use crate::platform::layout::LauncherLayout;
+
+/// 每实例设置文件的 schema 版本。
+///
+/// 与 `setting::instance_keys::instance_version` 的 minor 对应:D20 加了 `lastModeChange`,
+/// 老文件缺这个键即「没切换过」,不需要迁移步骤。
+pub const INSTANCE_CONFIG_VERSION: u32 = 1;
+
+/// 版本隔离档位(`isolation`)。
+///
+/// 缺省 `inherit` **不是**这个枚举的一个值,而是"键不存在"([`InstanceConfig::isolation`]
+/// 为 `None`)——这样稀疏序列化天然表达"跟随全局"。
+///
+/// 派生规则见 [`crate::game::isolation::derive_game_dir`]:`isolated` → `versions//`、
+/// `shared` → 游戏根 `.minecraft/`、`custom` → 用户指定的 `gameDir`。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Isolation {
+    /// 独立游戏目录(派生 `versions//`)。
+    #[serde(alias = "per_version")]
+    Isolated,
+    /// 与全局共用 `.minecraft`(HMCL 传统形态)。
+    Shared,
+    /// 用户指定的目录(此时 `gameDir` 必须有值)。
+    Custom,
+}
+
+impl Isolation {
+    /// 稳定字符串 id。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Isolated => "isolated",
+            Self::Shared => "shared",
+            Self::Custom => "custom",
+        }
+    }
+
+    /// 从字符串解析(未知值返回 `None`,由调用方决定是报错还是忽略)。
+    ///
+    /// `per_version` 是 M12 键草案里的旧字面量,读兼容映射到 `isolated`(语义相同)。
+    #[must_use]
+    pub fn parse(raw: &str) -> Option {
+        match raw.trim() {
+            "isolated" | "per_version" => Some(Self::Isolated),
+            "shared" => Some(Self::Shared),
+            "custom" => Some(Self::Custom),
+            _ => None,
+        }
+    }
+}
+
+/// 实例可覆盖的设置键(enum 白名单)。
+///
+/// research/04 §5 的教训:**不要用字符串集合**,拼错会静默不生效;这里用枚举,
+/// 后续 M28 接语义时编译器会逼着把每个键处理干净。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum OverrideKey {
+    /// 最大堆内存。
+    MaxMemory,
+    /// Java 运行时选择。
+    JavaType,
+    /// 工作目录覆盖。
+    RunningDirectory,
+}
+
+/// 每实例设置(`config/instances/.json`)。
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct InstanceConfig {
+    /// schema 版本(迁移链入口)。
+    #[serde(default = "default_version")]
+    pub schema_version: u32,
+    /// 实例 id(稳定 UUID,与文件名一致)。
+    pub id: String,
+    /// 绑定的版本 id(`versions//`)。
+    pub version_id: String,
+    /// 隔离档位;`None` = `inherit`(跟随全局,不落盘)。
+    ///
+    /// 显式写 `"inherit"` 也照读(归一成 `None`)——见
+    /// [`deserialize_isolation`]:schema 白名单允许用户/AI 写这个字面量,
+    /// 读路径必须闭环,否则「显式写回默认值」的实例文件会让启动直接报 unknown variant。
+    #[serde(
+        default,
+        skip_serializing_if = "Option::is_none",
+        deserialize_with = "deserialize_isolation"
+    )]
+    pub isolation: Option,
+    /// 自定义游戏目录(仅 `isolation = custom` 时有值)。
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub game_dir: Option,
+    /// 该实例覆盖了哪些设置键。
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub overrides: Vec,
+    /// 上次隔离档位切换的记录(`lastModeChange`;没切过就不落盘)。
+    ///
+    /// 由 [`crate::game::isolation::observe_mode`] 维护,只记事实(含双侧探测),**不迁移数据**。
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub last_mode_change: Option,
+    /// 未识别字段原样保留(向前兼容)。
+    #[serde(flatten)]
+    pub extra: BTreeMap,
+}
+
+fn default_version() -> u32 {
+    INSTANCE_CONFIG_VERSION
+}
+
+/// `isolation` 的反序列化:显式 `"inherit"` 归一成「键不存在」(`None`)。
+///
+/// 为什么需要:`setting::instance_keys` 的白名单里有 `inherit`(读/显示要用),
+/// 用户或 AI 把档位写回默认值时就会落一个 `"isolation": "inherit"`;从枚举角度它
+/// 是**没有变体**的(`inherit` 的真相是稀疏 —— 键缺失),所以在这里折叠成 `None`,
+/// 顺带把 `per_version` 旧字面量也接住(`Isolation::parse` 负责映射)。
+fn deserialize_isolation<'de, D>(deserializer: D) -> Result, D::Error>
+where
+    D: serde::Deserializer<'de>,
+{
+    match Option::::deserialize(deserializer)? {
+        None => Ok(None),
+        Some(text) if text.trim() == "inherit" => Ok(None),
+        Some(text) => Isolation::parse(&text).map(Some).ok_or_else(|| {
+            serde::de::Error::unknown_variant(&text, &["inherit", "isolated", "shared", "custom"])
+        }),
+    }
+}
+
+impl InstanceConfig {
+    /// 新建一份最小设置:`isolation` 留空(= inherit,不落盘)。
+    #[must_use]
+    pub fn new(id: impl Into, version_id: impl Into) -> Self {
+        Self {
+            schema_version: INSTANCE_CONFIG_VERSION,
+            id: id.into(),
+            version_id: version_id.into(),
+            isolation: None,
+            game_dir: None,
+            overrides: Vec::new(),
+            last_mode_change: None,
+            extra: BTreeMap::new(),
+        }
+    }
+
+    /// 是否跟随全局隔离模式(`isolation` 键缺失)。
+    #[must_use]
+    pub fn inherits_isolation(&self) -> bool {
+        self.isolation.is_none()
+    }
+
+    /// 生效隔离档位(覆盖链第二层落到全局第一层,`inherit` 在这里被解析掉)。
+    #[must_use]
+    pub fn effective_isolation(&self, global: IsolationMode) -> Isolation {
+        self.isolation.unwrap_or_else(|| global.as_isolation())
+    }
+
+    /// 取走待展示的切换提示(取走后 `warned` 置真,调用方负责落盘)。
+    pub fn take_mode_change_warning(&mut self) -> Option {
+        self.last_mode_change.as_mut()?.take_warning()
+    }
+
+    /// 解析。
+    ///
+    /// # Errors
+    ///
+    /// JSON 非法时返回 [`GameError::Json`]。
+    pub fn from_json_str(json: &str) -> Result {
+        serde_json::from_str(json).map_err(|e| GameError::json("instance_config", e))
+    }
+
+    /// 序列化(格式化,便于用户手改与 diff)。
+    ///
+    /// # Errors
+    ///
+    /// 序列化失败时返回 [`GameError::Json`]。
+    pub fn to_json_string(&self) -> Result {
+        serde_json::to_string_pretty(self).map_err(|e| GameError::json("instance_config", e))
+    }
+}
+
+/// 一次登记的结果。
+#[derive(Debug, Clone)]
+pub struct Registration {
+    /// 档案(新建或复用)。
+    pub profile: InstanceProfile,
+    /// 每实例设置文件路径。
+    pub config_path: PathBuf,
+    /// 是否新建(`false` = 已有同版本同名的实例,直接复用)。
+    pub created: bool,
+}
+
+/// 登记一个实例(幂等)。
+///
+/// 行为:
+/// 1. 已有"同 `version_id` + 同展示名"的档案 → 复用(`created = false`),只补写缺失的设置文件;
+/// 2. 否则新建档案(稳定 UUID)→ 写 `profiles.json` → 写 `config/instances/.json`。
+///
+/// `created_at` 由调用方给(core::game 不碰时钟格式;CLI 用 `launch::time` 的 RFC3339)。
+///
+/// # Errors
+///
+/// 读/写 `profiles.json`、实例 id 不合法、原子写失败。
+pub fn register(
+    layout: &LauncherLayout,
+    name: &str,
+    version_id: &str,
+    created_at: Option<&str>,
+) -> Result {
+    let mut profiles = read_profiles(layout)?;
+    let existing = profiles
+        .profiles
+        .iter()
+        .find(|profile| profile.version_id == version_id && profile.name == name)
+        .cloned();
+
+    if let Some(profile) = existing {
+        let config_path = instance_config_path(layout, profile.id.as_str())?;
+        // 老档案可能还没有设置文件(早期 CLI 只写 profiles.json),这里补齐。
+        if !config_path.is_file() {
+            write_instance_config(
+                layout,
+                &InstanceConfig::new(profile.id.as_str(), version_id),
+            )?;
+        }
+        return Ok(Registration {
+            profile,
+            config_path,
+            created: false,
+        });
+    }
+
+    let mut profile = InstanceProfile::new(name, version_id);
+    profile.created_at = created_at.map(str::to_string);
+    let id = profile.id.as_str().to_string();
+    profiles.upsert(profile.clone());
+    write_profiles(layout, &profiles)?;
+
+    let config_path = write_instance_config(layout, &InstanceConfig::new(id, version_id))?;
+    Ok(Registration {
+        profile,
+        config_path,
+        created: true,
+    })
+}
+
+/// 读实例清单(文件缺失 = 空列表,不算错误)。
+///
+/// # Errors
+///
+/// 读盘失败、非 UTF-8 或 JSON 非法。
+pub fn read_profiles(layout: &LauncherLayout) -> Result {
+    let path = layout.profiles_config();
+    let bytes = match std::fs::read(&path) {
+        Ok(bytes) => bytes,
+        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+            return Ok(ProfileList::default());
+        }
+        Err(error) => return Err(GameError::io(&path, error)),
+    };
+    let Ok(text) = String::from_utf8(bytes) else {
+        return Err(GameError::io(
+            &path,
+            std::io::Error::new(std::io::ErrorKind::InvalidData, "profiles.json 不是 UTF-8"),
+        ));
+    };
+    ProfileList::from_json_str(&text)
+}
+
+/// 写实例清单(原子写)。
+///
+/// # Errors
+///
+/// 序列化失败或原子写失败。
+pub fn write_profiles(layout: &LauncherLayout, profiles: &ProfileList) -> Result<(), GameError> {
+    let text = profiles.to_json_string()?;
+    write_atomic(&layout.profiles_config(), text.as_bytes())
+        .map_err(|error| GameError::io(layout.profiles_config(), std::io::Error::other(error)))
+}
+
+/// 写每实例设置(原子写)。
+///
+/// # Errors
+///
+/// 实例 id 不合法、序列化失败或原子写失败。
+pub fn write_instance_config(
+    layout: &LauncherLayout,
+    config: &InstanceConfig,
+) -> Result {
+    let path = instance_config_path(layout, &config.id)?;
+    let text = config.to_json_string()?;
+    write_atomic(&path, text.as_bytes())
+        .map_err(|error| GameError::io(&path, std::io::Error::other(error)))?;
+    Ok(path)
+}
+
+/// 读每实例设置;缺失返回 `None`。
+///
+/// # Errors
+///
+/// 实例 id 不合法、读盘失败或 JSON 非法。
+pub fn read_instance_config(
+    layout: &LauncherLayout,
+    id: &str,
+) -> Result, GameError> {
+    let path = instance_config_path(layout, id)?;
+    match std::fs::read_to_string(&path) {
+        Ok(text) => InstanceConfig::from_json_str(&text).map(Some),
+        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
+        Err(error) => Err(GameError::io(&path, error)),
+    }
+}
+
+/// `config/instances/.json`(实例 id 白名单校验在布局层,挡掉 `../` 穿越)。
+///
+/// # Errors
+///
+/// 实例 id 不合法时返回 [`GameError::Io`](`PlatformError` 没有对应变体)。
+fn instance_config_path(layout: &LauncherLayout, id: &str) -> Result {
+    layout
+        .instance_config(id)
+        .map_err(|error| GameError::io(layout.config(), std::io::Error::other(error)))
+}
diff --git a/crates/core/src/game/install/libraries.rs b/crates/core/src/game/install/libraries.rs
new file mode 100644
index 0000000..6638c38
--- /dev/null
+++ b/crates/core/src/game/install/libraries.rs
@@ -0,0 +1,258 @@
+//! 依赖库展开:主构件 + natives 分类器构件 → 待下载文件。
+//!
+//! 依据:`DevDocs/analyse/03-version-download.md` §6.4(libraries 含 natives 规则)、
+//! §6.8(目录布局速查)。
+//!
+//! 三条容易踩的规则:
+//! 1. **主构件与 natives 构件都要下**:老版本(lwjgl 2.x)一条库里同时有
+//!    `downloads.artifact` 与 `natives` 表,HMCL 也是两者都取;
+//! 2. **路径优先用 JSON 给的 `path`**(Mojang 权威值),缺了才按 Maven 坐标推导;
+//! 3. **`hint=local` 的库落到实例私有目录** `versions//libraries/`,其余一律进
+//!    恒共享的 `libraries/`(D20:任何模式都不按实例复制)。
+
+use std::path::{Path, PathBuf};
+
+use super::super::arguments::Environment;
+use super::super::layout::GameLayout;
+use super::super::library::Library;
+use super::plan::{FileRole, PlanFile};
+use crate::download::ResourceKind;
+
+/// 没写 `url` 的库兜底用的 Maven 基址(Mojang 官方库仓)。
+pub const DEFAULT_MAVEN_BASE: &str = "https://libraries.minecraft.net/";
+
+/// 展开一层(已合并)清单里的库列表。
+///
+/// 返回(文件清单,人读告警)。告警不致命:缺下载地址的库会被跳过并告知,
+/// 让用户知道"缺哪个文件"而不是拿到一个静默不完整的实例。
+#[must_use]
+pub fn plan(
+    layout: &GameLayout,
+    version_id: &str,
+    libraries: &[Library],
+    environment: &Environment,
+) -> (Vec, Vec) {
+    let mut files = Vec::new();
+    let mut warnings = Vec::new();
+
+    for library in libraries {
+        if !library.applies(environment) {
+            continue;
+        }
+        if library.coordinate().is_err() {
+            warnings.push(format!("库坐标不合法,已跳过:{}", library.name));
+            continue;
+        }
+        let Ok(relative) = library.relative_path() else {
+            warnings.push(format!("库路径推导失败,已跳过:{}", library.name));
+            continue;
+        };
+        let dest = library_dest(layout, version_id, library, &relative);
+
+        match library.artifact() {
+            Some(artifact) if artifact.has_url() => files.push(PlanFile {
+                role: FileRole::Library,
+                name: library.name.clone(),
+                url: artifact.url.clone().unwrap_or_default(),
+                sha1: artifact.sha1.clone(),
+                size: artifact.size,
+                dest,
+                resource: ResourceKind::Library,
+            }),
+            // natives-only 库(1.6 时代的 `lwjgl-platform` / `jinput-platform`)**没有主构件**,
+            // 只要 `downloads.classifiers`。这里若按坐标硬拼 URL 会拼出一个不存在的 jar
+            // (实测:官方与 BMCLAPI 双双 404)。判据是 `natives` 在场。
+            None if library.natives.is_some() => {}
+            // 魔改/现代 loader 的库常见"只给 name 不给 downloads",按坐标拼仓库地址。
+            _ => files.push(PlanFile {
+                role: FileRole::Library,
+                name: library.name.clone(),
+                url: maven_url(library, &relative),
+                sha1: None,
+                size: None,
+                dest,
+                resource: ResourceKind::Library,
+            }),
+        }
+
+        // natives 分类器构件(存在才下;解压发生在启动期,不归安装管)。
+        let Some(classifier) = library.native_classifier(environment) else {
+            continue;
+        };
+        let Some(artifact) = library.native_artifact(&classifier) else {
+            warnings.push(format!(
+                "{} 的 natives 分类器 {classifier} 没有下载信息,已跳过",
+                library.name
+            ));
+            continue;
+        };
+        let Ok(native_relative) = library.native_relative_path(&classifier) else {
+            warnings.push(format!("{} 的 natives 路径推导失败,已跳过", library.name));
+            continue;
+        };
+        if !artifact.has_url() {
+            warnings.push(format!(
+                "{} 的 natives 构件 {classifier} 没有下载地址,已跳过",
+                library.name
+            ));
+            continue;
+        }
+        files.push(PlanFile {
+            role: FileRole::Native,
+            name: format!("{}:{classifier}", library.name),
+            url: artifact.url.clone().unwrap_or_default(),
+            sha1: artifact.sha1.clone(),
+            size: artifact.size,
+            dest: layout.libraries_dir().join(native_relative),
+            resource: ResourceKind::Library,
+        });
+    }
+
+    (files, warnings)
+}
+
+/// 库文件落点:实例私有(`hint=local`)或恒共享 `libraries/`。
+fn library_dest(
+    layout: &GameLayout,
+    version_id: &str,
+    library: &Library,
+    relative: &Path,
+) -> PathBuf {
+    if library.is_local() {
+        layout.version_libraries_dir(version_id).join(relative)
+    } else {
+        layout.libraries_dir().join(relative)
+    }
+}
+
+/// 按库声明(或默认)的 Maven 基址拼下载地址。
+fn maven_url(library: &Library, relative: &Path) -> String {
+    let base = library
+        .url
+        .as_deref()
+        .map(str::trim)
+        .filter(|base| !base.is_empty())
+        .unwrap_or(DEFAULT_MAVEN_BASE);
+    let path = relative.to_string_lossy().replace('\\', "/");
+    if base.ends_with('/') {
+        format!("{base}{path}")
+    } else {
+        format!("{base}/{path}")
+    }
+}
+
+/// 供上层判错用:库展开不产生硬失败,缺地址一律降级成告警。
+#[cfg(test)]
+mod tests {
+    use super::plan;
+    use crate::game::arguments::Environment;
+    use crate::game::layout::GameLayout;
+    use crate::game::library::Library;
+
+    fn environment() -> Environment {
+        Environment {
+            os: "windows".to_string(),
+            os_version: String::new(),
+            arch: "x86_64".to_string(),
+            ..Environment::host()
+        }
+    }
+
+    fn library_with_artifact(json: &str) -> Library {
+        serde_json::from_str(json).expect("库 JSON 应可解析")
+    }
+
+    #[test]
+    fn plans_artifact_at_maven_path() {
+        let library = library_with_artifact(
+            r#"{"name":"org.ow2.asm:asm:9.6","downloads":{"artifact":{"path":"org/ow2/asm/asm/9.6/asm-9.6.jar","sha1":"aa","size":122,"url":"https://libraries.minecraft.net/org/ow2/asm/asm/9.6/asm-9.6.jar"}}}"#,
+        );
+        let layout = GameLayout::new("/g/.minecraft");
+        let (files, warnings) = plan(&layout, "1.20.1", &[library], &environment());
+        assert!(warnings.is_empty());
+        assert_eq!(files.len(), 1);
+        assert_eq!(files[0].sha1.as_deref(), Some("aa"));
+        assert_eq!(files[0].size, Some(122));
+        assert!(files[0]
+            .dest
+            .ends_with("libraries/org/ow2/asm/asm/9.6/asm-9.6.jar"));
+    }
+
+    #[test]
+    fn natives_only_library_plans_no_artifact() {
+        // 1.6 时代的 lwjgl-platform / jinput-platform:有 natives 表、只有 classifiers,
+        // **没有 downloads.artifact**。按坐标硬拼主构件会拼出不存在的 jar(实测双源 404)。
+        let library = library_with_artifact(
+            r#"{"name":"org.lwjgl.lwjgl:lwjgl-platform:2.9.0",
+                "natives":{"windows":"natives-windows","linux":"natives-linux","osx":"natives-osx"},
+                "downloads":{"classifiers":{"natives-windows":{"path":"org/lwjgl/lwjgl/lwjgl-platform/2.9.0/lwjgl-platform-2.9.0-natives-windows.jar","sha1":"3f11","size":609967,"url":"https://libraries.minecraft.net/natives-windows.jar"}}}}"#,
+        );
+        let layout = GameLayout::new("/g/.minecraft");
+        let (files, warnings) = plan(&layout, "1.6.4", &[library], &environment());
+        assert!(warnings.is_empty(), "不该有告警:{warnings:?}");
+        assert_eq!(files.len(), 1, "只应有 natives 构件:{files:?}");
+        assert_eq!(files[0].role.as_str(), "native");
+        assert_eq!(files[0].sha1.as_deref(), Some("3f11"));
+    }
+
+    #[test]
+    fn plans_both_artifact_and_native_jar() {
+        let library = library_with_artifact(
+            r#"{"name":"org.lwjgl.lwjgl:lwjgl-platform:2.9.4","natives":{"windows":"natives-windows"},
+                "downloads":{"artifact":{"path":"org/lwjgl/lwjgl/lwjgl-platform/2.9.4/lwjgl-platform-2.9.4.jar","sha1":"bb","size":1,"url":"https://libraries.minecraft.net/a.jar"},
+                "classifiers":{"natives-windows":{"path":"org/lwjgl/lwjgl/lwjgl-platform/2.9.4/lwjgl-platform-2.9.4-natives-windows.jar","sha1":"cc","size":2,"url":"https://libraries.minecraft.net/b.jar"}}}}"#,
+        );
+        let layout = GameLayout::new("/g/.minecraft");
+        let (files, _) = plan(&layout, "1.7.10", &[library], &environment());
+        assert_eq!(files.len(), 2);
+        let native = files
+            .iter()
+            .find(|f| f.role.as_str() == "native")
+            .expect("应有 natives 构件");
+        assert_eq!(native.sha1.as_deref(), Some("cc"));
+        assert!(native
+            .dest
+            .ends_with("lwjgl-platform-2.9.4-natives-windows.jar"));
+    }
+
+    #[test]
+    fn derives_url_for_libraries_without_downloads() {
+        let library = library_with_artifact(
+            r#"{"name":"net.minecraftforge:forge:1.20.1-47.2.0:universal","url":"https://maven.minecraftforge.net/"}"#,
+        );
+        let layout = GameLayout::new("/g/.minecraft");
+        let (files, warnings) = plan(&layout, "1.20.1-forge", &[library], &environment());
+        assert!(warnings.is_empty());
+        assert_eq!(files.len(), 1);
+        assert_eq!(
+            files[0].url,
+            "https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-universal.jar"
+        );
+        assert!(files[0].sha1.is_none());
+    }
+
+    #[test]
+    fn skips_libraries_filtered_by_rules() {
+        let library = library_with_artifact(
+            r#"{"name":"org.lwjgl:lwjgl:3.3.2","rules":[{"action":"allow","os":{"name":"linux"}}],
+                "downloads":{"artifact":{"url":"https://libraries.minecraft.net/x.jar","sha1":"dd"}}}"#,
+        );
+        let layout = GameLayout::new("/g/.minecraft");
+        let (files, _) = plan(&layout, "1.20.1", &[library], &environment());
+        assert!(files.is_empty());
+    }
+
+    #[test]
+    fn local_hint_libraries_land_in_instance_dir() {
+        let library = library_with_artifact(
+            r#"{"name":"net.minecraftforge:forge:1.20.1-47.2.0","hint":"local",
+                "downloads":{"artifact":{"path":"net/minecraftforge/forge/1.20.1-47.2.0.jar","url":"https://libraries.minecraft.net/y.jar"}}}"#,
+        );
+        let layout = GameLayout::new("/g/.minecraft");
+        let (files, _) = plan(&layout, "inst", &[library], &environment());
+        assert_eq!(files.len(), 1);
+        assert!(files[0]
+            .dest
+            .ends_with("versions/inst/libraries/net/minecraftforge/forge/1.20.1-47.2.0.jar"));
+    }
+}
diff --git a/crates/core/src/game/install/mod.rs b/crates/core/src/game/install/mod.rs
new file mode 100644
index 0000000..903bdae
--- /dev/null
+++ b/crates/core/src/game/install/mod.rs
@@ -0,0 +1,186 @@
+//! 安装层:版本链 → 下载计划 → 分区落盘 → 实例登记(P2 核心闭环「装一个版本并启动它」)。
+//!
+//! 设计输入:`DevDocs/analyse/03-version-download.md` §6(逐类资源拆解)、
+//! `DevDocs/PlanDoc/04-data-and-config.md` §4(D20 实例模型)、§2(`.ULL/` 四分区)、
+//! `DevDocs/PlanDoc/05-download-and-mirrors.md` §2(分层下载策略)、
+//! `DevDocs/PlanDoc/research/04-launcher-configs.md` §5(实例键草案)。
+//!
+//! # 分工(每层只做一件事,谁都不越界)
+//!
+//! ```text
+//! source    网络:版本清单(带 TTL 缓存)→ 版本 JSON 链 → 资源索引      (异步)
+//! plan      纯计算:清单 + 索引 → InstallPlan(文件清单 / 总大小 / 角色)  (同步、可 dry-run)
+//! execute   落盘:逐文件跳过判定 + 下载引擎 + 原子写版本 JSON            (异步)
+//! instance  登记:profiles.json + config/instances/.json(稀疏、幂等)(同步)
+//! ```
+//!
+//! # 三条硬约定
+//!
+//! 1. **恒共享层**(D20):`versions/` `assets/` `libraries/` 一律进游戏根,任何模式都不按实例复制;
+//! 2. **幂等**:重复装同一版本不重复下载(按 sha1 跳过)、不重复建实例(按 `versionId` 复用档案);
+//! 3. **如实告知**:清单缺件(旧版无 `downloads.client` / 无 `assetIndex`)只降级成计划告警,
+//!    不静默假装成功,也不编造 URL。
+//!
+//! # 同步宿主怎么用
+//!
+//! `crates/cli` 不依赖 tokio:整段「规划 + 下载」包在一次
+//! [`crate::download::blocking::block_on`] 里,见 `ull install` 的实现。
+
+pub mod assets;
+pub mod execute;
+pub mod instance;
+pub mod libraries;
+pub mod plan;
+pub mod plan_view;
+pub mod source;
+
+use std::fmt;
+
+pub use assets::{AssetIndex, AssetObject, ASSET_OBJECT_BASE};
+pub use execute::{execute, FileFailure, FileOutcome, FileStatus, InstallReport, ProgressSink};
+pub use instance::{register, InstanceConfig, Registration};
+pub use plan::{FileRole, InstallPlan, PlanFile, PlanInput};
+pub use source::{ManifestFreshness, RemoteSource, MANIFEST_TTL_MS};
+
+use crate::download::{DownloadError, DownloadErrorKind};
+use crate::game::error::GameError;
+
+/// 安装失败的大类(CLI 据此给稳定错误码,脚本可分流)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum InstallErrorKind {
+    /// 版本 id 在官方清单与本地仓库里都不存在。
+    VersionNotFound,
+    /// 网络侧失败(所有候选源都失败 / 超时 / 连接错误)。
+    Network,
+    /// 内容校验不符(镜像给了错内容)。
+    Integrity,
+    /// 本地 IO(写盘失败、目录权限)。
+    Io,
+    /// 元数据解析失败(清单/版本 JSON/资源索引坏了)。
+    Metadata,
+}
+
+impl InstallErrorKind {
+    /// 稳定字符串 id(进 `--json` 的 `error.code`)。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::VersionNotFound => "install_version_not_found",
+            Self::Network => "install_network",
+            Self::Integrity => "install_integrity",
+            Self::Io => "install_io",
+            Self::Metadata => "install_metadata",
+        }
+    }
+}
+
+/// 安装期错误:大类 + 人读说明 + 可选提示。
+#[derive(Debug, Clone)]
+pub struct InstallError {
+    kind: InstallErrorKind,
+    message: String,
+    hint: Option,
+}
+
+impl InstallError {
+    /// 构造。
+    #[must_use]
+    pub fn new(kind: InstallErrorKind, message: impl Into) -> Self {
+        Self {
+            kind,
+            message: message.into(),
+            hint: None,
+        }
+    }
+
+    /// 版本不存在。
+    #[must_use]
+    pub fn version_not_found(id: &str) -> Self {
+        Self::new(
+            InstallErrorKind::VersionNotFound,
+            format!("版本 {id} 不在版本清单里,也不在本地 versions/ 下"),
+        )
+        .with_hint("用 `ull --json install ` 前先确认版本 id(如 26.3 / 1.21.4);快照版本 id 形如 24w14a")
+    }
+
+    /// 补一句提示。
+    #[must_use]
+    pub fn with_hint(mut self, hint: impl Into) -> Self {
+        self.hint = Some(hint.into());
+        self
+    }
+
+    /// 大类。
+    #[must_use]
+    pub const fn kind(&self) -> InstallErrorKind {
+        self.kind
+    }
+
+    /// 人读说明。
+    #[must_use]
+    pub fn message(&self) -> &str {
+        &self.message
+    }
+
+    /// 下一步提示。
+    #[must_use]
+    pub fn hint(&self) -> Option<&str> {
+        self.hint.as_deref()
+    }
+}
+
+impl fmt::Display for InstallError {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        formatter.write_str(&self.message)
+    }
+}
+
+impl std::error::Error for InstallError {}
+
+impl From for InstallError {
+    fn from(error: DownloadError) -> Self {
+        let kind = match error.kind {
+            DownloadErrorKind::Integrity => InstallErrorKind::Integrity,
+            DownloadErrorKind::Io => InstallErrorKind::Io,
+            DownloadErrorKind::InvalidRequest => InstallErrorKind::Metadata,
+            DownloadErrorKind::NotFound
+            | DownloadErrorKind::ServerError
+            | DownloadErrorKind::Timeout
+            | DownloadErrorKind::Network
+            | DownloadErrorKind::RangeUnsupported
+            | DownloadErrorKind::AllSourcesFailed => InstallErrorKind::Network,
+        };
+        let mut message = error.message.clone();
+        if let Some(source) = &error.source {
+            message.push_str(&format!("(源 {source})"));
+        }
+        Self::new(kind, message)
+    }
+}
+
+impl From for InstallError {
+    fn from(error: GameError) -> Self {
+        let kind = match &error {
+            GameError::Io { .. } => InstallErrorKind::Io,
+            GameError::Json { .. } | GameError::InvalidAssetHash { .. } => {
+                InstallErrorKind::Metadata
+            }
+            GameError::MissingVersionId
+            | GameError::MissingParent { .. }
+            | GameError::CircularInheritance { .. }
+            | GameError::InvalidCoordinate { .. }
+            | GameError::CustomIsolationWithoutDir { .. }
+            | GameError::DraftBusy => InstallErrorKind::Metadata,
+        };
+        Self::new(kind, error.to_string())
+    }
+}
+
+impl From for InstallError {
+    fn from(error: crate::platform::error::PlatformError) -> Self {
+        Self::new(InstallErrorKind::Io, error.to_string())
+    }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/core/src/game/install/plan.rs b/crates/core/src/game/install/plan.rs
new file mode 100644
index 0000000..8310353
--- /dev/null
+++ b/crates/core/src/game/install/plan.rs
@@ -0,0 +1,342 @@
+//! 安装计划:把「已解析的版本清单 + 资源索引」翻译成一份可下载文件清单。
+//!
+//! 依据:`DevDocs/analyse/03-version-download.md` §6(安装期资源清单逐类拆解)、
+//! `DevDocs/PlanDoc/05-download-and-mirrors.md`(镜像由下载引擎展开)。
+//!
+//! 本模块是**纯计算**:输入已解析清单([`VersionJson`])与已取到的资源索引,输出
+//! [`InstallPlan`];不发请求、不写文件。网络在 [`super::source`]、落盘在 [`super::execute`]。
+//! 这样 `--dry-run`、测试、以及 GUI 的"安装前确认页"共用同一份计划真相。
+//!
+//! 恒共享层(D20):`versions/`、`assets/`、`libraries/` 一律进游戏根,不按实例复制;
+//! 只有 `hint=local` 的库落实例私有目录(见 [`super::libraries`])。
+
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+
+use super::super::arguments::Environment;
+use super::super::error::GameError;
+use super::super::layout::GameLayout;
+use super::super::manifest::{Logging, VersionJson};
+use super::assets::{self, AssetIndex};
+use super::libraries;
+use crate::download::{DownloadRequest, ResourceKind};
+
+/// 一个待落盘文件的角色(进度分组、`--dry-run` 汇总、错误定位都用它)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum FileRole {
+    /// 客户端 jar `versions//.jar`。
+    ClientJar,
+    /// 共享库 `libraries/...`。
+    Library,
+    /// natives 分类器构件(也进 `libraries/`,启动期解压)。
+    Native,
+    /// 资源索引 `assets/indexes/.json`。
+    AssetIndex,
+    /// 资源对象 `assets/objects//`。
+    Asset,
+    /// 日志配置 `assets/log_configs/`。
+    LogConfig,
+}
+
+impl FileRole {
+    /// 稳定字符串 id(JSON 输出与日志用,**不要改名**)。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::ClientJar => "client_jar",
+            Self::Library => "library",
+            Self::Native => "native",
+            Self::AssetIndex => "asset_index",
+            Self::Asset => "asset",
+            Self::LogConfig => "log_config",
+        }
+    }
+
+    /// 人读名称(汇总行用)。
+    #[must_use]
+    pub const fn label(self) -> &'static str {
+        match self {
+            Self::ClientJar => "客户端 jar",
+            Self::Library => "依赖库",
+            Self::Native => "natives",
+            Self::AssetIndex => "资源索引",
+            Self::Asset => "资源对象",
+            Self::LogConfig => "日志配置",
+        }
+    }
+}
+
+/// 清单里的一个待下载文件。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PlanFile {
+    /// 角色。
+    pub role: FileRole,
+    /// 人读标识(库坐标 / 资源名 / 索引 id / jar id)。
+    pub name: String,
+    /// 官方逻辑 URL(镜像展开由下载引擎负责)。
+    pub url: String,
+    /// 期望 SHA-1(`None` = 清单没给,只能靠大小/存在性兜底)。
+    pub sha1: Option,
+    /// 期望字节数。
+    pub size: Option,
+    /// 最终落盘路径。
+    pub dest: PathBuf,
+    /// 资源类型(健康度统计与分块准入的第二维度)。
+    pub resource: ResourceKind,
+}
+
+impl PlanFile {
+    /// 转成下载引擎的请求(校验值/大小原样透传)。
+    #[must_use]
+    pub fn request(&self) -> DownloadRequest {
+        let mut request = DownloadRequest::new(self.resource, self.url.clone(), self.dest.clone());
+        request.integrity = self
+            .sha1
+            .clone()
+            .filter(|hex| !hex.is_empty())
+            .map(crate::download::Integrity::sha1);
+        request.expected_size = self.size;
+        request
+    }
+
+    /// `--dry-run` 的单行描述。
+    #[must_use]
+    pub fn to_json(&self) -> Value {
+        json!({
+            "role": self.role.as_str(),
+            "name": self.name,
+            "url": self.url,
+            "sha1": self.sha1,
+            "size": self.size,
+            "dest": self.dest.display().to_string(),
+            "resource": self.resource.as_str(),
+        })
+    }
+}
+
+/// 规划阶段已经取到内容、但因 `--dry-run` / 时序原因还没落盘的元数据文件。
+///
+/// 典型场景:资源索引——不先取到它就无法枚举对象,所以规划期已经下载在内存里;
+/// 执行期直接原子写盘,不再重复走一次网络。
+#[derive(Debug, Clone)]
+pub struct Prefetched {
+    /// 目标路径(必须与计划里的某个 [`PlanFile::dest`] 一致)。
+    pub dest: PathBuf,
+    /// 内容字节。
+    pub bytes: Vec,
+}
+
+/// 一次安装的完整计划。
+#[derive(Debug, Clone)]
+pub struct InstallPlan {
+    /// 目标版本 id(`versions//`)。
+    pub version_id: String,
+    /// 实际提供 jar 的版本 id(继承链上可能是父版本)。
+    pub jar_id: String,
+    /// 游戏根目录(恒共享层的根)。
+    pub game_root: PathBuf,
+    /// 版本清单落盘路径 `versions//.json`。
+    pub version_json_path: PathBuf,
+    /// 待下载文件(按 dest 去重、按角色排序)。
+    pub files: Vec,
+    /// 已解析的扁平清单(落盘时写进 `versions//.json`)。
+    pub version_json: VersionJson,
+    /// 规划期已取到内容的文件(见 [`Prefetched`])。
+    pub prefetched: Vec,
+    /// 人读告警(缺哪类资源一律如实告知,不静默降级)。
+    pub warnings: Vec,
+}
+
+/// [`InstallPlan::build`] 的输入。
+pub struct PlanInput<'a> {
+    /// 游戏目录布局。
+    pub layout: &'a GameLayout,
+    /// 目标版本 id。
+    pub version_id: &'a str,
+    /// 已 resolve 的扁平清单。
+    pub resolved: &'a VersionJson,
+    /// 已取到并解析的资源索引(缺 = 老版本没有 `assetIndex`)。
+    pub asset_index: Option<&'a AssetIndex>,
+    /// 规则求值环境(OS / 架构 / 特性开关)。
+    pub environment: &'a Environment,
+}
+
+impl InstallPlan {
+    /// 组计划。
+    ///
+    /// # Errors
+    ///
+    /// 资源对象的哈希不合法(索引坏了)时返回 [`GameError::InvalidAssetHash`]。
+    pub fn build(input: PlanInput<'_>) -> Result {
+        let layout = input.layout;
+        let version_id = input.version_id;
+        let resolved = input.resolved;
+        let mut files: Vec = Vec::new();
+        let mut warnings: Vec = Vec::new();
+
+        let jar_id = resolved
+            .jar
+            .clone()
+            .unwrap_or_else(|| version_id.to_string());
+
+        // ① 客户端 jar:老版本(pre-1.6)没有 `downloads.client`,如实告知而不是编 URL。
+        match resolved.client_jar() {
+            Some(info) if info.has_url() => files.push(PlanFile {
+                role: FileRole::ClientJar,
+                name: jar_id.clone(),
+                url: info.url.clone().unwrap_or_default(),
+                sha1: info.sha1.clone(),
+                size: info.size,
+                dest: layout.version_jar(&jar_id),
+                resource: ResourceKind::ClientJar,
+            }),
+            _ => warnings.push(format!(
+                "版本 {version_id} 的清单没有 downloads.client:客户端 jar 无法自动获取(pre-1.6 旧清单待补兜底)"
+            )),
+        }
+
+        // ② 依赖库 + natives。
+        let (library_files, library_warnings) = libraries::plan(
+            layout,
+            version_id,
+            resolved.libraries.as_deref().unwrap_or_default(),
+            input.environment,
+        );
+        files.extend(library_files);
+        warnings.extend(library_warnings);
+
+        // ③ 资源索引 + 逐对象。
+        match resolved.asset_index.as_ref() {
+            Some(reference) if reference.url.is_some() => files.push(PlanFile {
+                role: FileRole::AssetIndex,
+                name: reference.id.clone(),
+                url: reference.url.clone().unwrap_or_default(),
+                sha1: reference.sha1.clone(),
+                size: reference.size,
+                dest: layout.asset_index(&reference.id),
+                resource: ResourceKind::AssetIndex,
+            }),
+            Some(reference) => warnings.push(format!(
+                "资源索引 {} 没有下载地址,跳过(对象无法枚举)",
+                reference.id
+            )),
+            None => warnings.push(format!(
+                "版本 {version_id} 的清单没有 assetIndex:资源对象无法枚举(旧版 sha1 反查表待补)"
+            )),
+        }
+        if let Some(index) = input.asset_index {
+            files.extend(assets::plan(layout, index)?);
+        }
+
+        // ④ 日志配置(官方 manifest 带的 appender 配置;有就下,没有就用启动器的内置模板)。
+        if let Some(logging) = resolved.logging.as_ref().and_then(Logging::client) {
+            if logging.file.has_url() {
+                let file_id = logging
+                    .file
+                    .id
+                    .clone()
+                    .unwrap_or_else(|| format!("{version_id}-client"));
+                files.push(PlanFile {
+                    role: FileRole::LogConfig,
+                    name: file_id.clone(),
+                    url: logging.file.url.clone().unwrap_or_default(),
+                    sha1: logging.file.sha1.clone(),
+                    size: logging.file.size,
+                    dest: layout.log_config(&file_id),
+                    resource: ResourceKind::Other,
+                });
+            }
+        }
+
+        let files = dedupe(files);
+
+        Ok(Self {
+            version_id: version_id.to_string(),
+            jar_id,
+            game_root: layout.root().to_path_buf(),
+            version_json_path: layout.version_json(version_id),
+            files,
+            version_json: resolved.clone(),
+            prefetched: Vec::new(),
+            warnings,
+        })
+    }
+
+    /// 挂一份"规划期已取到内容"的文件(内容必须与计划里的对应文件同一落点)。
+    pub fn prefetch(&mut self, dest: PathBuf, bytes: Vec) {
+        self.prefetched.push(Prefetched { dest, bytes });
+    }
+
+    /// 客户端 jar 的待下载项(`None` = 旧版清单没给下载信息)。
+    #[must_use]
+    pub fn client_jar(&self) -> Option<&PlanFile> {
+        self.files
+            .iter()
+            .find(|file| file.role == FileRole::ClientJar)
+    }
+
+    /// 这份计划能否真的装出一个可启动的版本(判据:有客户端 jar)。
+    ///
+    /// 旧版(pre-1.6)清单没有 `downloads.client`,计划里就没有 jar——此时"装成功"
+    /// 等于骗人,调用方应当拒绝执行而不是登记一个起不来的实例。
+    #[must_use]
+    pub fn is_installable(&self) -> bool {
+        self.client_jar().is_some()
+    }
+
+    /// 文件数。
+    #[must_use]
+    pub fn file_count(&self) -> usize {
+        self.files.len()
+    }
+
+    /// 总字节数(已知大小的合计;`unknown_size` 给出"有几个文件大小未知")。
+    #[must_use]
+    pub fn total_bytes(&self) -> (u64, usize) {
+        let mut total = 0_u64;
+        let mut unknown = 0_usize;
+        for file in &self.files {
+            match file.size {
+                Some(size) => total += size,
+                None => unknown += 1,
+            }
+        }
+        (total, unknown)
+    }
+
+    /// 按角色统计(角色 → (文件数, 字节数))。
+    #[must_use]
+    pub fn by_role(&self) -> BTreeMap {
+        let mut out: BTreeMap = BTreeMap::new();
+        for file in &self.files {
+            let entry = out.entry(file.role).or_insert((0, 0));
+            entry.0 += 1;
+            entry.1 += file.size.unwrap_or(0);
+        }
+        out
+    }
+}
+
+/// 按落盘路径去重:同一目标只留一条,优先保留带 sha1 的那条(后出现的更可信时覆盖)。
+fn dedupe(files: Vec) -> Vec {
+    let mut by_dest: BTreeMap = BTreeMap::new();
+    for file in files {
+        match by_dest.get(&file.dest) {
+            Some(existing) if existing.sha1.is_some() || file.sha1.is_none() => {}
+            _ => {
+                by_dest.insert(file.dest.clone(), file);
+            }
+        }
+    }
+    let mut out: Vec = by_dest.into_values().collect();
+    out.sort_by(|left, right| {
+        left.role
+            .cmp(&right.role)
+            .then_with(|| left.dest.cmp(&right.dest))
+    });
+    out
+}
diff --git a/crates/core/src/game/install/plan_view.rs b/crates/core/src/game/install/plan_view.rs
new file mode 100644
index 0000000..119df0e
--- /dev/null
+++ b/crates/core/src/game/install/plan_view.rs
@@ -0,0 +1,72 @@
+//! 计划的 JSON 视图与源选择样本(`--dry-run` 的输出形状)。
+//!
+//! 单独一份是为了让 [`InstallPlan`](super::plan::InstallPlan) 的"计算"与"呈现"分开:
+//! 形状变了只动这里,计划算法不受影响;也让 `plan.rs` 不至于越过 400 行硬约束。
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use serde_json::{json, Value};
+
+use super::plan::{FileRole, InstallPlan, PlanFile};
+use crate::download::DownloadEngine;
+
+impl InstallPlan {
+    /// 计划摘要(`--dry-run` 与日志共用;不含逐文件明细)。
+    #[must_use]
+    pub fn summary_json(&self) -> Value {
+        let (total, unknown) = self.total_bytes();
+        let by_role: BTreeMap = self
+            .by_role()
+            .into_iter()
+            .map(|(role, (count, bytes))| {
+                (
+                    role.as_str().to_string(),
+                    json!({"files": count, "bytes": bytes, "label": role.label()}),
+                )
+            })
+            .collect();
+        json!({
+            "version_id": self.version_id,
+            "jar_id": self.jar_id,
+            "game_root": self.game_root.display().to_string(),
+            "file_count": self.file_count(),
+            "total_bytes": total,
+            "unknown_size_files": unknown,
+            "by_role": by_role,
+            "warnings": self.warnings,
+        })
+    }
+
+    /// 逐文件明细(`--dry-run` 的"列清单"用它;真实安装不输出,避免上千行噪声)。
+    #[must_use]
+    pub fn files_json(&self) -> Vec {
+        self.files.iter().map(PlanFile::to_json).collect()
+    }
+
+    /// 每个角色取第一个文件,[`DownloadEngine::plan`] 给出候选源顺序("源选择")。
+    ///
+    /// 只取每类的**首个**文件做样本:3000 个资源对象的候选源是一样的,
+    /// 逐文件列源选择既没用也没法读。
+    #[must_use]
+    pub fn source_samples(&self, engine: &DownloadEngine, now_ms: u64) -> Vec {
+        let mut seen: BTreeSet = BTreeSet::new();
+        let mut samples = Vec::new();
+        for file in &self.files {
+            if !seen.insert(file.role) {
+                continue;
+            }
+            let candidates: Vec = engine
+                .plan(&file.request(), now_ms)
+                .into_iter()
+                .map(|candidate| json!({"source": candidate.source, "url": candidate.url}))
+                .collect();
+            samples.push(json!({
+                "role": file.role.as_str(),
+                "label": file.role.label(),
+                "sample": file.name,
+                "candidates": candidates,
+            }));
+        }
+        samples
+    }
+}
diff --git a/crates/core/src/game/install/source.rs b/crates/core/src/game/install/source.rs
new file mode 100644
index 0000000..e9e5b4d
--- /dev/null
+++ b/crates/core/src/game/install/source.rs
@@ -0,0 +1,353 @@
+//! 远端元数据:版本清单(带 TTL 缓存与过期回退)、版本 JSON 链、资源索引。
+//!
+//! 依据:`DevDocs/analyse/03-version-download.md` §2.6(清单缓存)、§4.3(继承解析)、
+//! §6.2(版本 JSON)、§6.5(资源索引);`DevDocs/PlanDoc/05-download-and-mirrors.md` §2
+//! (清单类资源"短 TTL + 失败立切源")。
+//!
+//! 三条与 HMCL 有意的差异(都是 analyse/03 §8 点名的上游坑):
+//! 1. **清单与版本 JSON 一律校验 sha1**(v2 端点自带),镜像篡改/截断在下载阶段就暴露;
+//! 2. **清单缓存过期自动刷新**,且刷新失败时**回退到过期缓存**并标注
+//!    [`ManifestFreshness::StaleFallback`]——离线机器上也能装已缓存的清单;
+//! 3. 版本 JSON 链**远端优先、本地兜底**:装已存在的 loader 实例时不会因为"官方清单里没有
+//!    这个魔改 id"就直接失败。
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+use super::super::error::GameError;
+use super::super::inherit;
+use super::super::layout::GameLayout;
+use super::super::manifest::{AssetIndexRef, VersionJson};
+use super::super::unlisted;
+use super::super::version::{RemoteVersion, VersionList, VERSION_MANIFEST_V2_URL};
+use super::assets::AssetIndex;
+use super::InstallError;
+use super::InstallErrorKind;
+use crate::download::{DownloadEngine, DownloadRequest, ResourceKind};
+use crate::platform::atomic::write_atomic;
+
+/// 版本清单缓存有效期(12 小时)。过期后自动重新拉取;拉取失败退回旧缓存。
+pub const MANIFEST_TTL_MS: u64 = 12 * 60 * 60 * 1000;
+
+/// 清单缓存文件名(落在 `.ULL/cache/meta/`)。
+pub const MANIFEST_CACHE_FILE: &str = "version_manifest_v2.json";
+
+/// 继承链最大层数(防御恶意/损坏清单造成的无限展开;`resolve` 另有成环检测)。
+const MAX_CHAIN_DEPTH: usize = 16;
+
+/// 清单内容的来源(可观测性:诊断页/事件流要能说明"这次用的是缓存还是新拉的")。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ManifestFreshness {
+    /// 本次真实拉取。
+    Fetched,
+    /// 命中未过期的本地缓存。
+    Cached,
+    /// 拉取失败,退回过期缓存(离线可用)。
+    StaleFallback,
+}
+
+impl ManifestFreshness {
+    /// 稳定字符串 id。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Fetched => "fetched",
+            Self::Cached => "cached",
+            Self::StaleFallback => "stale_fallback",
+        }
+    }
+}
+
+/// 落盘的缓存形态(带拉取时刻,TTL 判定用)。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct ManifestCache {
+    fetched_at_ms: u64,
+    list: VersionList,
+}
+
+/// 远端元数据来源。
+pub struct RemoteSource<'a> {
+    engine: &'a DownloadEngine,
+    game: &'a GameLayout,
+    meta_cache: &'a Path,
+    manifest_url: String,
+    now_ms: u64,
+}
+
+impl<'a> RemoteSource<'a> {
+    /// 绑定引擎、游戏布局与缓存目录。
+    #[must_use]
+    pub fn new(
+        engine: &'a DownloadEngine,
+        game: &'a GameLayout,
+        meta_cache: &'a Path,
+        now_ms: u64,
+    ) -> Self {
+        Self {
+            engine,
+            game,
+            meta_cache,
+            manifest_url: VERSION_MANIFEST_V2_URL.to_string(),
+            now_ms,
+        }
+    }
+
+    /// 覆盖清单地址(内网专有清单/测试用)。
+    #[must_use]
+    pub fn with_manifest_url(mut self, url: impl Into) -> Self {
+        self.manifest_url = url.into();
+        self
+    }
+
+    /// 取版本清单(带 TTL 缓存;`refresh = true` 强制重新拉取)。
+    ///
+    /// # Errors
+    ///
+    /// 无可用缓存且网络失败、或清单 JSON/校验失败时返回 [`InstallError`]。
+    pub async fn manifest(
+        &self,
+        refresh: bool,
+    ) -> Result<(VersionList, ManifestFreshness), InstallError> {
+        let path = self.meta_cache.join(MANIFEST_CACHE_FILE);
+        let cached = read_cache(&path);
+        if !refresh {
+            if let Some(cache) = &cached {
+                if self.now_ms.saturating_sub(cache.fetched_at_ms) < MANIFEST_TTL_MS {
+                    return Ok((cache.list.clone(), ManifestFreshness::Cached));
+                }
+            }
+        }
+        match self.fetch_manifest(&path).await {
+            Ok(list) => Ok((list, ManifestFreshness::Fetched)),
+            Err(error) => match cached {
+                Some(cache) => Ok((cache.list, ManifestFreshness::StaleFallback)),
+                None => Err(error),
+            },
+        }
+    }
+
+    /// 解析目标版本的扁平清单(远端取版本 JSON,递归拉父层,再走
+    /// [`inherit::resolve`])。
+    ///
+    /// # Errors
+    ///
+    /// 版本不存在([`InstallError::version_not_found`])、网络失败、继承链断裂/成环、
+    /// JSON 非法。
+    pub async fn resolve(&self, version_id: &str) -> Result {
+        let (manifest, _) = self.manifest(false).await?;
+        let root = self.load_one(version_id, &manifest).await?;
+
+        let mut table: BTreeMap = BTreeMap::new();
+        table.insert(version_id.to_string(), root.clone());
+        let mut visited: BTreeSet = BTreeSet::new();
+        visited.insert(version_id.to_string());
+        let mut pending = vec![root.clone()];
+        let mut depth = 0_usize;
+
+        while let Some(layer) = pending.pop() {
+            let Some(parent_id) = layer.inherits_from.clone() else {
+                continue;
+            };
+            depth += 1;
+            if depth > MAX_CHAIN_DEPTH {
+                return Err(InstallError::new(
+                    InstallErrorKind::Metadata,
+                    format!("版本 {version_id} 的继承链超过 {MAX_CHAIN_DEPTH} 层,疑似清单损坏"),
+                ));
+            }
+            // 环不在这里报:交给 resolve 给出完整 id 链(信息更全)。
+            if !visited.insert(parent_id.clone()) {
+                continue;
+            }
+            let parent = self.load_one(&parent_id, &manifest).await?;
+            pending.push(parent.clone());
+            table.insert(parent_id, parent);
+        }
+
+        inherit::resolve(&table, &root)
+            .map_err(|error| InstallError::new(InstallErrorKind::Metadata, error.to_string()))
+    }
+
+    /// 取资源索引,返回(索引模型,原始字节)。
+    ///
+    /// 原始字节一并返回,是因为**规划必须先有索引内容**才能枚举对象;这份字节随后由
+    /// [`super::execute`] 原子落到 `assets/indexes/.json`,不重复下载。
+    ///
+    /// # Errors
+    ///
+    /// 索引没有下载地址、网络失败、sha1 不符或 JSON 非法。
+    pub async fn asset_index(
+        &self,
+        reference: &AssetIndexRef,
+    ) -> Result<(AssetIndex, Vec), InstallError> {
+        let url = reference.url.clone().ok_or_else(|| {
+            InstallError::new(
+                InstallErrorKind::Metadata,
+                format!("资源索引 {} 没有下载地址", reference.id),
+            )
+        })?;
+        let bytes = self
+            .fetch_bytes(ResourceKind::AssetIndex, &url, reference.sha1.as_deref())
+            .await?;
+        let index = AssetIndex::from_json_slice(&bytes)?;
+        Ok((index, bytes))
+    }
+
+    /// 拉清单并落缓存(成功才写,失败不动旧缓存)。
+    async fn fetch_manifest(&self, cache_path: &Path) -> Result {
+        let request = DownloadRequest::new(
+            ResourceKind::VersionManifest,
+            self.manifest_url.clone(),
+            cache_path,
+        );
+        let bytes = self.engine.download_bytes(request).await?;
+        let mut list =
+            VersionList::from_json_str(std::str::from_utf8(&bytes).map_err(|_| {
+                InstallError::new(InstallErrorKind::Metadata, "版本清单不是 UTF-8")
+            })?)
+            .map_err(|error| InstallError::new(InstallErrorKind::Metadata, error.to_string()))?;
+        // 官方清单没有的远古版本/魔改条目由内嵌补充清单兜底(analyse/03 §2.3)。
+        unlisted::merge_into(&mut list);
+        let cache = ManifestCache {
+            fetched_at_ms: self.now_ms,
+            list: list.clone(),
+        };
+        let json = serde_json::to_vec(&cache).map_err(|error| {
+            InstallError::new(
+                InstallErrorKind::Metadata,
+                format!("序列化清单缓存失败:{error}"),
+            )
+        })?;
+        write_atomic(cache_path, &json)?;
+        Ok(list)
+    }
+
+    /// 取一份版本 JSON:远端优先(带 v2 的 sha1 校验),本地 `versions//.json` 兜底。
+    async fn load_one(
+        &self,
+        id: &str,
+        manifest: &VersionList,
+    ) -> Result {
+        let remote = match manifest.find(id) {
+            Some(entry) => Some(self.fetch_version_json(entry).await),
+            None => None,
+        };
+        match remote {
+            Some(Ok(version)) => Ok(version),
+            Some(Err(remote_error)) => {
+                let local = self.read_local(id)?;
+                match local {
+                    Some(version) => Ok(version),
+                    None => Err(remote_error),
+                }
+            }
+            None => match self.read_local(id)? {
+                Some(version) => Ok(version),
+                None => Err(InstallError::version_not_found(id)),
+            },
+        }
+    }
+
+    async fn fetch_version_json(&self, entry: &RemoteVersion) -> Result {
+        let bytes = self
+            .fetch_bytes(
+                ResourceKind::VersionManifest,
+                &entry.url,
+                entry.sha1.as_deref(),
+            )
+            .await?;
+        VersionJson::from_json_slice(&bytes).map_err(InstallError::from)
+    }
+
+    async fn fetch_bytes(
+        &self,
+        kind: ResourceKind,
+        url: &str,
+        sha1: Option<&str>,
+    ) -> Result, InstallError> {
+        let mut request = DownloadRequest::new(kind, url, self.meta_cache.join("download.bin"));
+        if let Some(hex) = sha1.filter(|hex| !hex.is_empty()) {
+            request = request.with_sha1(hex);
+        }
+        Ok(self.engine.download_bytes(request).await?)
+    }
+
+    /// 读本地版本 JSON(`versions//.json`);不存在返回 `Ok(None)`。
+    fn read_local(&self, id: &str) -> Result, InstallError> {
+        let path: PathBuf = self.game.version_json(id);
+        match std::fs::read(&path) {
+            Ok(bytes) => VersionJson::from_json_slice(&bytes)
+                .map(Some)
+                .map_err(|error| InstallError::new(InstallErrorKind::Metadata, error.to_string())),
+            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
+            Err(error) => Err(InstallError::new(
+                InstallErrorKind::Io,
+                format!("读 {} 失败:{error}", path.display()),
+            )),
+        }
+    }
+}
+
+/// 读清单缓存(文件缺失/损坏都当没有,不报错——缓存坏了就重新拉)。
+fn read_cache(path: &Path) -> Option {
+    let bytes = std::fs::read(path).ok()?;
+    serde_json::from_slice(&bytes).ok()
+}
+
+/// 当前毫秒时间戳(缓存 TTL 判定用;与下载引擎同一个时钟来源)。
+pub use crate::download::cache::now_ms;
+
+/// 把 [`GameError`] 转成"缺元数据"错误(供上层统一处理)。
+#[must_use]
+pub fn metadata_error(error: GameError) -> InstallError {
+    InstallError::new(InstallErrorKind::Metadata, error.to_string())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{metadata_error, now_ms, read_cache, ManifestCache, MANIFEST_TTL_MS};
+    use crate::game::error::GameError;
+    use crate::game::version::VersionList;
+
+    #[test]
+    fn ttl_constant_is_half_a_day() {
+        assert_eq!(MANIFEST_TTL_MS, 43_200_000);
+    }
+
+    #[test]
+    fn now_ms_is_a_plausible_wall_clock() {
+        // 2020-01-01 之后、2100 年之前:只用来挡住"单位错成秒"这类退化。
+        let now = now_ms();
+        assert!(now > 1_577_836_800_000, "now_ms 太小:{now}");
+        assert!(now < 4_102_444_800_000, "now_ms 太大:{now}");
+    }
+
+    #[test]
+    fn cache_roundtrip_and_corruption_tolerance() {
+        let dir = std::env::temp_dir().join(format!("ull-manifest-cache-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).expect("建临时目录");
+        let path = dir.join("version_manifest_v2.json");
+
+        let cache = ManifestCache {
+            fetched_at_ms: 123,
+            list: VersionList::default(),
+        };
+        std::fs::write(&path, serde_json::to_vec(&cache).expect("序列化")).expect("写");
+        let read = read_cache(&path).expect("应读回");
+        assert_eq!(read.fetched_at_ms, 123);
+
+        std::fs::write(&path, b"not json").expect("写坏内容");
+        assert!(read_cache(&path).is_none());
+
+        assert!(read_cache(&dir.join("missing.json")).is_none());
+        std::fs::remove_dir_all(&dir).ok();
+    }
+
+    #[test]
+    fn game_error_becomes_metadata_error() {
+        let error = metadata_error(GameError::MissingVersionId);
+        assert_eq!(error.kind().as_str(), "install_metadata");
+    }
+}
diff --git a/crates/core/src/game/install/tests/execute.rs b/crates/core/src/game/install/tests/execute.rs
new file mode 100644
index 0000000..80d5271
--- /dev/null
+++ b/crates/core/src/game/install/tests/execute.rs
@@ -0,0 +1,270 @@
+//! 落盘执行:跳过判定(三级)、幂等重装、预取元数据落盘、失败分类聚合。
+//!
+//! 全部用例都**已把文件放好**或**只做纯函数判定**,因此不发网络请求。
+
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
+use std::sync::Arc;
+
+use crate::download::verify;
+use crate::download::ResourceKind;
+use crate::download::{
+    ContentCache, DownloadEngine, DownloadErrorKind, EngineConfig, HashAlgo, HealthRegistry,
+    MirrorTable, RegionProfile,
+};
+use crate::game::arguments::Environment;
+use crate::game::install::assets::{AssetIndex, AssetObject};
+use crate::game::install::execute::{current_bytes, execute, FileOutcome, FileStatus};
+use crate::game::install::plan::{FileRole, InstallPlan, PlanFile, PlanInput};
+use crate::game::layout::GameLayout;
+use crate::game::manifest::VersionJson;
+
+fn temp_root(tag: &str) -> PathBuf {
+    static COUNTER: AtomicU64 = AtomicU64::new(0);
+    let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
+    std::env::temp_dir().join(format!(
+        "ull-install-exec-{}-{tag}-{unique}",
+        std::process::id()
+    ))
+}
+
+fn engine(root: &Path) -> DownloadEngine {
+    DownloadEngine::new(
+        EngineConfig::default(),
+        Arc::new(MirrorTable::builtin()),
+        Arc::new(RegionProfile::direct(0)),
+        Arc::new(HealthRegistry::in_memory()),
+        Arc::new(ContentCache::open(root.join("cache"))),
+    )
+}
+
+fn sha1_of(bytes: &[u8]) -> String {
+    verify::digest_bytes(HashAlgo::Sha1, bytes)
+}
+
+fn write_file(path: &Path, bytes: &[u8]) {
+    std::fs::create_dir_all(path.parent().expect("有父目录")).expect("建目录");
+    std::fs::write(path, bytes).expect("写文件");
+}
+
+#[test]
+fn current_bytes_matches_by_sha1_then_size_then_existence() {
+    let root = temp_root("current");
+    let payload = b"client jar payload";
+    let dest: PathBuf = root.join("versions/1.20.1/1.20.1.jar");
+    write_file(&dest, payload);
+
+    let with_sha1 = PlanFile {
+        role: FileRole::ClientJar,
+        name: "1.20.1".to_string(),
+        url: "https://example.invalid/x.jar".to_string(),
+        sha1: Some(sha1_of(payload)),
+        size: Some(payload.len() as u64),
+        dest: dest.clone(),
+        resource: ResourceKind::ClientJar,
+    };
+    assert_eq!(current_bytes(&with_sha1), Some(payload.len() as u64));
+
+    let wrong_sha1 = PlanFile {
+        sha1: Some("0".repeat(40)),
+        ..with_sha1.clone()
+    };
+    assert_eq!(current_bytes(&wrong_sha1), None, "sha1 不符必须重下");
+
+    let size_only = PlanFile {
+        sha1: None,
+        size: Some(payload.len() as u64),
+        ..with_sha1.clone()
+    };
+    assert_eq!(current_bytes(&size_only), Some(payload.len() as u64));
+
+    let size_mismatch = PlanFile {
+        sha1: None,
+        size: Some(1),
+        ..with_sha1.clone()
+    };
+    assert_eq!(current_bytes(&size_mismatch), None);
+
+    let bare = PlanFile {
+        sha1: None,
+        size: None,
+        ..with_sha1.clone()
+    };
+    assert_eq!(current_bytes(&bare), Some(payload.len() as u64), "存在即信");
+
+    let missing = PlanFile {
+        dest: root.join("nope.jar"),
+        ..with_sha1
+    };
+    assert_eq!(current_bytes(&missing), None);
+    std::fs::remove_dir_all(&root).ok();
+}
+
+/// 一份"客户端 jar 已存在"的清单 + 计划,用于幂等与跳过用例。
+fn plan_with_existing_jar(root: &Path) -> (InstallPlan, Vec) {
+    let payload = b"already installed client jar".to_vec();
+    let layout = GameLayout::new(root);
+    let sha1 = sha1_of(&payload);
+    write_file(&layout.version_jar("1.20.1"), &payload);
+
+    let resolved = VersionJson::from_json_str(&format!(
+        r#"{{"id": "1.20.1", "mainClass": "net.minecraft.client.main.Main",
+            "downloads": {{"client": {{"sha1": "{sha1}", "size": {}, "url": "https://piston-data.mojang.com/v1/objects/{sha1}/client.jar"}}}}}}"#,
+        payload.len()
+    ))
+    .expect("清单应可解析");
+
+    let plan = InstallPlan::build(PlanInput {
+        layout: &layout,
+        version_id: "1.20.1",
+        resolved: &resolved,
+        asset_index: None,
+        environment: &Environment::host(),
+    })
+    .expect("组计划应成功");
+    (plan, payload)
+}
+
+#[tokio::test]
+async fn skips_existing_files_and_writes_version_json() {
+    let root = temp_root("skip");
+    let (plan, payload) = plan_with_existing_jar(&root);
+    let engine = engine(&root);
+    let calls = Arc::new(AtomicUsize::new(0));
+    let sink_calls = Arc::clone(&calls);
+
+    let report = execute(
+        &engine,
+        &plan,
+        4,
+        Some(Arc::new(move |_outcome: &FileOutcome| {
+            sink_calls.fetch_add(1, Ordering::Relaxed);
+        })),
+    )
+    .await
+    .expect("执行应成功");
+
+    assert!(report.is_complete());
+    assert_eq!(report.downloaded(), 0, "已存在且校验通过的文件不该重下");
+    assert_eq!(report.skipped(), 1);
+    assert_eq!(report.bytes(), 0);
+    assert_eq!(calls.load(Ordering::Relaxed), 1, "每个文件回调一次");
+
+    // 版本清单必须落盘且能被重新解析。
+    let version_json = report.version_json.clone().expect("全量成功应写版本清单");
+    let text = std::fs::read_to_string(&version_json).expect("版本清单应已写盘");
+    let written = VersionJson::from_json_str(&text).expect("写出的清单应可解析");
+    assert_eq!(written.id.as_deref(), Some("1.20.1"));
+    assert_eq!(
+        written.main_class.as_deref(),
+        Some("net.minecraft.client.main.Main")
+    );
+
+    // 盘上内容没被改动(跳过 = 不动文件)。
+    assert_eq!(
+        std::fs::read(GameLayout::new(&root).version_jar("1.20.1")).expect("读 jar"),
+        payload
+    );
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[tokio::test]
+async fn reinstalling_twice_is_idempotent() {
+    let root = temp_root("idempotent");
+    let (plan, _) = plan_with_existing_jar(&root);
+    let engine = engine(&root);
+    let first = execute(&engine, &plan, 2, None).await.expect("第一次");
+    let second = execute(&engine, &plan, 2, None).await.expect("第二次");
+
+    assert!(first.is_complete() && second.is_complete());
+    assert_eq!(second.downloaded(), 0);
+    assert_eq!(second.skipped(), second.files.len());
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[tokio::test]
+async fn prefetched_metadata_is_written_without_network() {
+    let root = temp_root("prefetch");
+    let layout = GameLayout::new(&root);
+    let hash = "0f9d6b1c0d5f4e3b2a1908070605040302010f0e";
+    // 规划期"已经下载到内存"的资源索引字节(sha1 必须与清单声明一致,否则跳过判定不成立)。
+    let index_bytes = br#"{"objects":{}}"#.to_vec();
+    let index_sha1 = sha1_of(&index_bytes);
+    let resolved = VersionJson::from_json_str(&format!(
+        r#"{{"id": "1.20.1", "assetIndex": {{"id": "17", "sha1": "{index_sha1}", "size": {}, "url": "https://piston-meta.mojang.com/v1/packages/{index_sha1}/17.json"}}}}"#,
+        index_bytes.len()
+    ))
+    .expect("清单应可解析");
+    let mut index = AssetIndex::default();
+    index.objects.insert(
+        "minecraft/lang/zh_cn.json".to_string(),
+        AssetObject {
+            hash: hash.to_string(),
+            size: 3,
+        },
+    );
+
+    let mut plan = InstallPlan::build(PlanInput {
+        layout: &layout,
+        version_id: "1.20.1",
+        resolved: &resolved,
+        asset_index: Some(&index),
+        environment: &Environment::host(),
+    })
+    .expect("组计划应成功");
+
+    let index_dest = layout.asset_index("17");
+    plan.prefetch(index_dest.clone(), index_bytes.clone());
+
+    // 只跑索引这一个文件:把资源对象从计划里摘掉,避免真发网络请求。
+    plan.files.retain(|file| file.role != FileRole::Asset);
+    let engine = engine(&root);
+    let report = execute(&engine, &plan, 2, None).await.expect("执行应成功");
+
+    assert!(report.is_complete(), "不该有失败:{:?}", report.failures(3));
+    assert_eq!(report.downloaded(), 1);
+    assert_eq!(
+        std::fs::read(&index_dest).expect("索引应已写盘"),
+        index_bytes
+    );
+    let outcome = report
+        .files
+        .iter()
+        .find(|file| file.dest == index_dest)
+        .expect("应有索引结果");
+    assert_eq!(outcome.source.as_deref(), Some("metadata"));
+
+    // 重跑:索引已在盘上且 sha1 一致 → 跳过,不重写、不算"下载"。
+    let again = execute(&engine, &plan, 2, None).await.expect("第二次");
+    assert_eq!(again.downloaded(), 0, "重装不该再写索引");
+    assert_eq!(again.skipped(), 1);
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn failure_kind_prefers_integrity() {
+    let failed = |kind: DownloadErrorKind| FileOutcome {
+        role: FileRole::Asset,
+        name: "x".to_string(),
+        dest: PathBuf::from("/x"),
+        status: FileStatus::Failed,
+        bytes: 0,
+        source: None,
+        failure: Some(crate::game::install::FileFailure {
+            kind,
+            message: "boom".to_string(),
+        }),
+    };
+    let report = crate::game::install::InstallReport {
+        files: vec![
+            failed(DownloadErrorKind::AllSourcesFailed),
+            failed(DownloadErrorKind::Integrity),
+        ],
+        version_json: None,
+        warnings: Vec::new(),
+    };
+    assert_eq!(report.failed(), 2);
+    assert!(!report.is_complete());
+    assert_eq!(report.failure_kind(), Some(DownloadErrorKind::Integrity));
+    assert_eq!(report.failures(1).len(), 1);
+}
diff --git a/crates/core/src/game/install/tests/instance.rs b/crates/core/src/game/install/tests/instance.rs
new file mode 100644
index 0000000..797859c
--- /dev/null
+++ b/crates/core/src/game/install/tests/instance.rs
@@ -0,0 +1,122 @@
+//! 实例登记:幂等(同版本同名复用)、`isolation: inherit` 不落盘、两份文件各就各位。
+
+use std::path::PathBuf;
+
+use crate::game::install::instance::{
+    read_instance_config, read_profiles, register, InstanceConfig, Isolation,
+};
+use crate::platform::layout::LauncherLayout;
+
+fn temp_layout(tag: &str) -> (LauncherLayout, PathBuf) {
+    let unique = format!(
+        "{}-{}-{tag}",
+        std::process::id(),
+        std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .map(|d| d.as_nanos())
+            .unwrap_or(0)
+    );
+    let root = std::env::temp_dir().join(format!("ull-install-instance-{unique}"));
+    let layout = LauncherLayout::new(&root);
+    layout.ensure().expect("建四分区");
+    (layout, root)
+}
+
+#[test]
+fn registers_profile_and_sparse_instance_config() {
+    let (layout, root) = temp_layout("new");
+    let registration = register(
+        &layout,
+        "1.20.1",
+        "1.20.1",
+        Some("2026-10-30T00:00:00.000Z"),
+    )
+    .expect("登记应成功");
+    assert!(registration.created);
+    assert!(
+        registration.profile.id.is_uuid_shaped(),
+        "实例 id 必须是稳定 UUID 形状"
+    );
+    assert!(registration.config_path.is_file());
+
+    let profiles = read_profiles(&layout).expect("读实例清单");
+    assert_eq!(profiles.len(), 1);
+    assert_eq!(profiles.profiles[0].version_id, "1.20.1");
+    assert_eq!(
+        profiles.profiles[0].created_at.as_deref(),
+        Some("2026-10-30T00:00:00.000Z")
+    );
+
+    // 每实例设置:id / versionId / schemaVersion 在,isolation 缺省不落盘。
+    let text = std::fs::read_to_string(®istration.config_path).expect("读设置文件");
+    assert!(
+        text.contains("\"versionId\": \"1.20.1\""),
+        "键名用 camelCase:{text}"
+    );
+    assert!(
+        !text.contains("isolation"),
+        "inherit 是默认值且不落盘:{text}"
+    );
+    assert!(!text.contains("gameDir"), "非 custom 不写 gameDir:{text}");
+
+    let config = read_instance_config(&layout, registration.profile.id.as_str())
+        .expect("读设置")
+        .expect("设置文件应存在");
+    assert!(config.inherits_isolation());
+    assert_eq!(config.id, registration.profile.id.as_str());
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn reinstall_same_version_reuses_instance() {
+    let (layout, root) = temp_layout("idempotent");
+    let first = register(&layout, "1.20.1", "1.20.1", None).expect("第一次登记");
+    let second = register(&layout, "1.20.1", "1.20.1", None).expect("第二次登记");
+
+    assert!(!second.created, "同版本同名应复用");
+    assert_eq!(second.profile.id.as_str(), first.profile.id.as_str());
+    assert_eq!(read_profiles(&layout).expect("读清单").len(), 1);
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn distinct_names_create_distinct_instances_of_same_version() {
+    let (layout, root) = temp_layout("names");
+    let a = register(&layout, "生存", "1.20.1", None).expect("A");
+    let b = register(&layout, "创造", "1.20.1", None).expect("B");
+
+    assert!(a.created && b.created);
+    assert_ne!(a.profile.id.as_str(), b.profile.id.as_str());
+    assert_eq!(read_profiles(&layout).expect("读清单").len(), 2);
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn instance_config_roundtrip_keeps_explicit_isolation() {
+    let mut config = InstanceConfig::new("11111111-2222-4333-8444-555555555555", "1.20.1");
+    config.isolation = Some(Isolation::Isolated);
+    let text = config.to_json_string().expect("序列化");
+    assert!(text.contains("\"isolation\": \"isolated\""));
+    let back = InstanceConfig::from_json_str(&text).expect("反序列化");
+    assert_eq!(back.isolation, Some(Isolation::Isolated));
+    assert!(!back.inherits_isolation());
+    assert_eq!(Isolation::parse("custom"), Some(Isolation::Custom));
+    assert_eq!(Isolation::parse("inherit"), None, "inherit 不是显式值");
+}
+
+#[test]
+fn registers_legacy_profile_without_config_file() {
+    let (layout, root) = temp_layout("legacy");
+    // 早期 CLI 只写 profiles.json(没有 instances/.json)。
+    let profile = crate::game::profiles::InstanceProfile::new("老实例", "1.20.1");
+    let id = profile.id.as_str().to_string();
+    let mut profiles = crate::game::profiles::ProfileList::default();
+    profiles.upsert(profile);
+    super::super::instance::write_profiles(&layout, &profiles).expect("写清单");
+
+    let registration = register(&layout, "老实例", "1.20.1", None).expect("补登记");
+    assert!(!registration.created, "已有档案应复用");
+    assert_eq!(registration.profile.id.as_str(), id);
+    assert!(registration.config_path.is_file(), "缺失的设置文件应补齐");
+    std::fs::remove_dir_all(&root).ok();
+}
diff --git a/crates/core/src/game/install/tests/mod.rs b/crates/core/src/game/install/tests/mod.rs
new file mode 100644
index 0000000..21cecbe
--- /dev/null
+++ b/crates/core/src/game/install/tests/mod.rs
@@ -0,0 +1,8 @@
+//! `game::install` 的单元测试:计划正确性 / 跳过与幂等 / 实例登记。
+//!
+//! 刻意**不打真实网络**:计划是纯计算,跳过判定只看盘,实例登记只看文件系统。
+//! 需要"引擎"的用例只要求它"不联网也能跑"(全部文件已就位 → 全跳过)。
+
+mod execute;
+mod instance;
+mod plan;
diff --git a/crates/core/src/game/install/tests/plan.rs b/crates/core/src/game/install/tests/plan.rs
new file mode 100644
index 0000000..dddf582
--- /dev/null
+++ b/crates/core/src/game/install/tests/plan.rs
@@ -0,0 +1,285 @@
+//! 计划正确性:多级继承折叠后的文件清单、URL/sha1/落点逐项、JSON 形状。
+
+use std::sync::atomic::{AtomicU64, Ordering};
+
+use crate::game::arguments::Environment;
+use crate::game::inherit::resolve;
+use crate::game::install::assets::{AssetIndex, AssetObject};
+use crate::game::install::plan::{FileRole, InstallPlan, PlanInput};
+use crate::game::layout::GameLayout;
+use crate::game::manifest::VersionJson;
+
+fn temp_root(tag: &str) -> std::path::PathBuf {
+    static COUNTER: AtomicU64 = AtomicU64::new(0);
+    let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
+    std::env::temp_dir().join(format!(
+        "ull-install-plan-{}-{tag}-{unique}",
+        std::process::id()
+    ))
+}
+
+/// 三层继承:base(客户端 jar + 资源索引 + 库)← mid(库)← top(库)。
+fn resolved_three_levels() -> VersionJson {
+    let base = VersionJson::from_json_str(
+        r#"{
+            "id": "base",
+            "mainClass": "net.minecraft.client.main.Main",
+            "downloads": {"client": {"sha1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 100, "url": "https://piston-data.mojang.com/v1/objects/aa/client.jar"}},
+            "assetIndex": {"id": "17", "sha1": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "size": 200, "totalSize": 300, "url": "https://piston-meta.mojang.com/v1/packages/bb/17.json"},
+            "assets": "17",
+            "libraries": [
+                {"name": "org.ow2.asm:asm:9.6", "downloads": {"artifact": {"path": "org/ow2/asm/asm/9.6/asm-9.6.jar", "sha1": "c1", "size": 11, "url": "https://libraries.minecraft.net/org/ow2/asm/asm/9.6/asm-9.6.jar"}}},
+                {"name": "org.lwjgl:lwjgl:3.3.2", "rules": [{"action": "allow", "os": {"name": "linux"}}], "downloads": {"artifact": {"url": "https://libraries.minecraft.net/lwjgl-linux-only.jar", "sha1": "c2", "size": 5}}}
+            ]
+        }"#,
+    )
+    .expect("base 清单应可解析");
+    let mid = VersionJson::from_json_str(
+        r#"{"id": "mid", "inheritsFrom": "base", "libraries": [
+            {"name": "net.minecraftforge:forge:1.20.1-47.2.0", "downloads": {"artifact": {"path": "net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0.jar", "sha1": "c3", "size": 22, "url": "https://maven.minecraftforge.net/forge.jar"}}}
+        ]}"#,
+    )
+    .expect("mid 清单应可解析");
+    let top = VersionJson::from_json_str(
+        r#"{"id": "top", "inheritsFrom": "mid", "libraries": [
+            {"name": "top.patch:core:1.0", "downloads": {"artifact": {"path": "top/patch/core/1.0/core-1.0.jar", "sha1": "c4", "size": 33, "url": "https://example.invalid/core-1.0.jar"}}}
+        ], "logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}", "file": {"id": "client-1.12.xml", "sha1": "dd", "size": 44, "url": "https://piston-data.mojang.com/v1/objects/dd/client-1.12.xml"}}}}"#,
+    )
+    .expect("top 清单应可解析");
+
+    let mut table = std::collections::BTreeMap::new();
+    table.insert("base".to_string(), base);
+    table.insert("mid".to_string(), mid);
+    table.insert("top".to_string(), top.clone());
+    resolve(&table, &top).expect("三层继承应解析成功")
+}
+
+fn asset_index() -> AssetIndex {
+    let mut index = AssetIndex::default();
+    index.objects.insert(
+        "minecraft/lang/zh_cn.json".to_string(),
+        AssetObject {
+            hash: "0f9d6b1c0d5f4e3b2a1908070605040302010f0e".to_string(),
+            size: 55,
+        },
+    );
+    index
+}
+
+fn build(layout: &GameLayout, resolved: &VersionJson, index: Option<&AssetIndex>) -> InstallPlan {
+    InstallPlan::build(PlanInput {
+        layout,
+        version_id: "top",
+        resolved,
+        asset_index: index,
+        environment: &Environment::host(),
+    })
+    .expect("组计划应成功")
+}
+
+#[test]
+fn folds_inheritance_chain_into_one_plan() {
+    let root = temp_root("chain");
+    let layout = GameLayout::new(&root);
+    let resolved = resolved_three_levels();
+    // 三层库都保留(父在前、子在后,去重后按角色/路径排序)。
+    assert_eq!(resolved.libraries.as_ref().map(Vec::len), Some(4));
+
+    let index = asset_index();
+    let plan = build(&layout, &resolved, Some(&index));
+
+    // 客户端 jar 1 + 库 4(linux 规则那条在 Windows 上被过滤)+ 索引 1 + 资源 1 + 日志配置 1。
+    let host_linux = Environment::host().os == "linux";
+    let expected_libraries = if host_linux { 4 } else { 3 };
+    assert_eq!(
+        plan.by_role()
+            .get(&FileRole::ClientJar)
+            .map(|entry| entry.0),
+        Some(1)
+    );
+    assert_eq!(
+        plan.by_role().get(&FileRole::Library).map(|entry| entry.0),
+        Some(expected_libraries)
+    );
+    assert_eq!(
+        plan.by_role()
+            .get(&FileRole::AssetIndex)
+            .map(|entry| entry.0),
+        Some(1)
+    );
+    assert_eq!(
+        plan.by_role().get(&FileRole::Asset).map(|entry| entry.0),
+        Some(1)
+    );
+    assert_eq!(
+        plan.by_role()
+            .get(&FileRole::LogConfig)
+            .map(|entry| entry.0),
+        Some(1)
+    );
+    assert!(plan.warnings.is_empty(), "不应有告警:{:?}", plan.warnings);
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn client_jar_lands_in_version_dir_with_manifest_hash() {
+    let root = temp_root("jar");
+    let layout = GameLayout::new(&root);
+    let resolved = resolved_three_levels();
+    let plan = build(&layout, &resolved, None);
+
+    let jar = plan
+        .files
+        .iter()
+        .find(|file| file.role == FileRole::ClientJar)
+        .expect("应有客户端 jar");
+    assert_eq!(plan.jar_id, "top");
+    assert!(jar.dest.ends_with("versions/top/top.jar"));
+    assert_eq!(
+        jar.sha1.as_deref(),
+        Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
+    );
+    assert_eq!(jar.size, Some(100));
+    assert_eq!(
+        jar.url,
+        "https://piston-data.mojang.com/v1/objects/aa/client.jar"
+    );
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn asset_object_paths_and_hashes_are_exact() {
+    let root = temp_root("assets");
+    let layout = GameLayout::new(&root);
+    let resolved = resolved_three_levels();
+    let index = asset_index();
+    let plan = build(&layout, &resolved, Some(&index));
+
+    let object = plan
+        .files
+        .iter()
+        .find(|file| file.role == FileRole::Asset)
+        .expect("应有资源对象");
+    assert_eq!(
+        object.url,
+        "https://resources.download.minecraft.net/0f/0f9d6b1c0d5f4e3b2a1908070605040302010f0e"
+    );
+    assert_eq!(
+        object.sha1.as_deref(),
+        Some("0f9d6b1c0d5f4e3b2a1908070605040302010f0e")
+    );
+    assert_eq!(object.size, Some(55));
+    assert!(object
+        .dest
+        .ends_with("assets/objects/0f/0f9d6b1c0d5f4e3b2a1908070605040302010f0e"));
+
+    let index_file = plan
+        .files
+        .iter()
+        .find(|file| file.role == FileRole::AssetIndex)
+        .expect("应有资源索引");
+    assert!(index_file.dest.ends_with("assets/indexes/17.json"));
+    assert_eq!(
+        index_file.sha1.as_deref(),
+        Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
+    );
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn shares_libraries_across_versions_and_sums_totals() {
+    let root = temp_root("totals");
+    let layout = GameLayout::new(&root);
+    let resolved = resolved_three_levels();
+    let index = asset_index();
+    let plan = build(&layout, &resolved, Some(&index));
+
+    let (total, unknown) = plan.total_bytes();
+    // jar 100 + 索引 200 + 资源 55 + 日志 44 + 各库大小(host 上生效的那几条)。
+    assert_eq!(unknown, 0);
+    assert!(total >= 399, "总大小应至少含 jar+索引+资源+日志:{total}");
+
+    // 库落恒共享层(不是实例私有)。
+    for file in plan
+        .files
+        .iter()
+        .filter(|file| file.role == FileRole::Library)
+    {
+        assert!(file.dest.starts_with(root.join("libraries")));
+    }
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn warns_instead_of_inventing_urls_for_legacy_manifests() {
+    let root = temp_root("legacy");
+    let layout = GameLayout::new(&root);
+    let legacy = VersionJson::from_json_str(r#"{"id": "c0.30_01c", "assets": "legacy"}"#)
+        .expect("旧清单应可解析");
+    let plan = build(&layout, &legacy, None);
+
+    assert!(plan.files.is_empty(), "旧清单没有可下文件");
+    assert_eq!(
+        plan.warnings.len(),
+        2,
+        "应分别告警缺 jar 与缺索引:{:?}",
+        plan.warnings
+    );
+    assert!(plan.warnings.iter().any(|w| w.contains("downloads.client")));
+    assert!(plan.warnings.iter().any(|w| w.contains("assetIndex")));
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn json_shape_is_stable_for_dry_run_consumers() {
+    let root = temp_root("json");
+    let layout = GameLayout::new(&root);
+    let resolved = resolved_three_levels();
+    let index = asset_index();
+    let plan = build(&layout, &resolved, Some(&index));
+
+    let summary = plan.summary_json();
+    assert_eq!(summary["version_id"], "top");
+    assert_eq!(summary["jar_id"], "top");
+    assert_eq!(summary["file_count"], plan.file_count());
+    assert!(summary["total_bytes"].as_u64().unwrap_or(0) > 0);
+    assert!(summary["by_role"]["client_jar"]["files"].as_u64() == Some(1));
+    assert!(summary["warnings"].is_array());
+
+    let files = plan.files_json();
+    assert_eq!(files.len(), plan.file_count());
+    for row in &files {
+        assert!(row["role"].as_str().is_some());
+        assert!(row["url"].as_str().is_some());
+        assert!(row["dest"].as_str().is_some());
+        assert!(row["resource"].as_str().is_some());
+    }
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn deduplicates_same_destination_and_prefers_hashed_copy() {
+    let root = temp_root("dedupe");
+    let layout = GameLayout::new(&root);
+    let resolved = VersionJson::from_json_str(
+        r#"{"id": "1.20.1", "libraries": [
+            {"name": "a.b:c:1", "downloads": {"artifact": {"path": "a/b/c/1/c-1.jar", "url": "https://libraries.minecraft.net/c-1.jar"}}},
+            {"name": "a.b:c:1", "downloads": {"artifact": {"path": "a/b/c/1/c-1.jar", "sha1": "ff", "size": 9, "url": "https://libraries.minecraft.net/c-1.jar"}}}
+        ]}"#,
+    )
+    .expect("清单应可解析");
+    let plan = build(&layout, &resolved, None);
+
+    let libraries: Vec<_> = plan
+        .files
+        .iter()
+        .filter(|file| file.role == FileRole::Library)
+        .collect();
+    assert_eq!(libraries.len(), 1, "同一落点只应留一条");
+    assert_eq!(
+        libraries[0].sha1.as_deref(),
+        Some("ff"),
+        "应保留带 sha1 的那条"
+    );
+    std::fs::remove_dir_all(&root).ok();
+}
diff --git a/crates/core/src/game/isolation.rs b/crates/core/src/game/isolation.rs
new file mode 100644
index 0000000..e575962
--- /dev/null
+++ b/crates/core/src/game/isolation.rs
@@ -0,0 +1,345 @@
+//! 实例隔离档位的**派生**与**切换检测**(D20 定案)。
+//!
+//! 依据:`DevDocs/PlanDoc/research/04-launcher-configs.md` §5.1-§5.2(键草案 + 切换检测)、
+//! `DevDocs/analyse/07-settings-packaging-update.md` §2.4(HMCL 用 `runningDirectory` 覆盖表达隔离)、
+//! `DevDocs/PlanDoc/04-data-and-config.md` §4(实例模型)。
+//!
+//! # D20 的语义(用户定案,不可改)
+//!
+//! - **默认隔离**:全局 `instances.mode` 默认 `isolated`,用户可手动切 `shared`;
+//! - **恒共享层不可配**:`versions/` `assets/` `libraries/` 永远在游戏根,隔离只改**运行目录**
+//!   (= `--gameDir` 的取值 + 进程 CWD),见 [`SHARED_LAYERS`];
+//! - **切换不迁移 + 双侧内容提示**:切档只改路径语义,不动任何数据;两侧都有内容时提示
+//!   ([`ModeChange::take_warning`]),避免「存档消失」错觉。
+//!
+//! # 覆盖链(三层,左优先)
+//!
+//! ```text
+//! 临时启动参数(--game-dir 之类)  >  实例 isolation  >  全局 instances.mode
+//! ```
+//!
+//! `isolation` 的 `inherit` 不是第四个档位,而是「键不存在」→ 落到全局([`IsolationMode`]);
+//! 这样改全局对存量实例立即生效,不需要迁移(research/04 §5.3 的 PCL2 反面教材)。
+//!
+//! 本模块只做**纯路径计算 + 只读探测**:不建目录、不搬文件、不写配置。
+
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+use super::error::GameError;
+use super::install::instance::{InstanceConfig, Isolation};
+use super::layout::GameLayout;
+
+/// 恒共享层(任何隔离档位都不按实例复制;natives 例外,按实例解包)。
+pub const SHARED_LAYERS: &[&str] = &["versions", "assets", "libraries"];
+
+/// 切换检测探测的三类内容目录(两家启动器一致:只关心玩家数据)。
+pub const PROBED_CONTENT_DIRS: &[&str] = &["mods", "saves", "config"];
+
+/// 全局默认隔离模式(`launcher.json` 的 `instances.mode`)。
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum IsolationMode {
+    /// 每实例运行目录 = `versions//`(D20 默认)。
+    #[default]
+    Isolated,
+    /// 所有实例共用 `.minecraft` 根(HMCL 传统单目录形态)。
+    Shared,
+}
+
+impl IsolationMode {
+    /// 稳定字符串 id(与 `setting::keys::INSTANCE_MODES` 同字面量)。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Isolated => "isolated",
+            Self::Shared => "shared",
+        }
+    }
+
+    /// 从字符串解析(未知值返回 `None`,由调用方决定报错还是取默认)。
+    #[must_use]
+    pub fn parse(raw: &str) -> Option {
+        match raw.trim() {
+            "isolated" => Some(Self::Isolated),
+            "shared" => Some(Self::Shared),
+            _ => None,
+        }
+    }
+
+    /// 折算成实例档位(全局模式只有两档,`inherit`/`custom` 不存在)。
+    #[must_use]
+    pub const fn as_isolation(self) -> Isolation {
+        match self {
+            Self::Isolated => Isolation::Isolated,
+            Self::Shared => Isolation::Shared,
+        }
+    }
+}
+
+/// 派生结论的来源(诊断/日志用,`mode` 变了要能解释为什么)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum IsolationSource {
+    /// 临时启动参数覆盖(最高优先)。
+    Temporary,
+    /// 实例文件里的 `isolation`。
+    Instance,
+    /// 全局 `instances.mode`(实例 `inherit` 或没有实例文件)。
+    Global,
+}
+
+impl IsolationSource {
+    /// 稳定名。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Temporary => "temporary",
+            Self::Instance => "instance",
+            Self::Global => "global",
+        }
+    }
+}
+
+/// 一次派生(覆盖链求值)的结论。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Derivation {
+    /// 生效档位(`inherit` 已被解析掉)。
+    pub isolation: Isolation,
+    /// 生效运行目录(喂 `--gameDir` 与进程 CWD)。
+    pub game_dir: PathBuf,
+    /// 档位来自哪一层。
+    pub source: IsolationSource,
+}
+
+/// 按覆盖链派生运行目录(纯计算,不碰盘)。
+///
+/// - `temporary`:临时启动参数给的目录(空路径视为未给);
+/// - `instance`:实例设置;`isolation` 为 `None`(= `inherit`)或没有实例时落到 `global_mode`;
+/// - `custom` 却没有 `gameDir` → [`GameError::CustomIsolationWithoutDir`](不猜默认目录)。
+///
+/// # Errors
+///
+/// `custom` 档位缺 `gameDir` 时返回 [`GameError::CustomIsolationWithoutDir`]。
+pub fn derive_game_dir(
+    layout: &GameLayout,
+    version_id: &str,
+    global_mode: IsolationMode,
+    instance: Option<&InstanceConfig>,
+    temporary: Option<&Path>,
+) -> Result {
+    let (isolation, source) = match instance.and_then(|config| config.isolation) {
+        Some(isolation) => (isolation, IsolationSource::Instance),
+        None => (global_mode.as_isolation(), IsolationSource::Global),
+    };
+    if let Some(path) = temporary.filter(|path| !path.as_os_str().is_empty()) {
+        return Ok(Derivation {
+            isolation,
+            game_dir: path.to_path_buf(),
+            source: IsolationSource::Temporary,
+        });
+    }
+    let game_dir = match isolation {
+        Isolation::Isolated => layout.version_dir(version_id),
+        Isolation::Shared => layout.root().to_path_buf(),
+        Isolation::Custom => match instance.and_then(|config| config.game_dir.clone()) {
+            Some(path) if !path.as_os_str().is_empty() => path,
+            _ => {
+                return Err(GameError::CustomIsolationWithoutDir {
+                    instance: instance.map_or_else(String::new, |config| config.id.clone()),
+                })
+            }
+        },
+    };
+    Ok(Derivation {
+        isolation,
+        game_dir,
+        source,
+    })
+}
+
+/// 一侧目录的内容探测结果。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SideContent {
+    /// 探测的目录(隔离侧 = `versions//`,共享侧 = `.minecraft/`)。
+    pub path: PathBuf,
+    /// `mods` `saves` `config` 三类内容的条目数合计。
+    pub entries: u32,
+}
+
+impl SideContent {
+    /// 是否有玩家数据(条目数 > 0)。
+    #[must_use]
+    pub const fn occupied(&self) -> bool {
+        self.entries > 0
+    }
+}
+
+/// 切换检测的双侧快照(落进 `lastModeChange.probe`)。
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ModeChangeProbe {
+    /// 隔离侧条目数。
+    pub isolated_side: u32,
+    /// 共享侧条目数。
+    pub shared_side: u32,
+}
+
+impl ModeChangeProbe {
+    /// 两侧都有内容(切换后玩家会以为「存档消失」的条件)。
+    #[must_use]
+    pub const fn both_occupied(&self) -> bool {
+        self.isolated_side > 0 && self.shared_side > 0
+    }
+}
+
+/// 一次隔离档位切换的记录(实例文件的 `lastModeChange`,启动器写入)。
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ModeChange {
+    /// 切换时间(RFC-3339,由调用方给——core::game 不碰时钟格式)。
+    pub at: String,
+    /// 切换前生效档位。
+    pub from: Isolation,
+    /// 切换后生效档位。
+    pub to: Isolation,
+    /// 切换当时的双侧内容探测(只探测,不迁移)。
+    #[serde(default)]
+    pub probe: ModeChangeProbe,
+    /// 提示是否已展示过(保证「切换后首次启动提示一次」而不是每次启动都刷)。
+    #[serde(default)]
+    pub warned: bool,
+}
+
+impl ModeChange {
+    /// 是否是一次**真的**切换(`from != to`;首启建基线时两者相同)。
+    #[must_use]
+    pub fn is_real_change(&self) -> bool {
+        self.from != self.to
+    }
+
+    /// 取走待展示的提示:真切换 + 两侧都有内容 + 还没提示过。
+    ///
+    /// 取走后把 `warned` 置真(调用方负责落盘),因此同一次切换只提示一次。
+    pub fn take_warning(&mut self) -> Option {
+        if self.warned || !self.is_real_change() || !self.probe.both_occupied() {
+            return None;
+        }
+        self.warned = true;
+        Some(ModeChangeWarning {
+            from: self.from,
+            to: self.to,
+            probe: self.probe,
+        })
+    }
+}
+
+/// 「切换后两侧都有内容」的提示(D20:**只提示不迁移**)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ModeChangeWarning {
+    /// 切换前档位。
+    pub from: Isolation,
+    /// 切换后档位。
+    pub to: Isolation,
+    /// 双侧探测结果。
+    pub probe: ModeChangeProbe,
+}
+
+impl ModeChangeWarning {
+    /// 一句给人看的文案(含两侧条目数与「不自动搬迁」的说明)。
+    #[must_use]
+    pub fn message(&self) -> String {
+        format!(
+            "隔离档位已从 {} 切到 {}:隔离侧 {} 条、共享侧 {} 条,两侧都有内容。\
+             ULL 不会自动搬数据——旧内容仍在原来那一侧,需要的话请手动把 mods/saves/config 挪到新目录。",
+            self.from.as_str(),
+            self.to.as_str(),
+            self.probe.isolated_side,
+            self.probe.shared_side,
+        )
+    }
+}
+
+/// 探测一侧目录的内容条目数(`mods` `saves` `config` 的直接子项合计)。
+///
+/// 目录不存在按 0 计(新实例、纯新的隔离目录都是常态,不是错误)。
+///
+/// # Errors
+///
+/// 目录存在但**读不了**(权限/被占用)时返回 [`GameError::Io`]——这种情况不能当成「空」,
+/// 否则切换检测会给出错误的「那侧没内容」结论。
+pub fn probe_side(path: &Path) -> Result {
+    let mut entries: u32 = 0;
+    for name in PROBED_CONTENT_DIRS {
+        let dir = path.join(name);
+        let reader = match std::fs::read_dir(&dir) {
+            Ok(reader) => reader,
+            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
+            Err(error) => return Err(GameError::io(&dir, error)),
+        };
+        for entry in reader {
+            entry.map_err(|error| GameError::io(&dir, error))?;
+            entries = entries.saturating_add(1);
+        }
+    }
+    Ok(SideContent {
+        path: path.to_path_buf(),
+        entries,
+    })
+}
+
+/// 同时探测隔离侧(`versions//`)与共享侧(游戏根)。
+///
+/// # Errors
+///
+/// 任一侧读不了时返回 [`GameError::Io`]。
+pub fn probe_both_sides(
+    layout: &GameLayout,
+    version_id: &str,
+) -> Result<(SideContent, SideContent), GameError> {
+    Ok((
+        probe_side(&layout.version_dir(version_id))?,
+        probe_side(layout.root())?,
+    ))
+}
+
+/// 启动时刷新切换记录(幂等):生效档位与记录里的 `to` 不同才记一次。
+///
+/// 返回 `true` 表示 `config` 被改动(调用方负责落盘——本模块不写文件)。
+///
+/// 首次观察(没有记录)会建一条基线:`from == to`,`warned = true`——这样老实例
+/// 升级到 D20 后不会平白弹一次「两侧都有内容」。
+///
+/// # Errors
+///
+/// 探测读盘失败时返回 [`GameError::Io`]。
+pub fn observe_mode(
+    config: &mut InstanceConfig,
+    layout: &GameLayout,
+    at: &str,
+    effective: Isolation,
+) -> Result {
+    if config
+        .last_mode_change
+        .as_ref()
+        .is_some_and(|change| change.to == effective)
+    {
+        return Ok(false);
+    }
+    let from = config
+        .last_mode_change
+        .as_ref()
+        .map_or(effective, |change| change.to);
+    let (isolated_side, shared_side) = probe_both_sides(layout, &config.version_id)?;
+    config.last_mode_change = Some(ModeChange {
+        at: at.to_string(),
+        from,
+        to: effective,
+        probe: ModeChangeProbe {
+            isolated_side: isolated_side.entries,
+            shared_side: shared_side.entries,
+        },
+        warned: from == effective,
+    });
+    Ok(true)
+}
diff --git a/crates/core/src/game/java/api.rs b/crates/core/src/game/java/api.rs
new file mode 100644
index 0000000..c04b47e
--- /dev/null
+++ b/crates/core/src/game/java/api.rs
@@ -0,0 +1,344 @@
+//! Adoptium(Eclipse Temurin)版本查询:`api.adoptium.net` → 一个可下载的 JDK 归档。
+//!
+//! 只用 v3 的 `assets/latest/{major}/hotspot` 这一条接口(D13:自动下载只装最新 GA):
+//!
+//! ```text
+//! GET https://api.adoptium.net/v3/assets/latest/21/hotspot?os=windows&arch=x64&image_type=jdk&vendor=eclipse
+//!     → [ { release_name, version{major,semver}, binary{os,architecture,image_type,package{name,link,checksum,size}} } ]
+//! ```
+//!
+//! 两个实测坑(都在 Windows x64 / Linux x64 上确认过):
+//!
+//! 1. **`arch` 参数不参与过滤**:`os=windows&arch=x64` 也会把 `aarch64` 的条目一起返回,
+//!    必须自己按 `binary.architecture` 再筛一遍,否则 ARM 包会被当成 x64 装下来;
+//! 2. **`package.link` 是 GitHub release 直链**(`github.com/adoptium/temurin21-binaries/releases/download/…`),
+//!    这正是 [`crate::download::adoptium`] 的镜像展开所认识的形态——所以院校镜像加速
+//!    不需要本模块拼任何 URL。
+
+use serde::Deserialize;
+
+use super::error::{JavaError, JavaErrorKind};
+use super::release::version_from_release_name;
+use super::target::AdoptiumTarget;
+use super::vendor::JdkVendor;
+
+/// Adoptium API 根。
+pub const ADOPTIUM_API_BASE: &str = "https://api.adoptium.net/v3";
+
+/// 托管 JDK 的默认供应商标识(D13 v1 的目录名 `jdk--vendor` 沿用它)。
+pub const JDK_VENDOR: &str = "temurin";
+
+/// 归档与查询结果的结构定义在 [`super::release`](四家发行版共用);这里重导出以保持
+/// `api::JdkRelease` / `api::JdkArchive` 这两个老路径可用。
+pub use super::release::{JdkArchive, JdkRelease};
+
+/// 拼 `latest/{major}` 的查询地址。
+#[must_use]
+pub fn latest_url(major: u32, target: AdoptiumTarget) -> String {
+    format!(
+        "{ADOPTIUM_API_BASE}/assets/latest/{major}/hotspot?os={}&arch={}&image_type=jdk&vendor=eclipse",
+        target.os, target.arch
+    )
+}
+
+/// 解析 `assets/latest` 的响应体,挑出与目标平台匹配的那一条。
+///
+/// # Errors
+///
+/// 响应体不是 JSON、列表为空、没有匹配 `os` + `arch` 的 jdk 条目、sha256 不合法时返回错误。
+pub fn parse_latest(json: &str, target: AdoptiumTarget) -> Result {
+    let assets: Vec = serde_json::from_str(json).map_err(|error| {
+        JavaError::new(
+            JavaErrorKind::Metadata,
+            format!("Adoptium 响应不是预期 JSON:{error}"),
+        )
+    })?;
+    let matched = assets
+        .into_iter()
+        .find(|asset| asset.matches(target))
+        .ok_or_else(|| {
+            JavaError::new(
+                JavaErrorKind::Metadata,
+                format!(
+                    "Adoptium 没有 {}/{} 的 jdk 产物(响应里 {} 条都不匹配)",
+                    target.os, target.arch, "全部"
+                ),
+            )
+            .with_hint("可能该大版本只有别的架构;用 `ull java list` 挑已有 JDK")
+        })?;
+    matched.into_release()
+}
+
+/// 请求一次「某大版本的最新 GA」。
+///
+/// # Errors
+///
+/// 网络不可达、HTTP 非 2xx、响应体不认识、没有对应平台产物时返回错误。
+pub async fn fetch_latest(
+    client: &reqwest::Client,
+    major: u32,
+    target: AdoptiumTarget,
+) -> Result {
+    let url = latest_url(major, target);
+    let response = client.get(&url).send().await.map_err(|error| {
+        JavaError::new(
+            JavaErrorKind::Network,
+            format!("连不上 Adoptium API:{error}"),
+        )
+        .with_hint("检查网络/代理;已装的 JDK 仍可用 `ull java list` 看到")
+    })?;
+    let status = response.status();
+    if status == reqwest::StatusCode::NOT_FOUND {
+        return Err(JavaError::new(
+            JavaErrorKind::Metadata,
+            format!("Adoptium 没有 {major} 的 GA 产物(HTTP 404)"),
+        )
+        .with_hint("可自动下载的一般是 8 / 11 / 17 / 21 / 25 这类 GA 大版本"));
+    }
+    if !status.is_success() {
+        return Err(JavaError::new(
+            JavaErrorKind::Network,
+            format!("Adoptium API 返回 HTTP {status}({url})"),
+        ));
+    }
+    let body = response.text().await.map_err(|error| {
+        JavaError::new(
+            JavaErrorKind::Network,
+            format!("读 Adoptium 响应失败:{error}"),
+        )
+    })?;
+    parse_latest(&body, target)
+}
+
+/// 响应里的 `package` 子对象。
+#[derive(Debug, Deserialize)]
+struct ApiPackage {
+    name: String,
+    link: String,
+    #[serde(default)]
+    checksum: Option,
+    #[serde(default)]
+    size: u64,
+}
+
+/// 响应里的 `binary` 子对象。
+#[derive(Debug, Deserialize)]
+struct ApiBinary {
+    #[serde(default)]
+    os: Option,
+    #[serde(default)]
+    architecture: Option,
+    #[serde(default)]
+    image_type: Option,
+    #[serde(default)]
+    package: Option,
+}
+
+/// 响应里的 `version` 子对象。
+#[derive(Debug, Deserialize)]
+struct ApiVersion {
+    #[serde(default)]
+    major: Option,
+    #[serde(default)]
+    semver: Option,
+}
+
+/// 响应列表里的一条。
+#[derive(Debug, Deserialize)]
+struct ApiAsset {
+    #[serde(default)]
+    release_name: String,
+    #[serde(default)]
+    version: Option,
+    #[serde(default)]
+    binary: Option,
+}
+
+impl ApiAsset {
+    /// 是否就是目标平台的 jdk 归档(`arch` 参数不过滤,必须自己判)。
+    fn matches(&self, target: AdoptiumTarget) -> bool {
+        let Some(binary) = &self.binary else {
+            return false;
+        };
+        let same_os = binary.os.as_deref().is_some_and(|os| os == target.os);
+        let same_arch = binary
+            .architecture
+            .as_deref()
+            .is_some_and(|arch| arch == target.arch);
+        let is_jdk = binary.image_type.as_deref() != Some("jre");
+        same_os && same_arch && is_jdk && binary.package.is_some()
+    }
+
+    /// 转成对外结果(校验 sha256 形态)。
+    fn into_release(self) -> Result {
+        let package = self
+            .binary
+            .and_then(|binary| binary.package)
+            .ok_or_else(|| {
+                JavaError::new(JavaErrorKind::Metadata, "Adoptium 条目缺少 package 子对象")
+            })?;
+        let sha256 = package
+            .checksum
+            .as_deref()
+            .map(|hex| hex.trim().to_ascii_lowercase())
+            .filter(|hex| hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
+            .ok_or_else(|| {
+                JavaError::new(
+                    JavaErrorKind::Integrity,
+                    format!("Adoptium 没给可用的 sha256({})", package.name),
+                )
+                .with_hint("换一个大版本,或手动装 JDK 后设 JAVA_HOME")
+            })?;
+        let version = self.version;
+        let major = version
+            .as_ref()
+            .and_then(|version| version.major)
+            .or_else(|| major_from_name(&package.name))
+            .ok_or_else(|| {
+                JavaError::new(
+                    JavaErrorKind::Metadata,
+                    format!("Adoptium 条目里读不出大版本号({})", package.name),
+                )
+            })?;
+        let native_version = version_from_release_name(&self.release_name)
+            .or_else(|| version.as_ref().and_then(|version| version.semver.clone()))
+            .unwrap_or_else(|| major.to_string());
+        Ok(JdkRelease {
+            vendor: JdkVendor::Temurin,
+            major,
+            version: native_version,
+            semver: version.and_then(|version| version.semver),
+            release_name: self.release_name,
+            archive: JdkArchive {
+                name: package.name,
+                url: package.link,
+                sha256,
+                size: package.size,
+            },
+        })
+    }
+}
+
+/// 从产物名兜底读大版本(`OpenJDK21U-jdk_x64_windows_hotspot_…` → `21`)。
+fn major_from_name(name: &str) -> Option {
+    let rest = name.split_once("OpenJDK")?.1;
+    let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
+    digits.parse().ok()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{latest_url, parse_latest, ApiAsset};
+    use crate::game::java::error::JavaErrorKind;
+    use crate::game::java::target::AdoptiumTarget;
+
+    const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
+
+    fn sample() -> String {
+        // 实测:`arch` 参数不过滤,响应里会混进别的架构。
+        r#"[
+          {
+            "release_name": "jdk-21.0.13+9",
+            "version": {"major": 21, "semver": "21.0.13+9.0.LTS"},
+            "binary": {
+              "os": "windows", "architecture": "aarch64", "image_type": "jdk",
+              "package": {
+                "name": "OpenJDK21U-jdk_aarch64_windows_hotspot_21.0.12.1_1.zip",
+                "link": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_aarch64_windows_hotspot_21.0.12.1_1.zip",
+                "checksum": "9dcbb8ebe24aa9ee5a47a7fab2106a7c8988d7acee461260fef107411df91dc5",
+                "size": 179666944
+              }
+            }
+          },
+          {
+            "release_name": "jdk-21.0.13+9",
+            "version": {"major": 21, "semver": "21.0.13+9.0.LTS"},
+            "binary": {
+              "os": "windows", "architecture": "x64", "image_type": "jdk",
+              "package": {
+                "name": "OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip",
+                "link": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.13%2B9/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip",
+                "checksum": "a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9",
+                "size": 190000000
+              }
+            }
+          }
+        ]"#
+        .to_string()
+    }
+
+    #[test]
+    fn picks_the_matching_arch_and_keeps_the_github_link() {
+        let release = parse_latest(&sample(), WINDOWS).expect("应解析出 x64 条目");
+        assert_eq!(release.major, 21);
+        assert_eq!(
+            release.vendor,
+            crate::game::java::vendor::JdkVendor::Temurin
+        );
+        assert_eq!(release.version, "21.0.13+9", "版本串取自 release_name");
+        assert_eq!(release.release_name, "jdk-21.0.13+9");
+        assert_eq!(release.semver.as_deref(), Some("21.0.13+9.0.LTS"));
+        assert_eq!(
+            release.archive.name,
+            "OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip"
+        );
+        assert_eq!(release.archive.size, 190_000_000);
+        assert!(release.archive.url.contains("temurin21-binaries"));
+        assert_eq!(
+            crate::download::adoptium::parse_adoptium_artifact(&release.archive.url)
+                .map(|artifact| (artifact.major, artifact.arch, artifact.os)),
+            Some(("21".to_string(), "x64".to_string(), "windows".to_string()))
+        );
+    }
+
+    #[test]
+    fn missing_arch_is_an_error_not_a_guess() {
+        let error = parse_latest(&sample(), AdoptiumTarget::new("windows", "x86-32"))
+            .expect_err("x86-32 应没有条目");
+        assert_eq!(error.kind(), JavaErrorKind::Metadata);
+    }
+
+    #[test]
+    fn bad_checksum_is_rejected() {
+        let body = sample().replace(
+            "a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9",
+            "deadbeef",
+        );
+        let error = parse_latest(&body, WINDOWS).expect_err("短 sha256 应被拒");
+        assert_eq!(error.kind(), JavaErrorKind::Integrity);
+    }
+
+    #[test]
+    fn latest_url_carries_os_arch_and_flavor() {
+        let url = latest_url(17, AdoptiumTarget::new("linux", "aarch64"));
+        assert!(url.starts_with("https://api.adoptium.net/v3/assets/latest/17/hotspot?"));
+        assert!(url.contains("os=linux"));
+        assert!(url.contains("arch=aarch64"));
+        assert!(url.contains("image_type=jdk"));
+    }
+
+    #[test]
+    fn non_json_body_is_metadata_error() {
+        let error = parse_latest("502", WINDOWS).expect_err("HTML 应被拒");
+        assert_eq!(error.kind(), JavaErrorKind::Metadata);
+    }
+
+    #[test]
+    fn major_falls_back_to_the_artifact_name() {
+        let body = r#"[{"release_name":"jdk-17.0.13+11",
+            "binary":{"os":"windows","architecture":"x64","image_type":"jdk",
+            "package":{"name":"OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip",
+            "link":"https://example.invalid/x.zip",
+            "checksum":"a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9",
+            "size":1}}}]"#;
+        let release = parse_latest(body, WINDOWS).expect("应解析");
+        assert_eq!(release.major, 17);
+        assert_eq!(release.semver, None);
+    }
+
+    #[test]
+    fn asset_without_binary_never_matches() {
+        let asset: ApiAsset = serde_json::from_str(r#"{"release_name":"x"}"#).expect("最小条目");
+        assert!(!asset.matches(WINDOWS));
+    }
+}
diff --git a/crates/core/src/game/java/discover.rs b/crates/core/src/game/java/discover.rs
new file mode 100644
index 0000000..ded92af
--- /dev/null
+++ b/crates/core/src/game/java/discover.rs
@@ -0,0 +1,270 @@
+//! 标准安装位置扫描 + 全部发现源的合并去重(`java list` 的「系统发现」半边)。
+//!
+//! 只认 `JAVA_HOME`/`PATH` 会漏掉最常见的一类用户:**用 MSI 装的 Temurin / Microsoft OpenJDK
+//! 不设环境变量**(安装器只登记开始菜单)。HMCL 的 `JavaManager.searchAllJavaInRepository`
+//! 与 PCL 的 `JavaUtils.SearchFolders` 都扫这些位置,本模块照同一思路:
+//!
+//! | 平台 | 扫描位置 |
+//! | --- | --- |
+//! | Windows | `%ProgramFiles%\{Java, Eclipse Adoptium, Microsoft, Zulu, Amazon Corretto, BellSoft, Semeru}\*`(含 `ProgramFiles(x86)` / `ProgramW6432` 变体,WOW64 才看得到 32 位安装)、`%APPDATA%\.minecraft\runtime\*`(**官方启动器装的运行时**,跨产品兼容)、`HKLM\SOFTWARE\JavaSoft\JDK`(老 Oracle 写的,新版发行版通常没有→容错跳过) |
+//! | Linux | `/usr/lib/jvm/*`、`/usr/java/*` |
+//! | macOS | `/Library/Java/JavaVirtualMachines/*.jdk/Contents/Home` |
+//!
+//! 三个纪律:
+//!
+//! 1. **目录不存在是常态**(本机实测 `%APPDATA%\.minecraft\runtime` 与 `JavaSoft\JDK` 都没有),
+//!    一律静默跳过,绝不报错;
+//! 2. **同一路径多来源命中只列一次**,保留优先级高者(`JAVA_HOME` 指向 Program Files 里的
+//!    JDK 时,用户看到的是 `JAVA_HOME` 而不是 `system`);
+//! 3. **扫描只在需要时才做**:`java list` 全扫;启动路径上先看托管+环境,不满足才扫盘
+//!    (别为了一次启动白跑 N 次 `java -version`)。
+
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use super::extract::java_binary_name;
+use super::probe::{self, discover_managed_roots, system_candidates, JavaOrigin, JavaRuntime};
+
+/// 全部发现源(托管 → `JAVA_HOME` → `PATH` → 标准安装位置),已按路径去重。
+#[must_use]
+pub fn candidates(managed_root: &Path) -> Vec {
+    candidates_in_roots(std::slice::from_ref(&managed_root.to_path_buf()))
+}
+
+/// 同上,但托管区可以是**多个安装根**(Portable 数据根 + Standard/Custom 落点,D23)。
+#[must_use]
+pub fn candidates_in_roots(managed_roots: &[PathBuf]) -> Vec {
+    let mut all = discover_managed_roots(managed_roots);
+    all.extend(system_candidates());
+    all.extend(standard_candidates());
+    dedupe(all)
+}
+
+/// 标准安装位置扫出来的运行时(逐个探测)。
+#[must_use]
+pub fn standard_candidates() -> Vec {
+    scan_java_binaries()
+        .into_iter()
+        .map(|binary| probe::probe(&binary, JavaOrigin::System))
+        .collect()
+}
+
+/// 扫描标准安装位置,返回 `bin/java[.exe]` 路径表(稳定排序,未探测)。
+#[must_use]
+pub fn scan_java_binaries() -> Vec {
+    let mut found: Vec = Vec::new();
+    for root in standard_roots() {
+        let binary = root.join("bin").join(java_binary_name());
+        if binary.is_file() {
+            found.push(binary);
+        }
+    }
+    for home in registry_homes() {
+        let binary = home.join("bin").join(java_binary_name());
+        if binary.is_file() {
+            found.push(binary);
+        }
+    }
+    found.sort();
+    found.dedup();
+    found
+}
+
+/// 候选 JDK 根目录(`bin/java` 的父目录的父目录)。
+#[must_use]
+pub fn standard_roots() -> Vec {
+    let mut roots = Vec::new();
+    if cfg!(windows) {
+        for variable in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] {
+            let Some(base) = std::env::var_os(variable) else {
+                continue;
+            };
+            let base = PathBuf::from(base);
+            for vendor in WINDOWS_VENDOR_DIRS {
+                roots.extend(child_dirs(&base.join(vendor)));
+            }
+        }
+        if let Some(appdata) = std::env::var_os("APPDATA") {
+            roots.extend(child_dirs(
+                &PathBuf::from(appdata).join(".minecraft").join("runtime"),
+            ));
+        }
+    } else if cfg!(target_os = "macos") {
+        for entry in child_dirs(Path::new("/Library/Java/JavaVirtualMachines")) {
+            // `*.jdk/Contents/Home`(`java_home` 语义)。
+            let home = entry.join("Contents").join("Home");
+            roots.push(if home.is_dir() { home } else { entry });
+        }
+    } else {
+        for base in ["/usr/lib/jvm", "/usr/java"] {
+            roots.extend(child_dirs(Path::new(base)));
+        }
+    }
+    roots.sort();
+    roots.dedup();
+    roots
+}
+
+/// Windows 各发行版的安装父目录(每种下面按子目录名当版本)。
+const WINDOWS_VENDOR_DIRS: &[&str] = &[
+    "Java",
+    "Eclipse Adoptium",
+    "Microsoft",
+    "Zulu",
+    "Amazon Corretto",
+    "BellSoft",
+    "Semeru",
+];
+
+/// 列一个目录的直接子目录(不存在/无权限一律空)。
+fn child_dirs(dir: &Path) -> Vec {
+    let Ok(entries) = std::fs::read_dir(dir) else {
+        return Vec::new();
+    };
+    entries
+        .flatten()
+        .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
+        .map(|entry| entry.path())
+        .collect()
+}
+
+/// 注册表里登记的 `JavaHome`(只查老 Oracle 的 `JavaSoft\JDK`;查不到返回空)。
+///
+/// 不走额外依赖:`reg.exe` 在 Win10+ 恒定存在,输出格式也稳定
+/// (`    JavaHome    REG_SZ    C:\Program Files\Java\jdk-17`)。
+#[must_use]
+pub fn registry_homes() -> Vec {
+    if !cfg!(windows) {
+        return Vec::new();
+    }
+    let mut homes = Vec::new();
+    for view in ["64", "32"] {
+        let Ok(output) = Command::new("reg")
+            .args(["query", "HKLM\\SOFTWARE\\JavaSoft\\JDK", "/s", "/reg:"])
+            .arg(view)
+            .output()
+        else {
+            continue;
+        };
+        if !output.status.success() {
+            continue;
+        }
+        homes.extend(parse_java_home(&String::from_utf8_lossy(&output.stdout)));
+    }
+    homes.sort();
+    homes.dedup();
+    homes
+}
+
+/// 从 `reg query /s` 的输出里抠出 `JavaHome` 的值(纯函数,便于用真实输出做单测)。
+#[must_use]
+pub fn parse_java_home(output: &str) -> Vec {
+    output
+        .lines()
+        .filter_map(|line| {
+            let rest = line.trim().strip_prefix("JavaHome")?;
+            let (_, value) = rest.split_once("REG_SZ")?;
+            let home = value.trim();
+            (!home.is_empty()).then(|| PathBuf::from(home))
+        })
+        .collect()
+}
+
+/// 按路径去重,保留**先出现**的那个(调用方按优先级排好序)。
+#[must_use]
+pub fn dedupe(runtimes: Vec) -> Vec {
+    let mut seen: Vec = Vec::with_capacity(runtimes.len());
+    let mut kept = Vec::with_capacity(runtimes.len());
+    for runtime in runtimes {
+        let key = path_key(&runtime.binary);
+        if seen.contains(&key) {
+            continue;
+        }
+        seen.push(key);
+        kept.push(runtime);
+    }
+    kept
+}
+
+/// 去重键:去掉 Windows 的 `\\?\` 前缀、统一分隔符、大小写不敏感(Windows)。
+#[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();
+    if cfg!(windows) {
+        text.replace('/', "\\").to_ascii_lowercase()
+    } else {
+        text
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{dedupe, parse_java_home, path_key, standard_roots};
+    use crate::game::java::probe::{JavaOrigin, JavaRuntime};
+
+    fn runtime(path: &str, origin: JavaOrigin) -> JavaRuntime {
+        JavaRuntime {
+            binary: std::path::PathBuf::from(path),
+            origin,
+            version: None,
+            major: Some(21),
+            bits: 64,
+            vendor: None,
+        }
+    }
+
+    #[test]
+    fn dedupe_keeps_the_higher_priority_source() {
+        let list = vec![
+            runtime(
+                "C:/Program Files/Java/jdk-21/bin/java.exe",
+                JavaOrigin::JavaHome,
+            ),
+            runtime(
+                "C:\\Program Files\\Java\\jdk-21\\bin\\java.exe",
+                JavaOrigin::System,
+            ),
+            runtime(
+                "C:/Program Files/Zulu/zulu-17/bin/java.exe",
+                JavaOrigin::System,
+            ),
+        ];
+        let kept = dedupe(list);
+        assert_eq!(kept.len(), 2);
+        assert_eq!(kept[0].origin, JavaOrigin::JavaHome, "重复路径保留高优先级");
+    }
+
+    #[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"\\"));
+    }
+
+    #[test]
+    fn parses_real_reg_query_output() {
+        // 实测形态(`reg query HKLM\SOFTWARE\JavaSoft\JDK /s`)。
+        let output = "\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\JDK\r\n    CurrentVersion    REG_SZ    1.8\r\n\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\JDK\\1.8\r\n    JavaHome    REG_SZ    C:\\Program Files\\Java\\jdk1.8.0_402\r\n";
+        let homes = parse_java_home(output);
+        assert_eq!(
+            homes,
+            vec![std::path::PathBuf::from(
+                r"C:\Program Files\Java\jdk1.8.0_402"
+            )]
+        );
+    }
+
+    #[test]
+    fn missing_registry_key_is_not_an_error() {
+        assert!(parse_java_home("错误: 系统找不到指定的注册表项或值。\r\n").is_empty());
+    }
+
+    #[test]
+    fn standard_roots_never_panic_on_missing_directories() {
+        // 本机大概率缺若干目录;这里只要求「能跑、不 panic」。
+        let roots = standard_roots();
+        assert!(roots.iter().all(|root| !root.as_os_str().is_empty()));
+    }
+}
diff --git a/crates/core/src/game/java/error.rs b/crates/core/src/game/java/error.rs
new file mode 100644
index 0000000..0cdc3d5
--- /dev/null
+++ b/crates/core/src/game/java/error.rs
@@ -0,0 +1,138 @@
+//! Java 管理模块的错误形状:稳定分类 + 人读说明 + 下一步提示。
+//!
+//! 与 `core::game::install::InstallError` 同一个套路(分类进 `--json` 的 `error.code`,
+//! 说明给人看,提示告诉用户下一步做什么),只是没有复用那个类型——安装层的错误语义
+//! 绑在版本本体上,JDK 下载的失败面(API 不可达 / 无对应产物 / 解压后跑不起来)不一样。
+
+use std::fmt;
+
+use crate::download::{DownloadError, DownloadErrorKind};
+
+/// 失败大类(CLI 映射成稳定错误码,脚本可分流)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum JavaErrorKind {
+    /// 网络侧:Adoptium API 不可达,或所有候选镜像源都失败。
+    Network,
+    /// 元数据不认识:API 返回体缺字段、空列表、要的大版本没有 GA 产物。
+    Metadata,
+    /// 摘要校验不符(镜像给了错内容)或 API 没给可用的 sha256。
+    Integrity,
+    /// 本地 IO:建目录 / 解压 / 落盘。
+    Io,
+    /// 当前平台没有对应的 Temurin 产物(架构不认识等)。
+    Unsupported,
+    /// 装完探测不到可用 java(解压成功但跑不起来 / 版本不符)。
+    Probe,
+}
+
+impl JavaErrorKind {
+    /// 稳定字符串 id(进 `--json` 的 `error.code`)。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Network => "java_network",
+            Self::Metadata => "java_metadata",
+            Self::Integrity => "java_integrity",
+            Self::Io => "java_io",
+            Self::Unsupported => "java_unsupported",
+            Self::Probe => "java_probe",
+        }
+    }
+}
+
+/// Java 管理期错误。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct JavaError {
+    kind: JavaErrorKind,
+    message: String,
+    hint: Option,
+}
+
+impl JavaError {
+    /// 构造。
+    #[must_use]
+    pub fn new(kind: JavaErrorKind, message: impl Into) -> Self {
+        Self {
+            kind,
+            message: message.into(),
+            hint: None,
+        }
+    }
+
+    /// 补一句「下一步怎么办」。
+    #[must_use]
+    pub fn with_hint(mut self, hint: impl Into) -> Self {
+        self.hint = Some(hint.into());
+        self
+    }
+
+    /// 分类。
+    #[must_use]
+    pub const fn kind(&self) -> JavaErrorKind {
+        self.kind
+    }
+
+    /// 稳定错误码。
+    #[must_use]
+    pub const fn code(&self) -> &'static str {
+        self.kind.as_str()
+    }
+
+    /// 人读说明。
+    #[must_use]
+    pub fn message(&self) -> &str {
+        &self.message
+    }
+
+    /// 下一步提示。
+    #[must_use]
+    pub fn hint(&self) -> Option<&str> {
+        self.hint.as_deref()
+    }
+
+    /// 在说明前面加一段上下文(保留分类与提示)。
+    #[must_use]
+    pub fn with_context(mut self, context: impl fmt::Display) -> Self {
+        self.message = format!("{context}:{}", self.message);
+        self
+    }
+}
+
+impl fmt::Display for JavaError {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        formatter.write_str(&self.message)
+    }
+}
+
+impl std::error::Error for JavaError {}
+
+impl From for JavaError {
+    fn from(error: DownloadError) -> Self {
+        let kind = match error.kind {
+            DownloadErrorKind::Integrity => JavaErrorKind::Integrity,
+            DownloadErrorKind::Io => JavaErrorKind::Io,
+            DownloadErrorKind::InvalidRequest => JavaErrorKind::Metadata,
+            DownloadErrorKind::NotFound
+            | DownloadErrorKind::ServerError
+            | DownloadErrorKind::Timeout
+            | DownloadErrorKind::Network
+            | DownloadErrorKind::RangeUnsupported
+            | DownloadErrorKind::AllSourcesFailed => JavaErrorKind::Network,
+        };
+        let mut message = error.message.clone();
+        if let Some(source) = &error.source {
+            message.push_str(&format!("(源 {source})"));
+        }
+        // 逐源失败摘要:没有这一段,「全部失败」在现场只能靠复现去猜(本轮真机验收就踩到了)。
+        if !error.suppressed.is_empty() {
+            message.push_str(&format!(";逐源明细:{}", error.suppressed.join(" / ")));
+        }
+        Self::new(kind, message)
+    }
+}
+
+/// IO 错误包装(带上「在做什么 / 哪个路径」,日志里才追得动)。
+#[must_use]
+pub fn io_error(op: &'static str, path: impl fmt::Display, source: &std::io::Error) -> JavaError {
+    JavaError::new(JavaErrorKind::Io, format!("{op} 失败({path}):{source}"))
+}
diff --git a/crates/core/src/game/java/extract.rs b/crates/core/src/game/java/extract.rs
new file mode 100644
index 0000000..44b392f
--- /dev/null
+++ b/crates/core/src/game/java/extract.rs
@@ -0,0 +1,318 @@
+//! 归档解压:zip(Windows)/ tar.gz(Linux、macOS 的 Temurin 只发这种)+ 幂等戳记。
+//!
+//! # 为什么不用「解压到最终目录」了事
+//!
+//! Temurin 的归档里所有条目都在一个顶层目录下(`jdk-21.0.13+9/bin/java`),
+//! 而托管布局要的是 `java-managed/jdk-21-temurin/bin/java`。裸解压会多出一层,
+//! 于是这里先在**暂存目录**里解完,再把那个唯一的顶层目录(或暂存目录本身)
+//! 提升成最终目录——这样终态恒定,`bin/java` 的位置不会因为厂商换打包方式而漂移。
+//!
+//! # 幂等
+//!
+//! 戳记 `.ull-jdk.json` 记下「这份目录是用哪个 sha256 的归档解出来的 + 文件数」。
+//! 重复 `ull java install 21` 时 sha256 相同就**一个字节都不动**(返回 `skipped`);
+//! 官方发了新版(sha256 变)才重解。戳记缺失/损坏一律当「没装过」。
+//!
+//! # 安全
+//!
+//! zip 走 `enclosed_name()`(拒绝 `..` 与绝对路径),tar 走 `unpack_in()`
+//! (同样拒绝越界),两者都不会把文件写到目标目录之外(zip-slip)。
+//! tar 里的符号链接条目跳过(Windows 建不了;Temurin 的 JDK 归档不依赖它们)。
+
+use std::fs::{self, File};
+use std::io;
+use std::path::{Path, PathBuf};
+
+use flate2::read::GzDecoder;
+use serde::{Deserialize, Serialize};
+use zip::ZipArchive;
+
+use super::error::{io_error, JavaError, JavaErrorKind};
+
+/// 戳记文件名(放在托管 JDK 根目录里)。
+pub const STAMP_FILE: &str = ".ull-jdk.json";
+
+/// 戳记 schema 版本(改字段语义才升)。
+const STAMP_VERSION: u8 = 1;
+
+/// 暂存目录前缀(解压在它里面进行,成功后提升成最终目录)。
+const STAGING_PREFIX: &str = ".staging-";
+
+/// 托管 JDK 的戳记。
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct JdkStamp {
+    /// schema 版本。
+    pub v: u8,
+    /// 归档文件名。
+    pub archive: String,
+    /// 归档的 SHA-256(幂等判据)。
+    pub sha256: String,
+    /// 大版本号。
+    pub major: u32,
+    /// 解出来的文件数。
+    pub files: usize,
+}
+
+/// 一次解压的结果。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ExtractReport {
+    /// 本次落盘的文件数(`skipped` 时为 0)。
+    pub files: usize,
+    /// 是否因为戳记命中而整体跳过。
+    pub skipped: bool,
+}
+
+impl ExtractReport {
+    /// 是否真的写盘了。
+    #[must_use]
+    pub const fn wrote(self) -> bool {
+        !self.skipped
+    }
+}
+
+/// 托管 JDK 的 `java` 可执行文件路径。
+#[must_use]
+pub fn java_binary(dir: &Path) -> PathBuf {
+    dir.join("bin").join(java_binary_name())
+}
+
+/// 平台上的 java 可执行文件名。
+#[must_use]
+pub const fn java_binary_name() -> &'static str {
+    if cfg!(windows) {
+        "java.exe"
+    } else {
+        "java"
+    }
+}
+
+/// 读戳记(不存在 / 损坏 / 版本不符都当作「没有」)。
+#[must_use]
+pub fn read_stamp(dir: &Path) -> Option {
+    let text = fs::read_to_string(dir.join(STAMP_FILE)).ok()?;
+    let stamp: JdkStamp = serde_json::from_str(&text).ok()?;
+    (stamp.v == STAMP_VERSION).then_some(stamp)
+}
+
+/// 这个目录里是不是已经躺着一份用**同一个归档**解出来的 JDK。
+#[must_use]
+pub fn is_ready(dir: &Path, sha256: &str) -> bool {
+    let Some(stamp) = read_stamp(dir) else {
+        return false;
+    };
+    stamp.sha256 == sha256.to_ascii_lowercase() && java_binary(dir).is_file()
+}
+
+/// 解压一个 JDK 归档到 `dest`(幂等)。
+///
+/// # Errors
+///
+/// 归档打不开/格式不认识、解压写盘失败、`dest` 已存在却不由我们管理
+/// (没有戳记)时返回错误。
+pub fn extract_archive(
+    archive: &Path,
+    sha256: &str,
+    major: u32,
+    dest: &Path,
+) -> Result {
+    if is_ready(dest, sha256) {
+        return Ok(ExtractReport {
+            files: 0,
+            skipped: true,
+        });
+    }
+    let parent = dest.parent().ok_or_else(|| {
+        JavaError::new(
+            JavaErrorKind::Io,
+            format!("托管目录没有父目录:{}", dest.display()),
+        )
+    })?;
+    fs::create_dir_all(parent)
+        .map_err(|error| io_error("创建托管目录", parent.display(), &error))?;
+
+    let folder = dest
+        .file_name()
+        .map(|name| name.to_string_lossy().into_owned())
+        .unwrap_or_else(|| "jdk".to_string());
+    let staging = parent.join(format!("{STAGING_PREFIX}{folder}"));
+    if staging.exists() {
+        fs::remove_dir_all(&staging)
+            .map_err(|error| io_error("清理暂存目录", staging.display(), &error))?;
+    }
+    fs::create_dir_all(&staging)
+        .map_err(|error| io_error("创建暂存目录", staging.display(), &error))?;
+
+    let extracted = if is_zip(archive) {
+        extract_zip(archive, &staging)
+    } else {
+        extract_tar_gz(archive, &staging)
+    };
+    let files = match extracted {
+        Ok(files) => files,
+        Err(error) => {
+            let _ = fs::remove_dir_all(&staging);
+            return Err(error);
+        }
+    };
+
+    let root = single_top_level_dir(&staging).unwrap_or_else(|| staging.clone());
+    if dest.exists() {
+        // 只回收「我们自己装的」目录:有戳记,或空目录。其余一律不碰。
+        let ours = read_stamp(dest).is_some() || is_empty_dir(dest);
+        if !ours {
+            let _ = fs::remove_dir_all(&staging);
+            return Err(JavaError::new(
+                JavaErrorKind::Io,
+                format!("目标目录已存在且不是托管 JDK:{}", dest.display()),
+            )
+            .with_hint("换个目录(`--data-dir`),或先手动清掉它"));
+        }
+        fs::remove_dir_all(dest)
+            .map_err(|error| io_error("清空旧托管目录", dest.display(), &error))?;
+    }
+    fs::rename(&root, dest).map_err(|error| {
+        io_error(
+            "提升暂存目录",
+            format!("{} → {}", root.display(), dest.display()),
+            &error,
+        )
+    })?;
+    if root != staging {
+        let _ = fs::remove_dir_all(&staging);
+    }
+    if !java_binary(dest).is_file() {
+        return Err(JavaError::new(
+            JavaErrorKind::Probe,
+            format!("解压完成但找不到 bin/{}", java_binary_name()),
+        )
+        .with_hint("归档形态与平台不匹配(例如把 Linux 包当成 Windows 包装)"));
+    }
+
+    let stamp = JdkStamp {
+        v: STAMP_VERSION,
+        archive: archive
+            .file_name()
+            .map(|name| name.to_string_lossy().into_owned())
+            .unwrap_or_default(),
+        sha256: sha256.to_ascii_lowercase(),
+        major,
+        files,
+    };
+    let text = serde_json::to_string_pretty(&stamp).map_err(|error| {
+        JavaError::new(
+            JavaErrorKind::Metadata,
+            format!("序列化 JDK 戳记失败:{error}"),
+        )
+    })?;
+    let stamp_path = dest.join(STAMP_FILE);
+    fs::write(&stamp_path, text)
+        .map_err(|error| io_error("写 JDK 戳记", stamp_path.display(), &error))?;
+    Ok(ExtractReport {
+        files,
+        skipped: false,
+    })
+}
+
+/// 归档是不是 zip(魔数优先,读不到才退回扩展名)。
+///
+/// 不按扩展名一刀切:镜像把 `.zip` 存成 `.zip.tmp` 之类时魔数仍然可靠,
+/// 反过来把 tar.gz 命名成 `.zip` 也不会被错解。
+#[must_use]
+pub fn is_zip(archive: &Path) -> bool {
+    let mut head = [0u8; 4];
+    let magic = File::open(archive)
+        .and_then(|mut file| io::Read::read_exact(&mut file, &mut head).map(|()| head));
+    if let Ok(head) = magic {
+        // `PK\x03\x04` 普通条目 / `PK\x05\x06` 空归档。
+        return head == *b"PK\x03\x04" || head == *b"PK\x05\x06";
+    }
+    archive
+        .file_name()
+        .map(|name| name.to_string_lossy().to_ascii_lowercase())
+        .is_some_and(|name| name.ends_with(".zip"))
+}
+
+/// zip:逐条目落盘,`enclosed_name` 挡路径穿越。
+fn extract_zip(archive: &Path, dir: &Path) -> Result {
+    let file = File::open(archive)
+        .map_err(|error| io_error("打开 JDK 归档", archive.display(), &error))?;
+    let mut zip = ZipArchive::new(file).map_err(|error| {
+        JavaError::new(
+            JavaErrorKind::Io,
+            format!("JDK 归档不是合法 zip({}):{error}", archive.display()),
+        )
+    })?;
+    let mut files = 0;
+    for index in 0..zip.len() {
+        let mut entry = zip.by_index(index).map_err(|error| {
+            JavaError::new(
+                JavaErrorKind::Io,
+                format!("读 zip 条目失败({}):{error}", archive.display()),
+            )
+        })?;
+        if entry.is_dir() {
+            continue;
+        }
+        let Some(relative) = entry.enclosed_name() else {
+            continue;
+        };
+        let dest = dir.join(&relative);
+        if let Some(parent) = dest.parent() {
+            fs::create_dir_all(parent)
+                .map_err(|error| io_error("创建解压子目录", parent.display(), &error))?;
+        }
+        let mut out = File::create(&dest)
+            .map_err(|error| io_error("创建解压文件", dest.display(), &error))?;
+        io::copy(&mut entry, &mut out)
+            .map_err(|error| io_error("写解压文件", dest.display(), &error))?;
+        files += 1;
+    }
+    Ok(files)
+}
+
+/// tar.gz:`unpack_in` 挡路径穿越,符号链接条目跳过。
+fn extract_tar_gz(archive: &Path, dir: &Path) -> Result {
+    let file = File::open(archive)
+        .map_err(|error| io_error("打开 JDK 归档", archive.display(), &error))?;
+    let mut tar = tar::Archive::new(GzDecoder::new(file));
+    let entries = tar.entries().map_err(|error| {
+        JavaError::new(
+            JavaErrorKind::Io,
+            format!("JDK 归档不是合法 tar.gz({}):{error}", archive.display()),
+        )
+    })?;
+    let mut files = 0;
+    for entry in entries {
+        let mut entry = entry.map_err(|error| {
+            JavaError::new(
+                JavaErrorKind::Io,
+                format!("读 tar 条目失败({}):{error}", archive.display()),
+            )
+        })?;
+        if !entry.header().entry_type().is_file() {
+            continue;
+        }
+        let unpacked = entry
+            .unpack_in(dir)
+            .map_err(|error| io_error("解压 tar 条目", dir.display(), &error))?;
+        if unpacked {
+            files += 1;
+        }
+    }
+    Ok(files)
+}
+
+/// 暂存目录里是不是只有一个顶层目录(Temurin 的 `jdk-21.0.13+9/`)。
+fn single_top_level_dir(dir: &Path) -> Option {
+    let mut entries = fs::read_dir(dir).ok()?;
+    let first = entries.next()?.ok()?;
+    if entries.next().is_some() {
+        return None;
+    }
+    let path = first.path();
+    path.is_dir().then_some(path)
+}
+
+fn is_empty_dir(dir: &Path) -> bool {
+    fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none())
+}
diff --git a/crates/core/src/game/java/install.rs b/crates/core/src/game/java/install.rs
new file mode 100644
index 0000000..654dbc6
--- /dev/null
+++ b/crates/core/src/game/java/install.rs
@@ -0,0 +1,379 @@
+//! 托管 JDK 安装编排:发行版回退链 → 下载引擎 → 解压 → `java -version` 验证。
+//!
+//! ```text
+//! vendor::order(用户优先级, 地区)                  D23 的尝试顺序
+//!   → source::fetch(vendor, major)                查到该家最新 GA(Temurin/Oracle/MS/Zulu)
+//!   → location::JavaInstallRoot::jdk_dir          按落点模式算目录
+//!   → [戳记 sha256 相同 && bin/java 在] 复用      幂等的第一层
+//!   → engine.download(JavaRuntime 请求)           镜像/健康度/退避/续传/边下边算全在引擎
+//!   → extract::extract_archive                    zip / tar.gz + 幂等戳记
+//!   → probe::probe(bin/java -version)             幂等的第二层,也是「装完真能用」的证据
+//! ```
+//!
+//! **回退的粒度是发行版**(D23):该家没有这个大版本、镜像全 4xx、sha256 取不到、下载校验失败,
+//! 都记一条降级原因然后换下一家;只有全部失败才整体报错,错误里带上每一跳的原因。
+//!
+//! 这一步**不拼任何镜像 URL**(Oracle 的哈希段不可推导,见 `oracle` 模块):Temurin 的
+//! GitHub 直链交给 `MirrorTable::expand` 展开成院校镜像,Oracle/MS/Zulu 的链接由各自模块
+//! 从目录页/模板/API 拿到最终形态,引擎只负责「把这个 URL 下下来并校验」。
+
+use std::path::{Path, PathBuf};
+
+use super::error::{io_error, JavaError, JavaErrorKind};
+use super::extract;
+use super::location::{JavaInstallRoot, LocationMode};
+use super::probe::{self, JavaOrigin, JavaRuntime};
+use super::release::JdkRelease;
+use super::source::{self, ChainLog};
+use super::target::AdoptiumTarget;
+use super::vendor::{self, JdkVendor};
+use crate::download::{DownloadEngine, DownloadRequest, ResourceKind};
+
+/// 归档缓存目录名(在安装根下;托管目录扫描跳过它)。
+pub use super::location::CACHE_DIR;
+
+/// 一次安装请求。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct InstallRequest {
+    /// 目标大版本。
+    pub major: u32,
+    /// 钉死某一家发行版(`--vendor`);`None` = 按回退链自己选。
+    pub vendor: Option,
+    /// 用户优先级(`java.vendorPriority`,空 = 按地区默认序)。
+    pub priority: Vec,
+}
+
+impl InstallRequest {
+    /// 只要大版本(走回退链,优先级用地区默认)。
+    #[must_use]
+    pub const fn new(major: u32) -> Self {
+        Self {
+            major,
+            vendor: None,
+            priority: Vec::new(),
+        }
+    }
+
+    /// 钉死发行版。
+    #[must_use]
+    pub const fn with_vendor(major: u32, vendor: JdkVendor) -> Self {
+        Self {
+            major,
+            vendor: Some(vendor),
+            priority: Vec::new(),
+        }
+    }
+
+    /// 带上用户优先级(只影响没有 `--vendor` 的情形)。
+    #[must_use]
+    pub fn with_priority(mut self, priority: Vec) -> Self {
+        self.priority = priority;
+        self
+    }
+
+    /// 实际尝试顺序(`--vendor` 钉死时链里只放它)。
+    #[must_use]
+    pub fn order(&self, domestic: bool) -> Vec {
+        match self.vendor {
+            Some(vendor) => vec![vendor],
+            None => vendor::order(&self.priority, domestic),
+        }
+    }
+}
+
+/// 会真正进入回退链的发行版(顺序 + 能力过滤)。
+///
+/// 与 [`install`] 的循环同源,但**纯**:不联网、不落盘,给 `--dry-run` 与离线测试用。
+/// 过滤条件只有一条——该家发不发这个 GA 大版本(MS 没有 8、Oracle 从 9 起)。
+/// `--vendor` 钉死时**不过滤**:链里就它一个,由 `install` 明确报「不支持 Java X」,
+/// 而不是悄悄把用户点名的发行版换成别的。
+#[must_use]
+pub fn chain_order(request: &InstallRequest, domestic: bool) -> Vec {
+    if request.vendor.is_some() {
+        return request.order(domestic);
+    }
+    request
+        .order(domestic)
+        .into_iter()
+        .filter(|vendor| vendor.supports(request.major))
+        .collect()
+}
+
+/// 一次成功安装的结果。
+#[derive(Debug, Clone)]
+pub struct InstalledJdk {
+    /// 大版本号。
+    pub major: u32,
+    /// 实际装上的发行版。
+    pub vendor: JdkVendor,
+    /// 版本查询结果(含归档名与 sha256)。
+    pub release: JdkRelease,
+    /// 托管目录(Portable:`java-managed/jdk-21-temurin`;Standard:`Java/oracle/21.0.2`)。
+    pub root: PathBuf,
+    /// 落点模式(报告里要说清「装到哪」)。
+    pub mode: LocationMode,
+    /// 归档路径(缓存)。
+    pub archive: PathBuf,
+    /// 探测到的运行时。
+    pub runtime: JavaRuntime,
+    /// 实际下载的源标识(`None` = 复用已装,没走网络)。
+    pub downloaded: Option,
+    /// 本次下载字节数(复用已装时为 0)。
+    pub bytes: u64,
+    /// 本次解压的文件数(复用已装时可能为 0)。
+    pub files: usize,
+    /// 本次是否真的解压了。
+    pub extracted: bool,
+    /// 回退链记录(命中与降级原因)。
+    pub chain: ChainLog,
+}
+
+impl InstalledJdk {
+    /// 是否完全复用了已装的那一份。
+    #[must_use]
+    pub fn reused(&self) -> bool {
+        self.downloaded.is_none()
+    }
+
+    /// `bin/java[.exe]` 路径。
+    #[must_use]
+    pub fn binary(&self) -> PathBuf {
+        extract::java_binary(&self.root)
+    }
+
+    /// 实际落点(人读一行)。
+    #[must_use]
+    pub fn location_line(&self) -> String {
+        format!("{} · {}", self.mode.as_str(), self.root.display())
+    }
+}
+
+/// 托管目录路径(Portable 布局 `java-managed/jdk--`)。
+#[must_use]
+pub fn managed_root_dir(managed_root: &Path, major: u32, vendor: &str) -> PathBuf {
+    managed_root.join(probe::managed_dir_name(major, vendor))
+}
+
+/// 归档缓存路径(`/.cache/<归档名>`)。
+#[must_use]
+pub fn archive_path(root: &Path, release: &JdkRelease) -> PathBuf {
+    root.join(CACHE_DIR).join(&release.archive.name)
+}
+
+/// 归档下载请求(`ResourceKind::JavaRuntime` + sha256 + 期望大小)。
+#[must_use]
+pub fn archive_request(release: &JdkRelease, dest: PathBuf) -> DownloadRequest {
+    let request =
+        DownloadRequest::new(ResourceKind::JavaRuntime, release.archive.url.clone(), dest)
+            .with_sha256(release.archive.sha256.clone());
+    if release.archive.size > 0 {
+        request.with_size(release.archive.size)
+    } else {
+        request
+    }
+}
+
+/// 按优先级链装一个托管 JDK(幂等:同一份归档装过就直接复用)。
+///
+/// # Errors
+///
+/// 全部发行版都失败(没有该大版本 / 平台无产物 / 网络 / 校验 / 解压 / 装完跑不起来)时
+/// 返回错误,`message()` 里带着每一跳的降级原因。
+pub async fn install(
+    engine: &DownloadEngine,
+    location: &JavaInstallRoot,
+    request: InstallRequest,
+) -> Result {
+    let target = AdoptiumTarget::host()?;
+    let domestic = engine.region().is_domestic();
+    let priority = request.order(domestic);
+    let mut chain = ChainLog::new();
+    let mut reasons: Vec = Vec::new();
+    // 清掉上次中断留下的 .staging-* 与空壳(只碰我们自己的形状,见 location 模块)。
+    let _ = super::location::cleanup_orphans(&location.root);
+
+    for candidate in &priority {
+        let vendor = *candidate;
+        if !vendor.supports(request.major) {
+            let reason = format!(
+                "不支持 Java {}({} 只发 {})",
+                request.major,
+                vendor.id(),
+                majors_text(vendor)
+            );
+            chain.skip(vendor, reason.clone());
+            reasons.push(format!("{}:{reason}", vendor.id()));
+            continue;
+        }
+        let release = match source::fetch(engine, vendor, request.major, target).await {
+            Ok(release) => release,
+            Err(error) => {
+                chain.failed(vendor, error.message().to_string());
+                reasons.push(format!("{}:{}", vendor.id(), error.message()));
+                continue;
+            }
+        };
+        match install_one(engine, location, &release).await {
+            Ok(prepared) => {
+                chain.hit(vendor, format!("命中 {}", release.summary()));
+                return Ok(InstalledJdk {
+                    major: release.major,
+                    vendor,
+                    release,
+                    root: prepared.root,
+                    mode: location.mode,
+                    archive: prepared.archive,
+                    runtime: prepared.runtime,
+                    downloaded: prepared.downloaded,
+                    bytes: prepared.bytes,
+                    files: prepared.files,
+                    extracted: prepared.extracted,
+                    chain,
+                });
+            }
+            Err(error) => {
+                chain.failed(vendor, error.message().to_string());
+                reasons.push(format!("{}:{}", vendor.id(), error.message()));
+            }
+        }
+    }
+
+    Err(JavaError::new(
+        JavaErrorKind::Metadata,
+        format!(
+            "没有装成 Java {}(试过 {}):{}",
+            request.major,
+            priority.len(),
+            reasons.join(";")
+        ),
+    )
+    .with_hint("`ull java vendors` 看各发行版的可用性;或手动装 JDK 后设 JAVA_HOME"))
+}
+
+/// 某一家的中间结果(把七元组元组换成具名结构,读起来才不费劲)。
+struct Prepared {
+    root: PathBuf,
+    archive: PathBuf,
+    runtime: JavaRuntime,
+    downloaded: Option,
+    bytes: u64,
+    files: usize,
+    extracted: bool,
+}
+
+/// 装某一家的那一份(下载 → 解压 → 探测)。
+async fn install_one(
+    engine: &DownloadEngine,
+    location: &JavaInstallRoot,
+    release: &JdkRelease,
+) -> Result {
+    let root = location.jdk_dir(release.major, release.vendor, &release.version);
+    let binary = extract::java_binary(&root);
+    let archive = archive_path(&location.root, release);
+
+    // 幂等第一层:戳记里的 sha256 与本次一致,且 bin/java 在 → 一个字节都不动。
+    if extract::is_ready(&root, release.sha256()) {
+        let runtime = probe::probe(&binary, JavaOrigin::Managed);
+        if runtime.major == Some(release.major) {
+            let mut runtime = runtime;
+            runtime.vendor = Some(release.vendor.id().to_string());
+            return Ok(Prepared {
+                root,
+                archive,
+                runtime,
+                downloaded: None,
+                bytes: 0,
+                files: 0,
+                extracted: false,
+            });
+        }
+        note(&format!(
+            "托管目录 {} 里的 java 跑不起来,重装一份",
+            root.display()
+        ));
+    }
+
+    if let Some(parent) = archive.parent() {
+        std::fs::create_dir_all(parent)
+            .map_err(|error| io_error("创建归档缓存目录", parent.display(), &error))?;
+    }
+    let outcome = engine
+        .download(archive_request(release, archive.clone()))
+        .await?;
+    let report = extract::extract_archive(&archive, release.sha256(), release.major, &root)?;
+    let mut runtime = probe::probe(&binary, JavaOrigin::Managed);
+    runtime.vendor = Some(release.vendor.id().to_string());
+    if runtime.major != Some(release.major) {
+        return Err(JavaError::new(
+            JavaErrorKind::Probe,
+            format!(
+                "装好的 JDK 探测结果是 Java {},与请求的 {} 不符({})",
+                runtime
+                    .major
+                    .map_or_else(|| "未知".to_string(), |major| major.to_string()),
+                release.major,
+                binary.display()
+            ),
+        )
+        .with_hint("删掉该托管目录后重跑 `ull java install`;或手动设 JAVA_HOME"));
+    }
+    Ok(Prepared {
+        root,
+        archive,
+        runtime,
+        downloaded: Some(outcome.source),
+        bytes: outcome.bytes,
+        files: report.files,
+        extracted: report.wrote(),
+    })
+}
+
+/// 只做规划不落盘:返回将要安装的版本、目标目录与「是否已经装好」。
+///
+/// # Errors
+///
+/// 与 [`install`] 的查询段相同(平台不支持 / 网络失败 / 所有发行版都没有该大版本)。
+pub async fn plan(
+    engine: &DownloadEngine,
+    location: &JavaInstallRoot,
+    major: u32,
+) -> Result<(JdkRelease, PathBuf, bool), JavaError> {
+    let target = AdoptiumTarget::host()?;
+    let domestic = engine.region().is_domestic();
+    let mut reasons: Vec = Vec::new();
+    for vendor in vendor::order(&[], domestic) {
+        if !vendor.supports(major) {
+            reasons.push(format!("{}:不支持 Java {major}", vendor.id()));
+            continue;
+        }
+        match source::fetch(engine, vendor, major, target).await {
+            Ok(release) => {
+                let root = location.jdk_dir(release.major, release.vendor, &release.version);
+                let ready = extract::is_ready(&root, release.sha256());
+                return Ok((release, root, ready));
+            }
+            Err(error) => reasons.push(format!("{}:{}", vendor.id(), error.message())),
+        }
+    }
+    Err(JavaError::new(
+        JavaErrorKind::Metadata,
+        format!("没有可用的 Java {major}({} )", reasons.join(";")),
+    ))
+}
+
+fn majors_text(vendor: JdkVendor) -> String {
+    vendor
+        .hint_majors()
+        .iter()
+        .map(u32::to_string)
+        .collect::>()
+        .join("/")
+}
+
+fn note(message: &str) {
+    if cfg!(debug_assertions) {
+        eprintln!("[ull::game::java] {message}");
+    } else {
+        let _ = message;
+    }
+}
diff --git a/crates/core/src/game/java/location.rs b/crates/core/src/game/java/location.rs
new file mode 100644
index 0000000..21fac8b
--- /dev/null
+++ b/crates/core/src/game/java/location.rs
@@ -0,0 +1,335 @@
+//! 托管 JDK 的落点模式(D23 三模式,与 D6 数据目录三模式对齐)。
+//!
+//! | 模式 | 落点 | 说明 |
+//! | --- | --- | --- |
+//! | Portable | `<数据根>/.ULL/data/java-managed/jdk--/` | v1 既有布局,M33/M35 的托管根解析语义不变 |
+//! | Standard | Windows `%APPDATA%\Java\\\`;macOS `~/Library/Application Support/Java//`;Linux `$XDG_DATA_HOME/java//`(回落 `~/.local/share/java/…`) | 用户原话「安装到 %APPDATA%\Java\发行方\Java版本\」 |
+//! | Custom | 设置键 `java.installRoot` 指定的目录,同样按 `/` 分层 | 覆盖前两种模式 |
+//!
+//! 三条纪律:
+//!
+//! 1. **不猜平台目录**:`%APPDATA%` / `XDG_DATA_HOME` / `HOME` 全走 `platform::paths::EnvSource`,
+//!    测试能注入假环境确定性地覆盖各 OS 落点;
+//! 2. **Portable 布局永不改**:`.ULL/data/java-managed/jdk--` 是 M33 定下的,
+//!    改成 `/` 会让既有托管 JDK 变成孤儿——迁移语义靠「两种形状都扫得到」兜住;
+//! 3. **清理只碰自己的形状**:`cleanup_orphans` 只删 `.staging-*` 半成品与空目录,
+//!    没有戳记的非空目录一律不动(用户可能自己往里塞了 JDK)。
+
+use std::path::{Path, PathBuf};
+
+use crate::platform::data_dir::DataMode;
+use crate::platform::layout::LauncherLayout;
+use crate::platform::paths::{home_dir, EnvSource, Platform};
+
+use super::error::{JavaError, JavaErrorKind};
+use super::vendor::JdkVendor;
+
+/// Standard 模式下 JDK 的父目录名(Windows 是 `%APPDATA%\Java`)。
+pub const JAVA_DIR_NAME: &str = "Java";
+
+/// 归档缓存目录名(在安装根下;托管目录扫描跳过它)。
+pub const CACHE_DIR: &str = ".cache";
+
+/// 解压暂存目录前缀(中断后残留的就是孤儿)。
+pub const STAGING_PREFIX: &str = ".staging-";
+
+/// 落地模式。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum LocationMode {
+    /// 数据根内(v1 布局)。
+    Portable,
+    /// OS 标准应用数据目录。
+    Standard,
+    /// `java.installRoot` 指定。
+    Custom,
+}
+
+impl LocationMode {
+    /// 稳定名(JSON / 日志)。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Portable => "portable",
+            Self::Standard => "standard",
+            Self::Custom => "custom",
+        }
+    }
+
+    /// 从配置字符串解析。
+    #[must_use]
+    pub fn parse(text: &str) -> Option {
+        match text {
+            "portable" => Some(Self::Portable),
+            "standard" => Some(Self::Standard),
+            "custom" => Some(Self::Custom),
+            _ => None,
+        }
+    }
+}
+
+/// 解析好的安装根 + 它是怎么来的。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct JavaInstallRoot {
+    /// 落地模式。
+    pub mode: LocationMode,
+    /// 安装根目录(各 JDK 目录在它下面)。
+    pub root: PathBuf,
+}
+
+impl JavaInstallRoot {
+    /// Portable:`<数据根>/.ULL/data/java-managed`。
+    #[must_use]
+    pub fn portable(launcher: &LauncherLayout) -> Self {
+        Self {
+            mode: LocationMode::Portable,
+            root: launcher.java_managed(),
+        }
+    }
+
+    /// Standard:OS 标准应用数据目录 + `Java`。
+    ///
+    /// 平台约定与 `platform::paths::standard_launcher_home` 同源:
+    /// - Windows:`%APPDATA%\Java`(回落 `%USERPROFILE%\AppData\Roaming\Java`)
+    /// - macOS:`~/Library/Application Support/Java`
+    /// - Linux / 其它:`$XDG_DATA_HOME/java`(回落 `~/.local/share/java`)
+    #[must_use]
+    pub fn standard(platform: Platform, env: &dyn EnvSource) -> Option {
+        standard_java_root(platform, env).map(|root| Self {
+            mode: LocationMode::Standard,
+            root,
+        })
+    }
+
+    /// Custom:`java.installRoot` 指定的目录(相对路径按数据根解析)。
+    #[must_use]
+    pub fn custom(root: impl Into) -> Self {
+        Self {
+            mode: LocationMode::Custom,
+            root: root.into(),
+        }
+    }
+
+    /// 按设置解析出真正的落点。
+    ///
+    /// 顺序:`java.installRoot`(设了就用,模式 Custom)→ Standard(数据目录 Standard 模式)
+    /// → Portable(数据根内,含 `--data-dir` 的 Custom 数据模式)。
+    ///
+    /// # Errors
+    ///
+    /// 数据目录是 Standard 但拿不到系统标准目录时返回 [`JavaErrorKind::Unsupported`]
+    /// (不静默落到别处——否则用户会在 `%APPDATA%\Java` 里找不到刚装的 JDK)。
+    pub fn resolve(
+        data_mode: DataMode,
+        launcher: &LauncherLayout,
+        override_root: Option<&Path>,
+        platform: Platform,
+        env: &dyn EnvSource,
+    ) -> Result {
+        if let Some(root) = override_root.filter(|root| !root.as_os_str().is_empty()) {
+            let root = if root.is_absolute() {
+                root.to_path_buf()
+            } else {
+                launcher.home().join(root)
+            };
+            return Ok(Self::custom(root));
+        }
+        if data_mode == DataMode::Standard {
+            return Self::standard(platform, env).ok_or_else(|| {
+                JavaError::new(
+                    JavaErrorKind::Unsupported,
+                    "Standard 模式下拿不到系统标准目录(%APPDATA% / HOME 都读不到)",
+                )
+                .with_hint(
+                    "设置 `java.installRoot` 指定一个目录,或把 `core.data_mode` 改回 portable",
+                )
+            });
+        }
+        Ok(Self::portable(launcher))
+    }
+
+    /// 某个 JDK 的目录。
+    ///
+    /// Standard / Custom 用 `/`(D23 用户原话的 `<发行方>\\`),
+    /// Portable 保持 v1 的 `jdk--`(改形状会让既有托管 JDK 变孤儿)。
+    #[must_use]
+    pub fn jdk_dir(&self, major: u32, vendor: JdkVendor, version: &str) -> PathBuf {
+        match self.mode {
+            LocationMode::Standard | LocationMode::Custom => {
+                self.root.join(vendor.id()).join(sanitize_version(version))
+            }
+            LocationMode::Portable => self
+                .root
+                .join(super::probe::managed_dir_name(major, vendor.id())),
+        }
+    }
+
+    /// 归档缓存目录(`/.cache`)。
+    #[must_use]
+    pub fn cache_dir(&self) -> PathBuf {
+        self.root.join(CACHE_DIR)
+    }
+
+    /// 人读描述(`Standard(%APPDATA%\Java)` 这种)。
+    #[must_use]
+    pub fn describe(&self) -> String {
+        format!("{}({})", self.mode.as_str(), self.root.display())
+    }
+}
+
+/// OS 标准 JDK 根(纯函数,测试注入环境)。
+///
+/// 注意 `HOME` 不用 `platform::paths::home_dir`:那个函数按**编译目标**分叉(Windows 上读
+/// `USERPROFILE`),会让「在 Windows 上断言 macOS/Linux 落点」这种测试无法成立。
+/// 这里按传入的 `platform` 参数走,语义与 `paths::standard_launcher_home` 的落点一致。
+#[must_use]
+pub fn standard_java_root(platform: Platform, env: &dyn EnvSource) -> Option {
+    match platform {
+        Platform::Windows => roaming_app_data(env).map(|dir| dir.join(JAVA_DIR_NAME)),
+        Platform::MacOs => {
+            unix_home(env).map(|home| home.join("Library/Application Support").join(JAVA_DIR_NAME))
+        }
+        Platform::Linux | Platform::Other => env
+            .var("XDG_DATA_HOME")
+            .map(PathBuf::from)
+            .filter(|dir| dir.is_absolute())
+            .or_else(|| unix_home(env).map(|home| home.join(".local/share")))
+            .map(|dir| dir.join("java")),
+    }
+}
+
+/// Unix 家目录(`HOME`,缺了才退回 `platform::paths::home_dir`)。
+fn unix_home(env: &dyn EnvSource) -> Option {
+    env.var("HOME").map(PathBuf::from).or_else(|| home_dir(env))
+}
+
+/// `%APPDATA%`,回落 `%USERPROFILE%\AppData\Roaming`。
+fn roaming_app_data(env: &dyn EnvSource) -> Option {
+    env.var("APPDATA").map(PathBuf::from).or_else(|| {
+        env.var("USERPROFILE")
+            .map(|profile| PathBuf::from(profile).join("AppData/Roaming"))
+    })
+}
+
+/// 版本号 → 目录名(只保留文件系统安全的字符)。
+///
+/// Temurin `21.0.13+9`、MS `21.0.13.0.2`、Zulu `21.0.13+3` 都原样可作目录名;
+/// 遇到 `~` / `/` 这类才替换(Zulu 的 `21.0.13+3-x86_64` 之类不会出现,但别赌)。
+#[must_use]
+pub fn sanitize_version(version: &str) -> String {
+    let cleaned: String = version
+        .trim()
+        .chars()
+        .map(|ch| {
+            if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | '+') {
+                ch
+            } else {
+                '_'
+            }
+        })
+        .collect();
+    if cleaned.is_empty() {
+        "unknown".to_string()
+    } else {
+        cleaned
+    }
+}
+
+/// `java list` 要扫的全部安装根(Portable 数据根在前,去重)。
+#[must_use]
+pub fn scan_roots(
+    data_mode: DataMode,
+    launcher: &LauncherLayout,
+    override_root: Option<&Path>,
+    platform: Platform,
+    env: &dyn EnvSource,
+) -> Vec {
+    let mut roots = vec![launcher.java_managed()];
+    if let Ok(resolved) =
+        JavaInstallRoot::resolve(data_mode, launcher, override_root, platform, env)
+    {
+        if !roots.contains(&resolved.root) {
+            roots.push(resolved.root);
+        }
+    }
+    // 数据目录是 Standard 时,便携目录里的老装法仍然可能还在(用户从便携切过来的)。
+    if let Some(standard) = standard_java_root(platform, env) {
+        if !roots.contains(&standard) {
+            roots.push(standard);
+        }
+    }
+    roots
+}
+
+/// 清理结果。
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct CleanupReport {
+    /// 删掉的路径(暂存目录、空壳目录)。
+    pub removed: Vec,
+    /// 保留下来的条目数(都是我们没资格动的)。
+    pub kept: usize,
+}
+
+impl CleanupReport {
+    /// 是否什么都没删。
+    #[must_use]
+    pub fn is_noop(&self) -> bool {
+        self.removed.is_empty()
+    }
+}
+
+/// 清掉安装根下的半成品与空壳。
+///
+/// 只碰两种东西:①`.staging-*`(解压中断的残留,永远可以删)②空目录(含空 vendor 目录)。
+/// 其余一律保留并计入 `kept`——**宁可不删,也不误删用户手工放进来的 JDK**。
+#[must_use]
+pub fn cleanup_orphans(root: &Path) -> CleanupReport {
+    let mut report = CleanupReport::default();
+    let Ok(entries) = std::fs::read_dir(root) else {
+        return report;
+    };
+    for entry in entries.flatten() {
+        let path = entry.path();
+        let name = entry.file_name().to_string_lossy().into_owned();
+        if !path.is_dir() || name == CACHE_DIR {
+            report.kept += 1;
+            continue;
+        }
+        if name.starts_with(STAGING_PREFIX) {
+            if std::fs::remove_dir_all(&path).is_ok() {
+                report.removed.push(path);
+            } else {
+                report.kept += 1;
+            }
+            continue;
+        }
+        if is_empty_dir(&path) {
+            if std::fs::remove_dir(&path).is_ok() {
+                report.removed.push(path);
+            } else {
+                report.kept += 1;
+            }
+            continue;
+        }
+        // 两层的 Standard/Custom 布局:`/` 里的空目录也要收。
+        if JdkVendor::parse(&name).is_some() {
+            for child in std::fs::read_dir(&path).into_iter().flatten().flatten() {
+                let child_path = child.path();
+                if child_path.is_dir()
+                    && is_empty_dir(&child_path)
+                    && std::fs::remove_dir(&child_path).is_ok()
+                {
+                    report.removed.push(child_path);
+                }
+            }
+            if is_empty_dir(&path) && std::fs::remove_dir(&path).is_ok() {
+                report.removed.push(path);
+            }
+        }
+        report.kept += 1;
+    }
+    report
+}
+
+fn is_empty_dir(dir: &Path) -> bool {
+    std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none())
+}
diff --git a/crates/core/src/game/java/mapping.rs b/crates/core/src/game/java/mapping.rs
new file mode 100644
index 0000000..930b889
--- /dev/null
+++ b/crates/core/src/game/java/mapping.rs
@@ -0,0 +1,376 @@
+//! MC 版本 ↔ Java 版本映射(平移 HMCL 约束体系)+ 不符检查。
+//!
+//! 依据 `DevDocs/analyse/02-launch-pipeline.md` §5.4(HMCL `GameJavaVersion.getMinimumJavaVersion`)
+//! 与 §3.1 的强制约束清单(`JavaVersionConstraint` 的 `isMandatory` 项)。两条权威叠加:
+//!
+//! | MC 版本 | 最低 | 最高 | 出处 |
+//! | --- | --- | --- | --- |
+//! | ≥ 26.1 | 25 | — | `java-runtime-epsilon` |
+//! | ≥ 1.20.5 | 21 | — | `java-runtime-delta` |
+//! | ≥ 1.18 | 17 | — | `java-runtime-beta` |
+//! | ≥ 1.17 | 16 | — | `java-runtime-alpha` |
+//! | ≥ 1.13 | 8 | — | `jre-legacy` |
+//! | < 1.13 | 8 | **8** | `LAUNCH_WRAPPER`:LaunchWrapper 的 `URLClassLoader` 只能吃 Java 8 |
+//!
+//! **版本 JSON 优先**:装了 1.17 的玩家看的是 Mojang 自己写的 `javaVersion.majorVersion`
+//! (1.17 就是 16),静态表只在清单没写时兜底——这也是 HMCL 的 `GAME_JSON` 强制项语义。
+//!
+//! 上限(`maximum`)只对老版本存在:1.12.2 用 Java 17 会直接
+//! `UnsupportedClassVersionError`-级别的失败(启动器侧是 LaunchWrapper 假设),
+//! 所以「1.12.2 + Java 21」要被判成不符,而不是「19 以上都能跑」。
+
+use std::cmp::Ordering;
+
+use super::super::inherit::resolve;
+use super::super::layout::GameLayout;
+use super::super::manifest::{JavaVersion, VersionJson};
+
+/// 要求来源(`--json` 的 `requirement_source`)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RequirementSource {
+    /// 版本 JSON 的 `javaVersion`(含继承链解析)。
+    Manifest,
+    /// 静态表(HMCL 约束体系的平移)。
+    Table,
+}
+
+impl RequirementSource {
+    /// 稳定字符串 id。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Manifest => "manifest",
+            Self::Table => "table",
+        }
+    }
+}
+
+/// 一个版本对 Java 的要求(闭区间)。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct JavaRequirement {
+    /// 最低主版本号(`21`)。
+    pub minimum: u32,
+    /// 最高主版本号(`Some(8)` = 只能用 8 及以下)。
+    pub maximum: Option,
+    /// 来源。
+    pub source: RequirementSource,
+}
+
+impl JavaRequirement {
+    /// 构造。
+    #[must_use]
+    pub const fn new(minimum: u32, maximum: Option, source: RequirementSource) -> Self {
+        Self {
+            minimum,
+            maximum,
+            source,
+        }
+    }
+
+    /// 静态表来源的构造。
+    #[must_use]
+    pub const fn table(minimum: u32, maximum: Option) -> Self {
+        Self::new(minimum, maximum, RequirementSource::Table)
+    }
+
+    /// 某主版本号是否满足要求。
+    #[must_use]
+    pub fn satisfied_by(&self, major: u32) -> bool {
+        major >= self.minimum && self.maximum.is_none_or(|maximum| major <= maximum)
+    }
+
+    /// 人读描述(`Java 21+` / `Java 8` / `Java 8–16`)。
+    #[must_use]
+    pub fn describe(&self) -> String {
+        match self.maximum {
+            Some(maximum) if maximum == self.minimum => format!("Java {}", self.minimum),
+            Some(maximum) => format!("Java {}–{}", self.minimum, maximum),
+            None => format!("Java {}+", self.minimum),
+        }
+    }
+}
+
+/// 不符的两种形态(D13:只提示,不阻断)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum JavaMismatch {
+    /// Java 太低。
+    TooOld {
+        /// 需要的最低主版本。
+        required: u32,
+        /// 实际主版本。
+        actual: u32,
+    },
+    /// Java 太高(老版本只能用 8)。
+    TooNew {
+        /// 允许的最高主版本。
+        maximum: u32,
+        /// 实际主版本。
+        actual: u32,
+    },
+}
+
+impl JavaMismatch {
+    /// 稳定字符串 id(`--json` 的 `mismatch.code`)。
+    #[must_use]
+    pub const fn code(self) -> &'static str {
+        match self {
+            Self::TooOld { .. } => "java_too_old",
+            Self::TooNew { .. } => "java_too_new",
+        }
+    }
+
+    /// 人读说明(GUI 弹窗 P1 后直接复用这句)。
+    #[must_use]
+    pub fn message(self) -> String {
+        match self {
+            Self::TooOld { required, actual } => format!(
+                "该版本需要 Java {required} 及以上,当前用的是 Java {actual}(大概率起不来)"
+            ),
+            Self::TooNew { maximum, actual } => format!(
+                "该版本只能用 Java {maximum} 及以下,当前用的是 Java {actual}(老加载器吃不了新 class 文件)"
+            ),
+        }
+    }
+}
+
+/// 判需求与实际是否相符(`None` = 相符)。
+#[must_use]
+pub fn check(actual: u32, required: &JavaRequirement) -> Option {
+    if actual < required.minimum {
+        return Some(JavaMismatch::TooOld {
+            required: required.minimum,
+            actual,
+        });
+    }
+    if let Some(maximum) = required.maximum {
+        if actual > maximum {
+            return Some(JavaMismatch::TooNew { maximum, actual });
+        }
+    }
+    None
+}
+
+/// 静态表:MC 版本 → Java 要求。
+///
+/// 无法解析的版本(周快照 `20w14a`、魔改 id)只给下限 `8`、不设上限——
+/// 宁可漏判一次,也不要把「Java 21 + 1.20 快照」误锁成「必须降到 8」。
+#[must_use]
+pub fn requirement_for(mc_version: &str) -> JavaRequirement {
+    let parts = version_parts(mc_version);
+    if parts.is_empty() {
+        return JavaRequirement::table(8, None);
+    }
+    if at_least(&parts, &[26, 1]) {
+        return JavaRequirement::table(25, None);
+    }
+    if at_least(&parts, &[1, 20, 5]) {
+        return JavaRequirement::table(21, None);
+    }
+    if at_least(&parts, &[1, 18]) {
+        return JavaRequirement::table(17, None);
+    }
+    if at_least(&parts, &[1, 17]) {
+        return JavaRequirement::table(16, None);
+    }
+    if at_least(&parts, &[1, 13]) {
+        return JavaRequirement::table(8, None);
+    }
+    JavaRequirement::table(8, Some(8))
+}
+
+/// 静态表的最低位(`recommended_major("1.20.5") == 21`)。
+#[must_use]
+pub fn recommended_major(mc_version: &str) -> u32 {
+    requirement_for(mc_version).minimum
+}
+
+/// 版本 JSON 的 `javaVersion` → 要求(字段缺失或为 0 时返回 `None`)。
+///
+/// Mojang 这个字段给的是**最低**要求(`majorVersion: 21` 表示 21 起跳),
+/// 不声明上限——所以这里 `maximum` 恒为 `None`;上限由 [`requirement_for`] 补
+/// (见 [`requirement_for_version`] 的合成说明)。
+#[must_use]
+pub fn requirement_from_manifest(java: &JavaVersion) -> Option {
+    let major = java.major_version.filter(|major| *major > 0)?;
+    Some(JavaRequirement::new(
+        major,
+        None,
+        RequirementSource::Manifest,
+    ))
+}
+
+/// 已安装版本的 Java 要求:解析继承链 → 版本 JSON 的 `javaVersion` 优先 → 静态表兜底。
+///
+/// **下限与上限来源不同**:Mojang 的 `javaVersion` 只声明「最低要多少」,
+/// 而「1.12.2 及更早吃不了 Java 9+」这条知识来自 HMCL 的 `LAUNCH_WRAPPER` 约束,
+/// 不在清单里。所以合成时下限取清单、上限取静态表——否则「1.12.2 + Java 21」
+/// 会被判成完全合法(真机上必崩)。
+///
+/// 版本 JSON 读不到(未安装 / 损坏)时直接走静态表,不报错——这是「启动前提示」的路径,
+/// 不该因为清单缺件就拦住用户(真正拦人的是 `snapshot::prepare`)。
+#[must_use]
+pub fn requirement_for_version(layout: &GameLayout, version_id: &str) -> JavaRequirement {
+    let table = requirement_for(version_id);
+    let read = |id: &str| -> Option {
+        let text = std::fs::read_to_string(layout.version_json(id)).ok()?;
+        VersionJson::from_json_str(&text).ok()
+    };
+    let Some(manifest) = read(version_id) else {
+        return table;
+    };
+    let resolved = resolve(&read, &manifest).unwrap_or(manifest);
+    let Some(declared) = resolved
+        .java_version
+        .as_ref()
+        .and_then(requirement_from_manifest)
+    else {
+        // 清单没写 javaVersion:按 id 的静态表(继承来的 id 可能是魔改名)。
+        return match resolved.id.as_deref() {
+            Some(id) => requirement_for(id),
+            None => table,
+        };
+    };
+    JavaRequirement {
+        minimum: declared.minimum,
+        maximum: table.maximum,
+        source: RequirementSource::Manifest,
+    }
+}
+
+/// 版本串 → 数字分量(`1.20.5-pre1` → `[1, 20, 5]`;周快照 → 空)。
+fn version_parts(raw: &str) -> Vec {
+    let trimmed = raw.trim();
+    if is_weekly_snapshot(trimmed) {
+        return Vec::new();
+    }
+    let mut parts = Vec::new();
+    for piece in trimmed.split('.') {
+        let digits: String = piece.chars().take_while(char::is_ascii_digit).collect();
+        match digits.parse::() {
+            Ok(value) => parts.push(value),
+            Err(_) => break,
+        }
+    }
+    parts
+}
+
+/// `20w14a` / `26w14a` 形态(与 `launch::game_version` 同一套判据)。
+fn is_weekly_snapshot(raw: &str) -> bool {
+    let bytes = raw.as_bytes();
+    bytes.len() == 6
+        && bytes[..2].iter().all(u8::is_ascii_digit)
+        && bytes[2].eq_ignore_ascii_case(&b'w')
+        && bytes[3..5].iter().all(u8::is_ascii_digit)
+        && bytes[5].is_ascii_alphabetic()
+}
+
+/// `parts >= floor`(缺位按 0:`1.20` < `1.20.5`)。
+fn at_least(parts: &[u32], floor: &[u32]) -> bool {
+    let len = parts.len().max(floor.len());
+    for index in 0..len {
+        let left = parts.get(index).copied().unwrap_or(0);
+        let right = floor.get(index).copied().unwrap_or(0);
+        match left.cmp(&right) {
+            Ordering::Greater => return true,
+            Ordering::Less => return false,
+            Ordering::Equal => {}
+        }
+    }
+    true
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{check, recommended_major, requirement_for, JavaMismatch, JavaRequirement};
+
+    #[test]
+    fn table_covers_the_hmcl_boundaries() {
+        let cases: &[(&str, u32, Option)] = &[
+            ("1.6.4", 8, Some(8)),
+            ("1.12.2", 8, Some(8)),
+            ("1.13", 8, None),
+            ("1.15.2", 8, None),
+            ("1.16.5", 8, None),
+            ("1.17", 16, None),
+            ("1.17.1", 16, None),
+            ("1.18", 17, None),
+            ("1.19.4", 17, None),
+            ("1.20.1", 17, None),
+            ("1.20.4", 17, None),
+            ("1.20.5", 21, None),
+            ("1.21.4", 21, None),
+            ("26.1", 25, None),
+            ("26.3", 25, None),
+            ("26.3-snapshot-1", 25, None),
+        ];
+        for (version, minimum, maximum) in cases {
+            let requirement = requirement_for(version);
+            assert_eq!(
+                (requirement.minimum, requirement.maximum),
+                (*minimum, *maximum),
+                "{version}"
+            );
+            assert_eq!(recommended_major(version), *minimum, "{version}");
+        }
+    }
+
+    #[test]
+    fn weekly_snapshots_get_no_upper_bound() {
+        // 20w14a 的前导数字是 20,绝不能被当成「26.x」或误锁上限。
+        let requirement = requirement_for("20w14a");
+        assert_eq!(requirement.minimum, 8);
+        assert_eq!(requirement.maximum, None);
+        assert!(requirement.satisfied_by(21));
+    }
+
+    #[test]
+    fn pre_release_suffixes_do_not_change_the_floor() {
+        assert_eq!(requirement_for("1.20.5-pre1").minimum, 21);
+        assert_eq!(requirement_for("1.20.4-rc1").minimum, 17);
+    }
+
+    #[test]
+    fn check_reports_both_directions() {
+        let modern = JavaRequirement::table(21, None);
+        assert_eq!(check(21, &modern), None);
+        assert_eq!(check(25, &modern), None);
+        assert_eq!(
+            check(17, &modern),
+            Some(JavaMismatch::TooOld {
+                required: 21,
+                actual: 17
+            })
+        );
+        let legacy = JavaRequirement::table(8, Some(8));
+        assert_eq!(check(8, &legacy), None);
+        assert_eq!(
+            check(21, &legacy),
+            Some(JavaMismatch::TooNew {
+                maximum: 8,
+                actual: 21
+            })
+        );
+        assert_eq!(legacy.describe(), "Java 8");
+        assert_eq!(modern.describe(), "Java 21+");
+        assert_eq!(JavaRequirement::table(8, Some(16)).describe(), "Java 8–16");
+    }
+
+    #[test]
+    fn mismatch_messages_name_both_sides() {
+        let message = JavaMismatch::TooOld {
+            required: 21,
+            actual: 8,
+        }
+        .message();
+        assert!(message.contains("Java 21"));
+        assert!(message.contains("Java 8"));
+        assert_eq!(
+            JavaMismatch::TooNew {
+                maximum: 8,
+                actual: 21
+            }
+            .code(),
+            "java_too_new"
+        );
+    }
+}
diff --git a/crates/core/src/game/java/microsoft.rs b/crates/core/src/game/java/microsoft.rs
new file mode 100644
index 0000000..957c59c
--- /dev/null
+++ b/crates/core/src/game/java/microsoft.rs
@@ -0,0 +1,261 @@
+//! Microsoft OpenJDK:`aka.ms` 模板下载 + 成对 `.sha256sum.txt` 校验。
+//!
+//! 分发形态与 Temurin / Oracle 都不同(research/06 §3.2):
+//!
+//! ```text
+//! 下载   https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip
+//!        → 301 → https://download.visualstudio.microsoft.com/download/pr/{guid}/{hash}/{同名}
+//! 校验   https://aka.ms/download-jdk/<同一文件名>.sha256sum.txt   # "  "
+//! ```
+//!
+//! 三条硬事实(都影响实现):
+//!
+//! 1. **版本是四段/五段**(`21.0.13.0.2` = JDK 21.0.13+2;11 线是 `11.0.32.1`),
+//!    不能拿 Temurin 的三段号直接拼——所以这里有自己的 [`parse_version`] 与内置快照表;
+//! 2. **没有 8**(微软已停发,官方页面让去下 Temurin 8),`supports()` 也这么判;
+//! 3. **GitHub 侧没有任何资产**(`microsoft/openjdk` 的 release `assets` 全空),
+//!    所以 gh-proxy 对这条链毫无用处——`domestic_mirrors` 实测为空数组。
+
+use super::error::{JavaError, JavaErrorKind};
+use super::net;
+use super::release::{parse_sha256_text, JdkArchive, JdkRelease};
+use super::target::AdoptiumTarget;
+use super::vendor::JdkVendor;
+use crate::download::MsOpenJdkTable;
+
+/// 内置版本快照(`learn.microsoft.com/java/openjdk/download` 2026-10-30 的表,research/06 §1.3)。
+///
+/// 官方只有人读 HTML 表、没有 JSON API,逐版本探测又要 `aka.ms` 301(0.3–0.8 s/次),
+/// 所以按 research/06 §3.3 的建议 A 内置快照;`size` 与实测字节数一致,用于长度对拍。
+pub const SNAPSHOT: &[(u32, &str, u64)] = &[
+    (25, "25.0.5.0.2", 220_869_659),
+    (21, "21.0.13.0.2", 201_450_315),
+    (17, "17.0.21.0.2", 187_236_595),
+    (11, "11.0.32.1", 191_250_397),
+];
+
+/// 四段/五段版本号(`21.0.13.0.2` / `11.0.32.1`)。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MsVersion {
+    /// 大版本(`21`)。
+    pub major: u32,
+    /// 各数字段(长度 3..=5)。
+    pub segments: Vec,
+}
+
+impl MsVersion {
+    /// 原样的版本串。
+    #[must_use]
+    pub fn text(&self) -> String {
+        self.segments
+            .iter()
+            .map(u32::to_string)
+            .collect::>()
+            .join(".")
+    }
+}
+
+/// 解析 MS 的版本串;段数必须 3..=5(MS 的语义:`major.update.patch[.build[.rev]]`)。
+#[must_use]
+pub fn parse_version(text: &str) -> Option {
+    let mut segments = Vec::new();
+    for segment in text.trim().split('.') {
+        segments.push(segment.parse::().ok()?);
+    }
+    if segments.len() < 3 || segments.len() > 5 {
+        return None;
+    }
+    Some(MsVersion {
+        major: segments[0],
+        segments,
+    })
+}
+
+/// MS 的 os 段(`windows` / `macos` / `linux`)。
+#[must_use]
+pub fn ms_os(target: AdoptiumTarget) -> &'static str {
+    match target.os {
+        "mac" => "macos",
+        other => other,
+    }
+}
+
+/// MS 的 arch 段(`x64` / `aarch64`)。
+#[must_use]
+pub fn ms_arch(target: AdoptiumTarget) -> &'static str {
+    target.arch
+}
+
+/// 归档文件名(模板:`microsoft-jdk-{version}-{os}-{arch}.zip`,全平台都是 zip)。
+#[must_use]
+pub fn archive_name(version: &str, target: AdoptiumTarget) -> String {
+    format!(
+        "microsoft-jdk-{version}-{}-{}.zip",
+        ms_os(target),
+        ms_arch(target)
+    )
+}
+
+/// 把模板里的 `{version}` / `{os}` / `{arch}` 换掉。
+#[must_use]
+pub fn expand_template(template: &str, version: &str, target: AdoptiumTarget) -> String {
+    template
+        .replace("{version}", version)
+        .replace("{os}", ms_os(target))
+        .replace("{arch}", ms_arch(target))
+}
+
+/// 快照里该大版本的版本串(没有 → `None`)。
+#[must_use]
+pub fn snapshot_version(major: u32) -> Option<&'static str> {
+    SNAPSHOT
+        .iter()
+        .find(|(known, _, _)| *known == major)
+        .map(|(_, version, _)| *version)
+}
+
+/// 快照里该大版本的字节数。
+#[must_use]
+pub fn snapshot_size(major: u32) -> Option {
+    SNAPSHOT
+        .iter()
+        .find(|(known, _, _)| *known == major)
+        .map(|(_, _, size)| *size)
+}
+
+/// 查一次「某大版本的最新 MS OpenJDK」。
+///
+/// 两跳:内置快照挑版本 → 取 `.sha256sum.txt` 拿哈希。校验文件取不到就整家失败
+/// (宁可降级到 Zulu,也不下一个没法验证的 200 MB 归档)。
+///
+/// # Errors
+///
+/// 表里没配模板、该大版本微软不发(含 8)、校验文件取不到或格式不对时返回错误。
+pub async fn fetch_latest(
+    client: &reqwest::Client,
+    table: &MsOpenJdkTable,
+    major: u32,
+    target: AdoptiumTarget,
+) -> Result {
+    let version = snapshot_version(major).ok_or_else(|| {
+        JavaError::new(
+            JavaErrorKind::Metadata,
+            format!(
+                "Microsoft OpenJDK 没有 {major} 的 GA(只发 {})",
+                majors_text()
+            ),
+        )
+        .with_hint("8 用 temurin;其它大版本用 oracle / zulu")
+    })?;
+    let filename = archive_name(version, target);
+    let sha_url = expand_template(&table.sha256_template, version, target);
+    let sha_text = net::get_text(client, &sha_url, "Microsoft OpenJDK 校验文件").await?;
+    let sha256 = parse_sha256_text(&sha_text).ok_or_else(|| {
+        JavaError::new(
+            JavaErrorKind::Integrity,
+            format!("{filename} 的 .sha256sum.txt 里没有 64 位哈希"),
+        )
+    })?;
+    Ok(JdkRelease {
+        vendor: JdkVendor::Microsoft,
+        major,
+        version: version.to_string(),
+        semver: None,
+        release_name: version.to_string(),
+        archive: JdkArchive {
+            name: filename.clone(),
+            url: expand_template(&table.template, version, target),
+            sha256,
+            size: snapshot_size(major).unwrap_or(0),
+        },
+    })
+}
+
+fn majors_text() -> String {
+    SNAPSHOT
+        .iter()
+        .map(|(major, _, _)| major.to_string())
+        .collect::>()
+        .join(" / ")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{
+        archive_name, expand_template, ms_os, parse_version, snapshot_size, snapshot_version,
+        SNAPSHOT,
+    };
+    use crate::game::java::target::AdoptiumTarget;
+
+    const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
+
+    #[test]
+    fn snapshot_covers_the_four_majors_microsoft_ships() {
+        assert_eq!(snapshot_version(21), Some("21.0.13.0.2"));
+        assert_eq!(snapshot_version(11), Some("11.0.32.1"));
+        assert_eq!(snapshot_version(8), None, "微软已停发 8");
+        assert_eq!(snapshot_size(17), Some(187_236_595));
+        assert_eq!(
+            SNAPSHOT
+                .iter()
+                .map(|(major, _, _)| *major)
+                .collect::>(),
+            vec![25, 21, 17, 11]
+        );
+    }
+
+    #[test]
+    fn four_and_five_segment_versions_have_their_own_semantics() {
+        // 21.0.13.0.2 = JDK 21.0.13+2(五段);11 线只有四段。
+        let ms = parse_version("21.0.13.0.2").expect("五段应解析");
+        assert_eq!(ms.major, 21);
+        assert_eq!(ms.segments.len(), 5);
+        assert_eq!(ms.text(), "21.0.13.0.2");
+        let eleven = parse_version("11.0.32.1").expect("四段应解析");
+        assert_eq!(eleven.major, 11);
+        assert_eq!(eleven.segments.len(), 4);
+        // Temurin 的三段号形态在这里同样成立,但语义由快照表定义,不共用 Temurin 的解析。
+        assert_eq!(
+            parse_version("17.0.21").map(|version| version.major),
+            Some(17)
+        );
+        assert_eq!(parse_version("21"), None);
+        assert_eq!(parse_version("21.0"), None);
+        assert_eq!(parse_version("21.0.13.0.2.1"), None);
+        assert_eq!(parse_version("21.0.x.2"), None);
+    }
+
+    #[test]
+    fn archive_names_and_templates_expand_per_platform() {
+        assert_eq!(
+            archive_name("21.0.13.0.2", WINDOWS),
+            "microsoft-jdk-21.0.13.0.2-windows-x64.zip"
+        );
+        let template = "https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip";
+        assert_eq!(
+            expand_template(template, "17.0.21.0.2", WINDOWS),
+            "https://aka.ms/download-jdk/microsoft-jdk-17.0.21.0.2-windows-x64.zip"
+        );
+        assert_eq!(
+            expand_template(
+                "https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip.sha256sum.txt",
+                "21.0.13.0.2",
+                AdoptiumTarget::new("linux", "aarch64")
+            ),
+            "https://aka.ms/download-jdk/microsoft-jdk-21.0.13.0.2-linux-aarch64.zip.sha256sum.txt"
+        );
+        assert_eq!(ms_os(AdoptiumTarget::new("mac", "x64")), "macos");
+    }
+
+    #[test]
+    fn builtin_table_uses_the_official_templates() {
+        let table = crate::download::MirrorTable::builtin();
+        let microsoft = table.microsoft_openjdk().expect("内置表应带 MS 分发");
+        assert!(microsoft
+            .template
+            .starts_with("https://aka.ms/download-jdk/"));
+        assert!(microsoft.sha256_template.ends_with(".sha256sum.txt"));
+        assert!(microsoft.domestic_mirrors.is_empty(), "MS 无国内镜像");
+        assert_eq!(microsoft.majors, vec![25, 21, 17, 11]);
+    }
+}
diff --git a/crates/core/src/game/java/mod.rs b/crates/core/src/game/java/mod.rs
new file mode 100644
index 0000000..45e0110
--- /dev/null
+++ b/crates/core/src/game/java/mod.rs
@@ -0,0 +1,90 @@
+//! Java 运行时管理(D13 v1 → D23 v2):托管 JDK 的查询 / 下载 / 解压 / 探测,以及 MC↔Java 映射。
+//!
+//! ```text
+//! target    宿主平台 → Adoptium 的 os/arch(Win=zip、Linux/mac=tar.gz)
+//! vendor    四家发行版与用户可调的优先级链(国内 Temurin→Oracle→MS / 国外 Temurin→MS→Oracle)
+//! api       Temurin:api.adoptium.net 查询最新 GA → (github 直链, sha256, size)
+//! oracle    Oracle OpenJDK:镜像目录页解析(hash 段不可推导,禁止拼 URL)+ .sha256
+//! microsoft MS OpenJDK:aka.ms 模板 + 内置四段版本快照 + .sha256sum.txt
+//! zulu      Azul Zulu:两跳 JSON API(列表 → 详情)+ CDN 直链(第四备选)
+//! source    发行版分派 + 回退链记录(命中与降级原因进事件流)
+//! location  落点三模式(Portable / Standard / Custom)+ 孤儿目录清理
+//! release   一次版本查询的统一产物(vendor 原生版本串 + 归档 + sha256 + 大小)
+//! net       版本查询用的极小 GET 文本工具(下载不走这里)
+//! install   编排:回退链 → 下载引擎(镜像/健康度/退避)→ 解压 → 探测验证
+//! extract   zip / tar.gz 解压 + 顶层目录剥离 + 幂等戳记 + zip-slip 防护
+//! probe     java -version 探测、托管目录扫描(两种布局都认)
+//! discover  标准安装位置扫描(Program Files 系 / 官方启动器 runtime / 注册表 / /usr/lib/jvm)+ 去重
+//! select    启动选 Java 的**纯**优先链(满足要求优先,再按来源)
+//! mapping   MC↔Java 静态表(HMCL `GameJavaVersion`)+ 版本 JSON 优先 + 不符检查
+//! ```
+//!
+//! # 三条边界
+//!
+//! 1. **不碰网络细节**:包下载一律交 `core::download` 的引擎,本模块不写重试循环
+//!    (`MirrorTable::expand` 负责 Temurin 的镜像展开;Oracle 的目录解析在 `oracle`);
+//! 2. **不弹窗**:不符检查只产出数据([`JavaMismatch`]),提示交给 CLI 的告警行与 GUI;
+//! 3. **不改启动策略**:「用哪个 Java 启动」是启动期的派生结果,不落盘;落盘的是**装到哪**
+//!    ([`JavaInstallRoot`],由 `java.installRoot` + 数据目录模式决定)。
+//!
+//! # 托管布局(D23 后两种并存)
+//!
+//! ```text
+//! Portable:data/java-managed/
+//! ├── .cache/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip   归档缓存(可删)
+//! └── jdk-21-temurin/                                          一个 major+vendor 一份(永不改)
+//!     ├── .ull-jdk.json                                        戳记(幂等判据)
+//!     └── bin/java[.exe]
+//!
+//! Standard / Custom:%APPDATA%\Java\\\     (mac/Linux 见 location 模块)
+//! ```
+
+pub mod api;
+pub mod discover;
+pub mod error;
+pub mod extract;
+pub mod install;
+pub mod location;
+pub mod mapping;
+pub mod microsoft;
+pub mod net;
+pub mod oracle;
+pub mod probe;
+pub mod release;
+pub mod select;
+pub mod source;
+pub mod target;
+pub mod vendor;
+pub mod zulu;
+
+pub use self::api::{
+    latest_url, parse_latest, JdkArchive, JdkRelease, ADOPTIUM_API_BASE, JDK_VENDOR,
+};
+pub use self::discover::{
+    candidates, candidates_in_roots, scan_java_binaries, standard_candidates, standard_roots,
+};
+pub use self::error::{JavaError, JavaErrorKind};
+pub use self::extract::{
+    extract_archive, is_ready, java_binary, java_binary_name, read_stamp, ExtractReport, JdkStamp,
+    STAMP_FILE,
+};
+pub use self::install::{
+    archive_path, archive_request, chain_order, install, managed_root_dir, plan, InstallRequest,
+    InstalledJdk,
+};
+pub use self::location::{
+    cleanup_orphans, sanitize_version, scan_roots, CleanupReport, JavaInstallRoot, LocationMode,
+};
+pub use self::mapping::{
+    check, recommended_major, requirement_for, requirement_for_version, JavaMismatch,
+    JavaRequirement, RequirementSource,
+};
+pub use self::probe::{
+    discover_managed, discover_managed_roots, managed_dir_name, parse_managed_dir, probe,
+    resolve_on_path, system_candidates, JavaOrigin, JavaRuntime, DEFAULT_MAJOR,
+};
+pub use self::release::parse_sha256_text;
+pub use self::select::{pick, resolve, JavaResolution};
+pub use self::source::{ChainLog, VendorAttempt};
+pub use self::target::AdoptiumTarget;
+pub use self::vendor::{order as vendor_order, JdkVendor, VENDOR_IDS};
diff --git a/crates/core/src/game/java/net.rs b/crates/core/src/game/java/net.rs
new file mode 100644
index 0000000..991d994
--- /dev/null
+++ b/crates/core/src/game/java/net.rs
@@ -0,0 +1,60 @@
+//! 版本查询用的极小 HTTP 取文本工具(四家发行版的「一锤子 GET」共用)。
+//!
+//! 只有一件事:GET → 状态分类 → 整段文本。**下载不走这里**(那归 `core::download` 引擎),
+//! 所以没有重试、没有镜像展开、没有进度事件——查清单失败就直接把错误抛给发行版回退链,
+//! 让它换下一家(这是 D23「vendor 级失败自动降下一 vendor」的最短路径)。
+
+use super::error::{JavaError, JavaErrorKind};
+
+/// 取一段文本(`what` 是给用户看的名词,例如「华为云 openjdk 目录」)。
+///
+/// # Errors
+///
+/// 连不上 / 超时 → [`JavaErrorKind::Network`];404 或 410 → [`JavaErrorKind::Metadata`]
+/// (「这个东西上游没有」比「网络坏了」更准确,回退链据此换 vendor 而不是重试同一条)。
+pub async fn get_text(
+    client: &reqwest::Client,
+    url: &str,
+    what: &str,
+) -> Result {
+    let response = client.get(url).send().await.map_err(|error| {
+        JavaError::new(
+            JavaErrorKind::Network,
+            format!("取 {what} 失败:{error}({url})"),
+        )
+    })?;
+    let status = response.status();
+    if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE {
+        return Err(JavaError::new(
+            JavaErrorKind::Metadata,
+            format!("{what} 不存在(HTTP {status}):{url}"),
+        ));
+    }
+    if !status.is_success() {
+        return Err(JavaError::new(
+            JavaErrorKind::Network,
+            format!("{what} 返回 HTTP {status}:{url}"),
+        ));
+    }
+    let content_type = response
+        .headers()
+        .get(reqwest::header::CONTENT_TYPE)
+        .and_then(|value| value.to_str().ok())
+        .unwrap_or_default()
+        .to_string();
+    let body = response.text().await.map_err(|error| {
+        JavaError::new(
+            JavaErrorKind::Network,
+            format!("读 {what} 响应失败:{error}"),
+        )
+    })?;
+    // 门户兜底页会把「路径不存在」伪装成 200 + HTML;版本清单页面本身可能也是 HTML,
+    // 所以这里不做内容判定,只看调用方能不能解析出来(解析失败会给出更准的原因)。
+    if body.trim().is_empty() {
+        return Err(JavaError::new(
+            JavaErrorKind::Metadata,
+            format!("{what} 返回空响应(Content-Type: {content_type}):{url}"),
+        ));
+    }
+    Ok(body)
+}
diff --git a/crates/core/src/game/java/oracle.rs b/crates/core/src/game/java/oracle.rs
new file mode 100644
index 0000000..08530f8
--- /dev/null
+++ b/crates/core/src/game/java/oracle.rs
@@ -0,0 +1,344 @@
+//! Oracle OpenJDK(`download.java.net` GA):**枚举与下载都走国内镜像目录页**。
+//!
+//! 为什么不能像 Temurin 那样「先拿官方链接、再反解镜像路径」:Oracle 的直链里有一整段
+//! 构建哈希(`/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip`),
+//! 那段**不可推导**(research/06 §4.1 实测)。于是本模块的纪律是:
+//!
+//! > 候选 URL 的 `{ver}` 段与文件名段都必须来自对镜像目录页的解析(`parse_links`),
+//! > 代码里没有任何一处把哈希段拼出来。
+//!
+//! 目录形状(华为云 / 南大与官方完全同构,§4.2):
+//!
+//! ```text
+//! {base}/                      → Apache 列表页:21/ 21.0.1/ 21.0.2/ 27.0.1.0.3/ …
+//! {base}/{ver}/                → 文件列表:openjdk-21.0.2_windows-x64_bin.zip …
+//! {base}/{ver}/{filename}      → 归档本体(大小与官方逐字节一致)
+//! {base}/{ver}/{filename}.sha256 → 64 位哈希纯文本(成对提供)
+//! ```
+//!
+//! 三处实测坑(都写进了实现):
+//!
+//! 1. **华为云对任意不存在的路径都回 200 + 12 434 B 门户 HTML**(假阳性)——所以本模块只信
+//!    「目录页里真的出现了这个文件名」,不信状态码;下载侧另有 `download::http::html_mirror_reason`
+//!    兜一层(Content-Type 非 HTML + Content-Length 对拍)。
+//! 2. **只发「首个 GA + ≤2 个 update」**:21 线最高只到 21.0.2,别当补丁源用。
+//! 3. **目录页给的大小是四舍五入的**(`192 MB`)→ 不可当 `expected_size`,本模块一律填 0,
+//!    校验交给成对的 `.sha256`。
+
+use super::error::{JavaError, JavaErrorKind};
+use super::net;
+use super::release::{parse_sha256_text, JdkArchive, JdkRelease};
+use super::target::AdoptiumTarget;
+use super::vendor::JdkVendor;
+use crate::download::MirrorTable;
+
+/// Oracle 的 os 段(Adoptium 的 `mac` 在 Oracle 里叫 `macos`)。
+#[must_use]
+pub fn oracle_os(target: AdoptiumTarget) -> &'static str {
+    match target.os {
+        "mac" => "macos",
+        other => other,
+    }
+}
+
+/// Oracle 的 arch 段(`x86-32` → `x86`)。
+#[must_use]
+pub fn oracle_arch(target: AdoptiumTarget) -> &'static str {
+    match target.arch {
+        "x86-32" => "x86",
+        other => other,
+    }
+}
+
+/// 目录页 → 本级条目名列表(去掉尾部 `/`、跳过 `../` 与绝对链接,去重保序)。
+#[must_use]
+pub fn parse_links(html: &str) -> Vec {
+    let mut out: Vec = Vec::new();
+    let mut rest = html;
+    while let Some(start) = rest.find("href=\"") {
+        let after = &rest[start + "href=\"".len()..];
+        let Some(end) = after.find('"') else {
+            break;
+        };
+        let value = after[..end].trim();
+        rest = &after[end..];
+        if value.is_empty()
+            || value.starts_with("http")
+            || value.starts_with("//")
+            || value.starts_with('?')
+            || value.contains('#')
+        {
+            continue;
+        }
+        let name = value.trim_end_matches('/');
+        let Some(name) = name.rsplit('/').next() else {
+            continue;
+        };
+        if name.is_empty() || name == "." || name == ".." {
+            continue;
+        }
+        if !out.iter().any(|known| known == name) {
+            out.push(name.to_string());
+        }
+    }
+    out
+}
+
+/// 版本串 → 可比较的数字段(`21.0.2` → `[21, 0, 2]`,`27.0.1.0.3` → `[27,0,1,0,3]`)。
+#[must_use]
+pub fn version_key(version: &str) -> Option> {
+    let mut parts = Vec::new();
+    for segment in version.split('.') {
+        parts.push(segment.parse::().ok()?);
+    }
+    (!parts.is_empty()).then_some(parts)
+}
+
+/// 目录页里属于 `major` 的版本串,按版本**降序**(最新在前)。
+#[must_use]
+pub fn versions_for(html: &str, major: u32) -> Vec {
+    let mut found: Vec<(Vec, String)> = parse_links(html)
+        .into_iter()
+        .filter_map(|name| {
+            let key = version_key(&name)?;
+            (key.first() == Some(&u64::from(major))).then_some((key, name))
+        })
+        .collect();
+    found.sort_by(|left, right| right.0.cmp(&left.0));
+    found.into_iter().map(|(_, name)| name).collect()
+}
+
+/// 取 `major` 线的最新 GA 版本串。
+#[must_use]
+pub fn pick_version(html: &str, major: u32) -> Option {
+    versions_for(html, major).into_iter().next()
+}
+
+/// 期望文件名(Oracle 的命名是**模板**,哈希只在目录层级上,不在文件名里)。
+#[must_use]
+pub fn archive_name(version: &str, target: AdoptiumTarget) -> String {
+    format!(
+        "openjdk-{version}_{}-{}_bin.{}",
+        oracle_os(target),
+        oracle_arch(target),
+        target.archive_ext()
+    )
+}
+
+/// `{base}/{ver}/{filename}`。
+#[must_use]
+pub fn archive_url(base: &str, version: &str, filename: &str) -> String {
+    format!("{}/{version}/{filename}", base.trim_end_matches('/'))
+}
+
+/// 归档旁的 `.sha256` 地址。
+#[must_use]
+pub fn checksum_url(base: &str, version: &str, filename: &str) -> String {
+    format!("{}.sha256", archive_url(base, version, filename))
+}
+
+/// 目录页里是否有这个文件(**唯一**的「存在」判据:不信状态码)。
+#[must_use]
+pub fn find_archive(html: &str, filename: &str) -> bool {
+    parse_links(html).iter().any(|name| name == filename)
+}
+
+/// 查一次「某大版本的最新 Oracle OpenJDK GA」。
+///
+/// 按 `mirrors.json` 里 `oracle_openjdk` 的启用顺序逐个镜像试(国内:华为云 → 南大),
+/// 每个镜像三跳:目录页 → 版本目录页 → `.sha256`。任何一跳失败就记下原因换下一个镜像。
+///
+/// # Errors
+///
+/// 所有镜像都失败(没有该大版本 / 目录页不可解析 / 缺 sha256)时返回错误,
+/// 由回退链决定降级到下一家发行版。
+pub async fn fetch_latest(
+    client: &reqwest::Client,
+    table: &MirrorTable,
+    major: u32,
+    target: AdoptiumTarget,
+) -> Result {
+    let mirrors: Vec<_> = table.oracle_mirrors().collect();
+    if mirrors.is_empty() {
+        return Err(JavaError::new(
+            JavaErrorKind::Metadata,
+            "镜像清单里没有启用的 oracle_openjdk 源",
+        )
+        .with_hint("`mirrors.json` 的 `oracle_openjdk` 表需要至少一个 enabled 条目"));
+    }
+    let mut reasons: Vec = Vec::new();
+    for mirror in mirrors {
+        match fetch_from(client, mirror.base(), major, target).await {
+            Ok(release) => return Ok(release),
+            Err(error) => reasons.push(format!("{}:{}", mirror.name, error.message())),
+        }
+    }
+    Err(JavaError::new(
+        JavaErrorKind::Network,
+        format!(
+            "Oracle OpenJDK {major} 在所有镜像上都取不到:{}",
+            reasons.join(";")
+        ),
+    )
+    .with_hint("Oracle 的 21 线只到 21.0.2(只发首个 GA + ≤2 update);换 17/25 或改用 temurin"))
+}
+
+/// 单个镜像的完整三跳查询。
+async fn fetch_from(
+    client: &reqwest::Client,
+    base: &str,
+    major: u32,
+    target: AdoptiumTarget,
+) -> Result {
+    let index = net::get_text(
+        client,
+        &format!("{}/", base.trim_end_matches('/')),
+        "Oracle 镜像根目录",
+    )
+    .await?;
+    let version = pick_version(&index, major).ok_or_else(|| {
+        JavaError::new(
+            JavaErrorKind::Metadata,
+            format!(
+                "镜像目录里没有 {major} 线的 Oracle OpenJDK(目录页 {} 项)",
+                parse_links(&index).len()
+            ),
+        )
+    })?;
+    let filename = archive_name(&version, target);
+    let listing = net::get_text(
+        client,
+        &format!("{}/{version}/", base.trim_end_matches('/')),
+        "Oracle 版本目录",
+    )
+    .await?;
+    if !find_archive(&listing, &filename) {
+        return Err(JavaError::new(
+            JavaErrorKind::Metadata,
+            format!("{version} 目录里没有 {filename}(该平台没有产物,或目录页不可解析)"),
+        ));
+    }
+    let sha_text = net::get_text(
+        client,
+        &checksum_url(base, &version, &filename),
+        "Oracle 归档 sha256",
+    )
+    .await?;
+    let sha256 = parse_sha256_text(&sha_text).ok_or_else(|| {
+        JavaError::new(
+            JavaErrorKind::Integrity,
+            format!("{filename} 的 .sha256 不是 64 位十六进制"),
+        )
+    })?;
+    Ok(JdkRelease {
+        vendor: JdkVendor::Oracle,
+        major,
+        version: version.clone(),
+        semver: None,
+        release_name: version.clone(),
+        archive: JdkArchive {
+            name: filename.clone(),
+            url: archive_url(base, &version, &filename),
+            sha256,
+            // 目录页给的是四舍五入的大小(192 MB),不能当 expected_size。
+            size: 0,
+        },
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{
+        archive_name, archive_url, checksum_url, find_archive, oracle_os, parse_links,
+        pick_version, version_key, versions_for,
+    };
+    use crate::game::java::target::AdoptiumTarget;
+
+    /// 华为云 `/openjdk/` 的真实目录页片段(2026-10-30 抓取,`
` 列表)。
+    const INDEX: &str = r#"
Name         Last modified     Size

+10/          22-Aug-2021 15:18 -
+17.0.2/      22-Aug-2021 15:19 -
+21/          22-Aug-2021 15:19 -
+21.0.1/      22-Aug-2021 15:19 -
+21.0.2/      06-Jan-2024 15:26 -
+27.0.1.0.3/ 06-Jan-2026 15:26 -
+../
+
"#; + + /// 华为云 `/openjdk/21.0.2/` 的真实文件列表片段。 + const LISTING: &str = r#"
../
+openjdk-21.0.2_linux-x64_bin.tar.gz 194.18 MB
+openjdk-21.0.2_windows-x64_bin.zip 192 MB
+
"#; + + const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64"); + + #[test] + fn directory_links_are_scraped_without_any_hash_segment() { + let links = parse_links(INDEX); + assert!(links.contains(&"21.0.2".to_string())); + assert!(!links.iter().any(|name| name == "..")); + // Oracle 直链里的构建哈希(f2283984656d49d69e91c558476027ac)在镜像目录里根本不存在: + // 目录层级只有 `{ver}/`,所以实现里没有任何拼哈希的位置。 + assert!(!links.iter().any(|name| name.len() == 32)); + } + + #[test] + fn newest_version_of_a_line_wins_and_ordering_is_numeric() { + assert_eq!( + versions_for(INDEX, 21), + vec!["21.0.2".to_string(), "21.0.1".to_string(), "21".to_string()] + ); + assert_eq!(pick_version(INDEX, 21).as_deref(), Some("21.0.2")); + // 27.0.1.0.3 是四段+,比 27.0.1 新(字符串比较会得出相反结论)。 + assert_eq!(version_key("27.0.1.0.3"), Some(vec![27, 0, 1, 0, 3])); + assert_eq!(version_key("27.0.1"), Some(vec![27, 0, 1])); + assert!(version_key("27.0.1") < version_key("27.0.1.0.3")); + assert_eq!(pick_version(INDEX, 8), None); + } + + #[test] + fn filename_template_matches_the_archive_on_every_platform() { + assert_eq!( + archive_name("21.0.2", WINDOWS), + "openjdk-21.0.2_windows-x64_bin.zip" + ); + assert_eq!( + archive_name("21.0.2", AdoptiumTarget::new("linux", "aarch64")), + "openjdk-21.0.2_linux-aarch64_bin.tar.gz" + ); + assert_eq!( + archive_name("17.0.2", AdoptiumTarget::new("mac", "x64")), + "openjdk-17.0.2_macos-x64_bin.tar.gz" + ); + assert_eq!(oracle_os(AdoptiumTarget::new("mac", "x64")), "macos"); + assert_eq!(oracle_os(WINDOWS), "windows"); + } + + #[test] + fn archive_is_only_published_when_it_really_appears_in_the_listing() { + let filename = archive_name("21.0.2", WINDOWS); + assert!(find_archive(LISTING, &filename)); + // 华为云的门户兜底页(任意路径 200 + HTML)里没有这个链接 → 必须判为不存在。 + assert!(!find_archive( + "mirrors 门户", + &filename + )); + assert_eq!( + archive_url( + "https://mirrors.huaweicloud.com/openjdk/", + "21.0.2", + &filename + ), + format!("https://mirrors.huaweicloud.com/openjdk/21.0.2/{filename}") + ); + assert_eq!( + checksum_url( + "https://mirrors.huaweicloud.com/openjdk", + "21.0.2", + &filename + ), + format!("https://mirrors.huaweicloud.com/openjdk/21.0.2/{filename}.sha256") + ); + } +} diff --git a/crates/core/src/game/java/probe.rs b/crates/core/src/game/java/probe.rs new file mode 100644 index 0000000..5dee6eb --- /dev/null +++ b/crates/core/src/game/java/probe.rs @@ -0,0 +1,370 @@ +//! Java 运行时探测:跑一次 `java -version`、扫托管目录、列系统候选。 +//! +//! 探测动作只有两种:读托管目录(`.ULL/data/java-managed/jdk--/`) +//! 与跑 `java -version`(输出在 stderr,主版本号从引号里的版本号解析, +//! `1.8.0_402` 这种老形态换算成 8)。 +//! +//! 「选哪个 Java 启动」不在这里,而在 [`super::select`]——那边是**纯函数**, +//! 候选运行时由调用方注入,测试因此不需要真的装三个 JDK。 + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use super::extract::{java_binary_name, read_stamp}; +use super::vendor::JdkVendor; + +/// 缺省 Java 主版本(探测不到任何东西时的保守值,D13:JDK 17 基准)。 +pub const DEFAULT_MAJOR: u32 = 17; + +/// 托管目录名前缀。 +pub const MANAGED_PREFIX: &str = "jdk-"; + +/// 一个 Java 运行时的来源。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum JavaOrigin { + /// 实例覆盖(`--java` 参数或实例档案的 `javaPath`)。 + Instance, + /// 托管目录(`ull java install` 装的)。 + Managed, + /// `JAVA_HOME`。 + JavaHome, + /// `PATH` 上的 `java`。 + Path, + /// 标准安装位置扫出来的(MSI 装的 Temurin / 官方启动器的 `runtime/` / 注册表等)。 + System, +} + +impl JavaOrigin { + /// 稳定字符串 id(`--json` 的 `source` 字段)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Instance => "instance", + Self::Managed => "managed", + Self::JavaHome => "JAVA_HOME", + Self::Path => "PATH", + Self::System => "system", + } + } + + /// 展示类别(`--json` 的 `source_category`):GUI 用它分组,不必区分 `JAVA_HOME`/`PATH`。 + #[must_use] + pub const fn category(self) -> &'static str { + match self { + Self::Instance => "instance", + Self::Managed => "managed", + Self::JavaHome | Self::Path => "env", + Self::System => "system", + } + } + + /// 同档内的优先序(小者优先)。 + /// + /// 「环境变量」先于「扫盘」:用户显式设过的 `JAVA_HOME`/`PATH` 是意图表达, + /// 盘上扫出来的只是候选(HMCL/PCL 也是这个顺序)。 + #[must_use] + pub const fn rank(self) -> u8 { + match self { + Self::Instance => 0, + Self::Managed => 1, + Self::JavaHome => 2, + Self::Path => 3, + Self::System => 4, + } + } +} + +/// 一个探测到的 Java 运行时。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JavaRuntime { + /// 可执行文件。 + pub binary: PathBuf, + /// 来源。 + pub origin: JavaOrigin, + /// `java -version` 的首行(含 `version` 字样)。 + pub version: Option, + /// 主版本号(`1.8.0_402` → 8)。 + pub major: Option, + /// 位数(32 / 64;探测不到时按宿主)。 + pub bits: u32, + /// 托管目录里的供应商(`temurin`);其它来源为 `None`。 + pub vendor: Option, +} + +impl JavaRuntime { + /// 探测不出来时给个「版本未知」的占位。 + #[must_use] + pub fn unknown(binary: impl Into, origin: JavaOrigin) -> Self { + Self { + binary: binary.into(), + origin, + version: None, + major: None, + bits: host_bits(), + vendor: None, + } + } + + /// 主版本号(未知时退回 [`DEFAULT_MAJOR`])。 + #[must_use] + pub fn major_or_default(&self) -> u32 { + self.major.unwrap_or(DEFAULT_MAJOR) + } +} + +/// 跑一次 `java -version` 填满运行时信息(探测失败不报错,只留空字段)。 +/// +/// 失败不报错的理由:`ull java list` 要能列出「探测不到的项」而不是整体失败, +/// 而启动路径上探测失败会自然退化为系统默认 Java(见 [`super::select::resolve`])。 +#[must_use] +pub fn probe(binary: &Path, origin: JavaOrigin) -> JavaRuntime { + let mut runtime = JavaRuntime::unknown(binary.to_path_buf(), origin); + let Ok(output) = Command::new(binary).arg("-version").output() else { + return runtime; + }; + let mut text = String::from_utf8_lossy(&output.stderr).into_owned(); + text.push_str(&String::from_utf8_lossy(&output.stdout)); + if let Some((line, major)) = parse_version_output(&text) { + runtime.major = Some(major); + runtime.version = Some(line); + runtime.bits = bits_of(&text); + } + runtime +} + +/// 扫描一个安装根里的托管 JDK,逐个探测。 +/// +/// **两种布局都要认**(D23 之后同时存在): +/// +/// ```text +/// /jdk--/bin/java Portable(v1 布局,M33 定下,永不改) +/// ///bin/java Standard / Custom(D23 的 <发行方>/<版本>) +/// ``` +/// +/// 只认带戳记的目录:托管区是我们自己写的地方,但没有戳记的子目录可能是用户塞进来的 +/// 别的东西,不去猜它。`major` 一律以 `java -version` 为准(戳记只用于幂等判定)。 +#[must_use] +pub fn discover_managed(managed_root: &Path) -> Vec { + let mut found: Vec = Vec::new(); + let Ok(entries) = std::fs::read_dir(managed_root) else { + return found; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + if !path.is_dir() || name.starts_with('.') { + continue; + } + if let Some(runtime) = probe_managed_dir(&path, parse_managed_dir(&name).map(|(_, v)| v)) { + found.push(runtime); + continue; + } + // 两层布局:`/`——vendor 名取自父目录名。 + if JdkVendor::parse(&name).is_some() { + let Ok(children) = std::fs::read_dir(&path) else { + continue; + }; + for child in children.flatten() { + let child_path = child.path(); + if let Some(runtime) = probe_managed_dir(&child_path, Some(&name)) { + found.push(runtime); + } + } + } + } + sort_runtimes(&mut found); + found +} + +/// 扫多个安装根(Portable 数据根 + Standard/Custom 落点),按 `bin` 路径去重。 +#[must_use] +pub fn discover_managed_roots(roots: &[PathBuf]) -> Vec { + let mut found: Vec = Vec::new(); + for root in roots { + for runtime in discover_managed(root) { + if !found.iter().any(|known| known.binary == runtime.binary) { + found.push(runtime); + } + } + } + sort_runtimes(&mut found); + found +} + +/// 探测一个候选托管目录(有戳记 + `bin/java` 才算数)。 +fn probe_managed_dir(path: &Path, vendor: Option<&str>) -> Option { + if !path.is_dir() || read_stamp(path).is_none() { + return None; + } + let binary = path.join("bin").join(java_binary_name()); + if !binary.is_file() { + return None; + } + let mut runtime = probe(&binary, JavaOrigin::Managed); + runtime.vendor = vendor.map(str::to_string); + Some(runtime) +} + +/// 大版本升序,输出稳定(同 major 按路径)。 +fn sort_runtimes(found: &mut [JavaRuntime]) { + found.sort_by(|left, right| { + left.major + .cmp(&right.major) + .then_with(|| left.binary.cmp(&right.binary)) + }); +} + +/// 系统来源的候选:`JAVA_HOME` 与 `PATH` 上的 `java`(都探测一次)。 +/// +/// `PATH` 上的那一项会先**解析成绝对路径**再探测:否则它与扫盘结果(绝对路径)看起来是 +/// 两个不同的文件,`java list` 会把同一个 JDK 列两次。 +#[must_use] +pub fn system_candidates() -> Vec { + let mut found = Vec::new(); + if let Some(home) = std::env::var_os("JAVA_HOME") { + let binary = PathBuf::from(home).join("bin").join(java_binary_name()); + if binary.is_file() { + found.push(probe(&binary, JavaOrigin::JavaHome)); + } + } + let name = java_binary_name(); + let binary = resolve_on_path(name).unwrap_or_else(|| PathBuf::from(name)); + found.push(probe(&binary, JavaOrigin::Path)); + found +} + +/// 在 `PATH` 里找某个可执行文件的绝对路径(找不到返回 `None`)。 +#[must_use] +pub fn resolve_on_path(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|dir| dir.join(name)) + .find(|candidate| candidate.is_file()) +} + +/// 托管目录名(`jdk-21-temurin`)。 +#[must_use] +pub fn managed_dir_name(major: u32, vendor: &str) -> String { + format!("{MANAGED_PREFIX}{major}-{vendor}") +} + +/// 反解托管目录名 →(大版本,供应商)。 +#[must_use] +pub fn parse_managed_dir(name: &str) -> Option<(u32, &str)> { + let rest = name.strip_prefix(MANAGED_PREFIX)?; + let (major, vendor) = rest.split_once('-')?; + let major: u32 = major.parse().ok()?; + (!vendor.is_empty()).then_some((major, vendor)) +} + +/// 从 `java -version` 输出里取(含 `version` 的首行,主版本号)。 +#[must_use] +pub fn parse_version_output(text: &str) -> Option<(String, u32)> { + let line = text + .lines() + .find(|line| line.contains("version")) + .or_else(|| text.lines().find(|line| !line.trim().is_empty()))? + .trim(); + let version = quoted_version(line).or_else(|| bare_version(line))?; + let major = major_of(&version)?; + Some((line.to_string(), major)) +} + +/// 引号里的版本号(`openjdk version "21.0.13" 2026-01-20 LTS`)。 +fn quoted_version(line: &str) -> Option { + let start = line.find('"')? + 1; + let rest = &line[start..]; + let end = rest.find('"')?; + Some(rest[..end].to_string()) +} + +/// 无引号形态(`openjdk 25.0.3 2026-04-21`)。 +/// +/// 「含 `version` 的那一段之后逐个试」优先——避开 `Java(TM) SE Runtime` 这类噪声; +/// 没有 `version` 字样时从头试(部分精简发行版就这么打印)。 +fn bare_version(line: &str) -> Option { + let tokens: Vec<&str> = line.split_whitespace().collect(); + let start = tokens + .iter() + .position(|token| token.contains("version")) + .map_or(0, |position| position + 1); + tokens[start..].iter().find_map(|token| { + let candidate = token.trim_matches(['"', ',', '(', ')']); + (candidate.contains('.') && major_of(candidate).is_some()).then(|| candidate.to_string()) + }) +} + +/// 版本号 → 主版本(`1.8.0_402` → 8,`21.0.13` → 21)。 +#[must_use] +pub fn major_of(version: &str) -> Option { + let mut parts = version.split(['.', '_', '-']); + let first = parts.next()?.parse::().ok()?; + if first == 1 { + parts.next()?.parse::().ok() + } else { + Some(first) + } +} + +/// `java -version` 输出里的位数(`64-Bit` / `32-Bit`;读不到按宿主)。 +#[must_use] +pub fn bits_of(text: &str) -> u32 { + let lowered = text.to_ascii_lowercase(); + if lowered.contains("64-bit") { + 64 + } else if lowered.contains("32-bit") { + 32 + } else { + host_bits() + } +} + +/// 宿主指针宽度(32 / 64)。 +#[must_use] +pub const fn host_bits() -> u32 { + if cfg!(target_pointer_width = "64") { + 64 + } else { + 32 + } +} + +#[cfg(test)] +mod tests { + use super::{bits_of, major_of, managed_dir_name, parse_managed_dir, parse_version_output}; + + #[test] + fn parses_modern_and_legacy_version_lines() { + let modern = + parse_version_output("openjdk version \"21.0.13\" 2026-01-20 LTS\nOpenJDK 64-Bit"); + assert_eq!(modern.map(|(_, major)| major), Some(21)); + let legacy = parse_version_output("java version \"1.8.0_402\"\nJava(TM) SE Runtime"); + assert_eq!(legacy.map(|(_, major)| major), Some(8)); + let bare = parse_version_output("openjdk 25.0.3 2026-04-21\nOpenJDK 64-Bit Server VM"); + assert_eq!(bare.map(|(_, major)| major), Some(25)); + assert!(parse_version_output("no java here").is_none()); + } + + #[test] + fn parses_bit_width() { + assert_eq!(bits_of("OpenJDK 64-Bit Server VM (build 25.0.3)"), 64); + assert_eq!(bits_of("Java HotSpot(TM) Client VM (32-Bit)"), 32); + } + + #[test] + fn major_of_handles_legacy_scheme() { + assert_eq!(major_of("1.8.0_402"), Some(8)); + assert_eq!(major_of("17.0.13+11"), Some(17)); + assert_eq!(major_of("not-a-version"), None); + } + + #[test] + fn managed_dir_names_round_trip() { + assert_eq!(parse_managed_dir("jdk-21-temurin"), Some((21, "temurin"))); + assert_eq!(parse_managed_dir("jdk-8-temurin"), Some((8, "temurin"))); + assert_eq!(parse_managed_dir(".staging-jdk-21-temurin"), None); + assert_eq!(parse_managed_dir("java-21-temurin"), None); + assert_eq!(parse_managed_dir("jdk-x-temurin"), None); + assert_eq!(managed_dir_name(25, "temurin"), "jdk-25-temurin"); + } +} diff --git a/crates/core/src/game/java/release.rs b/crates/core/src/game/java/release.rs new file mode 100644 index 0000000..8e80ec3 --- /dev/null +++ b/crates/core/src/game/java/release.rs @@ -0,0 +1,170 @@ +//! 一次版本查询的统一产物:一个可下载的 JDK 归档(四家发行版共用形态)。 +//! +//! 各家的**版本号语义不同**(research/06 §5.3 第 3 条),所以这里只存「vendor 原生的版本串」 +//! 而不是硬拆成 semver 四段: +//! +//! | 发行版 | 版本串 | 出处 | +//! | --- | --- | --- | +//! | Temurin | `21.0.13+9`(三段+构建号) | Adoptium `release_name` | +//! | Oracle OpenJDK | `21.0.2`(三段,只到 GA + ≤2 update) | 镜像目录名 | +//! | Microsoft OpenJDK | `21.0.13.0.2`(四段/五段),11 线是 `11.0.32.1` | `aka.ms` 文件名 | +//! | Azul Zulu | `21.0.13+3`(三段+构建号,文件名里还有 `21.0.13.0.3` 另一种写法) | Zulu API | +//! +//! 目录名、戳记、`--json` 都直接用这个串(`location::sanitize_version` 只做字符白名单)。 + +use super::vendor::JdkVendor; + +/// 一个可下载的 JDK 归档。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JdkArchive { + /// 产物文件名(`OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip`)。 + pub name: String, + /// 官方下载链接(镜像展开的输入;Oracle / MS / Zulu 已经是最终链接)。 + pub url: String, + /// SHA-256(小写十六进制)。 + pub sha256: String, + /// 字节数(`0` = 上游没给可信大小,引擎跳过长度对拍只验哈希)。 + pub size: u64, +} + +/// 一次版本查询的结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JdkRelease { + /// 发行版。 + pub vendor: JdkVendor, + /// 大版本号(`21`)。 + pub major: u32, + /// vendor 原生的完整版本串(目录名与展示都用它)。 + pub version: String, + /// Temurin 系才有的 semver(其它家为 `None`)。 + pub semver: Option, + /// release 名(Temurin `jdk-21.0.13+9`;其它家用版本串)。 + pub release_name: String, + /// 归档。 + pub archive: JdkArchive, +} + +impl JdkRelease { + /// 是否就是这个发行版 + 大版本的产物。 + #[must_use] + pub fn matches(&self, vendor: JdkVendor, major: u32) -> bool { + self.vendor == vendor && self.major == major + } + + /// sha256(幂等判据用)。 + #[must_use] + pub fn sha256(&self) -> &str { + &self.archive.sha256 + } + + /// 人读一行(`Temurin 21.0.13+9 · OpenJDK21U-…zip`)。 + #[must_use] + pub fn summary(&self) -> String { + format!( + "{} {} · {}", + self.vendor.display(), + self.version, + self.archive.name + ) + } + + /// `--json` 的归档块(四家共用一个形状)。 + #[must_use] + pub fn archive_json(&self) -> serde_json::Value { + serde_json::json!({ + "vendor": self.vendor.id(), + "version": self.version, + "name": self.archive.name, + "url": self.archive.url, + "sha256": self.archive.sha256, + "size": self.archive.size, + }) + } +} + +/// 从 `release_name`(Temurin 的 `jdk-21.0.13+9`)取版本串。 +#[must_use] +pub fn version_from_release_name(release_name: &str) -> Option { + let trimmed = release_name.trim(); + let stripped = trimmed.strip_prefix("jdk-").unwrap_or(trimmed); + (!stripped.is_empty()).then(|| stripped.to_string()) +} + +/// 解析 sha256 文本(`.sha256` / `.sha256sum.txt` / 裸哈希都吃),校验 64 位十六进制。 +#[must_use] +pub fn parse_sha256_text(text: &str) -> Option { + for token in text.split_whitespace() { + let lowered = token.trim().to_ascii_lowercase(); + let candidate = lowered.strip_prefix('*').unwrap_or(lowered.as_str()); + if candidate.len() == 64 && candidate.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Some(candidate.to_string()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::{parse_sha256_text, version_from_release_name, JdkArchive, JdkRelease}; + use crate::game::java::vendor::JdkVendor; + + fn release() -> JdkRelease { + JdkRelease { + vendor: JdkVendor::Oracle, + major: 21, + version: "21.0.2".to_string(), + semver: None, + release_name: "21.0.2".to_string(), + archive: JdkArchive { + name: "openjdk-21.0.2_windows-x64_bin.zip".to_string(), + url: "https://mirrors.huaweicloud.com/openjdk/21.0.2/openjdk-21.0.2_windows-x64_bin.zip" + .to_string(), + sha256: "b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664" + .to_string(), + size: 0, + }, + } + } + + #[test] + fn release_carries_vendor_and_native_version() { + let item = release(); + assert!(item.matches(JdkVendor::Oracle, 21)); + assert!(!item.matches(JdkVendor::Microsoft, 21)); + assert!(item.summary().contains("Oracle OpenJDK 21.0.2")); + assert_eq!(item.archive_json()["vendor"], "oracle"); + } + + #[test] + fn sha256_texts_from_every_vendor_parse_the_same_way() { + // Huawei 的 .sha256:只有 64 位哈希,没有文件名。 + assert_eq!( + parse_sha256_text("b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664\n") + .as_deref(), + Some("b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664") + ); + // MS 的 .sha256sum.txt:` `,并且带大写与 CRLF。 + assert_eq!( + parse_sha256_text( + "6ECFA864AE0A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4E5F60718293A4 microsoft-jdk-21.0.13-windows-x64.zip\r\n" + ) + .as_deref(), + Some("6ecfa864ae0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4") + ); + assert_eq!(parse_sha256_text("404"), None); + assert_eq!(parse_sha256_text("deadbeef"), None); + } + + #[test] + fn version_falls_back_to_the_release_name() { + assert_eq!( + version_from_release_name("jdk-21.0.13+9").as_deref(), + Some("21.0.13+9") + ); + assert_eq!( + version_from_release_name("21.0.13+9").as_deref(), + Some("21.0.13+9") + ); + assert_eq!(version_from_release_name(""), None); + } +} diff --git a/crates/core/src/game/java/select.rs b/crates/core/src/game/java/select.rs new file mode 100644 index 0000000..496a0fd --- /dev/null +++ b/crates/core/src/game/java/select.rs @@ -0,0 +1,160 @@ +//! 「启动选哪个 Java」的**纯**优先链(D13)。 +//! +//! 优先级:实例覆盖(`--java` / 档案 `javaPath`)> 托管目录里**满足要求**的 JDK > +//! `JAVA_HOME` > `PATH`。实例覆盖由调用方单独判(用户明确指定了就不参与打分), +//! 这里只处理「一堆已探测的候选 + 一个要求」的挑选。 +//! +//! 挑选规则(比「机械按来源排序」更贴近用户预期): +//! +//! 1. **满足要求的候选优先**——1.12.2 只能用 Java 8,托管的 21 再新也不能选; +//! 2. 同档内按来源:托管 > `JAVA_HOME` > `PATH`(D13:托管目录是启动器的地盘, +//! 用户 PATH 上的 java 可能是另一个 JDK 的残留); +//! 3. 全都不满足时仍给一个(按来源优先取最好那个),并报不符——**提示而不是拒绝**。 +//! +//! 抽成纯函数是为了可测:测试注入「托管 21 + PATH 8」这类组合,不必真装 JDK。 + +use super::mapping::{check, JavaMismatch, JavaRequirement}; +use super::probe::{JavaRuntime, DEFAULT_MAJOR}; + +/// 一次启动的 Java 选择结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JavaResolution { + /// 选中的运行时(一个候选都没有时 `None`)。 + pub runtime: Option, + /// 目标版本对 Java 的要求。 + pub required: JavaRequirement, + /// 不符说明(`None` = 满足要求,或压根没探测到 Java)。 + pub mismatch: Option, +} + +impl JavaResolution { + /// 生效的主版本号(没有运行时按缺省值)。 + #[must_use] + pub fn major(&self) -> u32 { + self.runtime + .as_ref() + .map_or(DEFAULT_MAJOR, JavaRuntime::major_or_default) + } + + /// 是否满足要求(没有运行时视为「不知道」,返回 `true`)。 + #[must_use] + pub fn is_satisfied(&self) -> bool { + self.mismatch.is_none() + } +} + +/// 从候选里挑一个:满足要求的优先,同档内按来源优先序。 +#[must_use] +pub fn pick<'a>( + candidates: &'a [JavaRuntime], + required: &JavaRequirement, +) -> Option<&'a JavaRuntime> { + candidates.iter().min_by_key(|runtime| { + let ok = runtime + .major + .is_some_and(|major| required.satisfied_by(major)); + // 先排「不满足」(`ok=false` 的 key 大),再排来源优先序。 + (!ok, runtime.origin.rank()) + }) +} + +/// 挑 + 判不符。 +#[must_use] +pub fn resolve(candidates: &[JavaRuntime], required: &JavaRequirement) -> JavaResolution { + let runtime = pick(candidates, required).cloned(); + let mismatch = runtime + .as_ref() + .and_then(|runtime| runtime.major) + .and_then(|major| check(major, required)); + JavaResolution { + runtime, + required: required.clone(), + mismatch, + } +} + +#[cfg(test)] +mod tests { + use super::resolve; + use crate::game::java::mapping::{JavaRequirement, RequirementSource}; + use crate::game::java::probe::{JavaOrigin, JavaRuntime, DEFAULT_MAJOR}; + + fn runtime(major: u32, origin: JavaOrigin) -> JavaRuntime { + JavaRuntime { + binary: std::path::PathBuf::from(format!("java{major}")), + origin, + version: Some(format!("openjdk version \"{major}.0.1\"")), + major: Some(major), + bits: 64, + vendor: None, + } + } + + fn at_least(minimum: u32) -> JavaRequirement { + JavaRequirement::new(minimum, None, RequirementSource::Table) + } + + #[test] + fn satisfying_managed_runtime_beats_mismatching_system_java() { + let candidates = [ + runtime(8, JavaOrigin::Path), + runtime(21, JavaOrigin::Managed), + ]; + let picked = resolve(&candidates, &at_least(17)); + assert_eq!(picked.major(), 21); + assert!(picked.is_satisfied()); + } + + #[test] + fn satisfying_system_java_beats_mismatching_managed() { + // 1.12.2 只能 Java 8:托管的 21 不满足,PATH 上的 8 满足 → 选 8。 + let required = JavaRequirement::new(8, Some(8), RequirementSource::Table); + let candidates = [ + runtime(21, JavaOrigin::Managed), + runtime(8, JavaOrigin::Path), + ]; + let picked = resolve(&candidates, &required); + assert_eq!(picked.major(), 8); + assert!(picked.is_satisfied()); + } + + #[test] + fn managed_wins_when_both_satisfy() { + let candidates = [ + runtime(21, JavaOrigin::Path), + runtime(17, JavaOrigin::JavaHome), + runtime(17, JavaOrigin::Managed), + ]; + assert_eq!(resolve(&candidates, &at_least(17)).major(), 17); + } + + #[test] + fn java_home_beats_path_when_both_satisfy() { + let candidates = [ + runtime(21, JavaOrigin::Path), + runtime(21, JavaOrigin::JavaHome), + ]; + let picked = resolve(&candidates, &at_least(21)); + assert_eq!( + picked.runtime.map(|runtime| runtime.origin), + Some(JavaOrigin::JavaHome) + ); + } + + #[test] + fn no_candidate_keeps_the_default_and_reports_no_mismatch() { + let picked = resolve(&[], &at_least(21)); + assert_eq!(picked.major(), DEFAULT_MAJOR); + assert!(picked.runtime.is_none()); + assert!(picked.is_satisfied()); + } + + #[test] + fn mismatch_is_reported_when_nothing_fits() { + let candidates = [runtime(8, JavaOrigin::Path)]; + let picked = resolve(&candidates, &at_least(21)); + assert_eq!(picked.major(), 8); + let mismatch = picked.mismatch.expect("应报不符"); + assert_eq!(mismatch.code(), "java_too_old"); + } +} diff --git a/crates/core/src/game/java/source.rs b/crates/core/src/game/java/source.rs new file mode 100644 index 0000000..112691f --- /dev/null +++ b/crates/core/src/game/java/source.rs @@ -0,0 +1,199 @@ +//! 版本查询的发行版分派 + 回退链记录(D23)。 +//! +//! 两件事: +//! +//! 1. [`fetch`]:把一次「某发行版某大版本的最新 GA」查询分派给对应实现—— +//! Temurin 走 `api.adoptium.net`(v1 既有),Oracle / MS / Zulu 各走自己的源表; +//! 2. [`ChainLog`]:按用户优先级依次尝试时,**每一跳的结果与降级原因**都记下来。 +//! 这份记录直接进 `--json` 与事件流,是「为什么最后装的是 MS 而不是 Temurin」的唯一解释。 +//! +//! 回退的粒度是**发行版级**(D23 原话):版本不存在、镜像全 4xx、校验失败都算这一家失败, +//! 换下一家;同一家内部的镜像换源(TUNA→官方、华为云→南大)仍然归 `core::download` 引擎管。 + +use super::api; +use super::error::JavaError; +use super::microsoft; +use super::oracle; +use super::release::JdkRelease; +use super::target::AdoptiumTarget; +use super::vendor::JdkVendor; +use super::zulu; +use crate::download::DownloadEngine; + +/// 查一次「某发行版某大版本的最新 GA」。 +/// +/// # Errors +/// +/// 平台无产物、清单不可达、该家没有这个大版本、校验缺失时返回错误——调用方据此降级。 +pub async fn fetch( + engine: &DownloadEngine, + vendor: JdkVendor, + major: u32, + target: AdoptiumTarget, +) -> Result { + let client = engine.client(); + let table = engine.mirrors(); + match vendor { + JdkVendor::Temurin => api::fetch_latest(client, major, target).await, + JdkVendor::Oracle => oracle::fetch_latest(client, table, major, target).await, + JdkVendor::Microsoft => { + let table = table.microsoft_openjdk().ok_or_else(|| { + JavaError::new( + super::error::JavaErrorKind::Metadata, + "镜像清单里没有 microsoft_openjdk 表", + ) + .with_hint("升级 mirrors.json 到 v3,或改用 --vendor temurin") + })?; + microsoft::fetch_latest(client, table, major, target).await + } + JdkVendor::Zulu => { + let table = table.zulu().ok_or_else(|| { + JavaError::new( + super::error::JavaErrorKind::Metadata, + "镜像清单里没有 zulu 表", + ) + .with_hint("升级 mirrors.json 到 v3,或改用 --vendor temurin") + })?; + zulu::fetch_latest(client, table, major, target).await + } + } +} + +/// 一跳的结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VendorAttempt { + /// 尝试的发行版。 + pub vendor: JdkVendor, + /// 是否命中(成功拿到可下载的归档)。 + pub ok: bool, + /// 命中/降级的一句话原因(`"命中"` / `"没有 8 的 GA(只发 11/17/21/25)"`)。 + pub detail: String, +} + +impl VendorAttempt { + /// `--json` 形状。 + #[must_use] + pub fn to_json(&self) -> serde_json::Value { + serde_json::json!({ + "vendor": self.vendor.id(), + "ok": self.ok, + "detail": self.detail, + }) + } +} + +/// 一条回退链的执行记录。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ChainLog { + attempts: Vec, +} + +impl ChainLog { + /// 空记录。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 记一次「跳过」(该家不支持这个大版本,根本没查网络)。 + pub fn skip(&mut self, vendor: JdkVendor, detail: impl Into) { + self.attempts.push(VendorAttempt { + vendor, + ok: false, + detail: detail.into(), + }); + } + + /// 记一次失败(查到了但拿不到、或下载/校验失败)。 + pub fn failed(&mut self, vendor: JdkVendor, detail: impl Into) { + self.attempts.push(VendorAttempt { + vendor, + ok: false, + detail: detail.into(), + }); + } + + /// 记一次命中。 + pub fn hit(&mut self, vendor: JdkVendor, detail: impl Into) { + self.attempts.push(VendorAttempt { + vendor, + ok: true, + detail: detail.into(), + }); + } + + /// 是否已经命中(命中后不再往下试)。 + #[must_use] + pub fn hit_vendor(&self) -> Option { + self.attempts + .iter() + .find(|attempt| attempt.ok) + .map(|attempt| attempt.vendor) + } + + /// 全部记录。 + #[must_use] + pub fn attempts(&self) -> &[VendorAttempt] { + &self.attempts + } + + /// 降级说明(「已跳过」的那些;给事件流与人读告警用)。 + #[must_use] + pub fn degradations(&self) -> Vec { + self.attempts + .iter() + .filter(|attempt| !attempt.ok) + .map(|attempt| format!("{}:{}", attempt.vendor.id(), attempt.detail)) + .collect() + } + + /// 实际尝试了几家(不含跳过)。 + #[must_use] + pub fn tried(&self) -> usize { + self.attempts.len() + } + + /// `--json` 形状。 + #[must_use] + pub fn to_json(&self) -> serde_json::Value { + serde_json::json!({ + "hit": self.hit_vendor().map(JdkVendor::id), + "attempts": self.attempts.iter().map(VendorAttempt::to_json).collect::>(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::ChainLog; + use crate::game::java::vendor::JdkVendor; + + #[test] + fn chain_skips_unsupported_majors_and_records_the_reason() { + let mut log = ChainLog::new(); + log.skip(JdkVendor::Oracle, "不支持 8(Oracle archive 从 9 起)"); + log.skip(JdkVendor::Microsoft, "没有 8 的 GA(只发 11/17/21/25)"); + log.hit(JdkVendor::Temurin, "命中 8.0.504+1"); + assert_eq!(log.hit_vendor(), Some(JdkVendor::Temurin)); + let degradations = log.degradations(); + assert_eq!(degradations.len(), 2); + assert!(degradations[1].contains("microsoft")); + assert_eq!(log.attempts().len(), 3); + let json = log.to_json(); + assert_eq!(json["hit"], "temurin"); + assert_eq!(json["attempts"][0]["vendor"], "oracle"); + } + + #[test] + fn failure_reasons_are_kept_for_the_event_stream() { + let mut log = ChainLog::new(); + log.failed(JdkVendor::Temurin, "所有镜像都 4xx"); + log.hit(JdkVendor::Oracle, "命中 21.0.2"); + assert_eq!( + log.degradations(), + vec!["temurin:所有镜像都 4xx".to_string()], + "降级原因必须原样可读(用户要能看懂为什么换了发行版)" + ); + assert_eq!(log.tried(), 2); + } +} diff --git a/crates/core/src/game/java/target.rs b/crates/core/src/game/java/target.rs new file mode 100644 index 0000000..29fe094 --- /dev/null +++ b/crates/core/src/game/java/target.rs @@ -0,0 +1,85 @@ +//! 宿主平台 → Adoptium 请求参数,以及归档形态(Win = zip,Linux/mac = tar.gz)。 +//! +//! Adoptium 的 `os` / `arch` 取值与 Rust 的 `target_os` / `target_arch` 不同形 +//! (`windows`/`mac`/`linux` × `x64`/`aarch64`/`x86-32`……),这里做唯一一次翻译。 +//! 平台差异仍然按项目纪律收敛在 `core::platform` 的语义里,本文件只做**命名映射**, +//! 不写任何能力探测。 + +use super::error::{JavaError, JavaErrorKind}; + +/// 一次 Adoptium 请求的目标。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AdoptiumTarget { + /// Adoptium 的 `os` 段(`windows` / `linux` / `mac`)。 + pub os: &'static str, + /// Adoptium 的 `arch` 段(`x64` / `aarch64` / `x86-32` …)。 + pub arch: &'static str, +} + +impl AdoptiumTarget { + /// 宿主平台(不认识的架构直接拒绝,不猜一个错的产物下来)。 + /// + /// # Errors + /// + /// 当前 OS / 架构没有 Temurin 产物时返回 [`JavaErrorKind::Unsupported`]。 + pub fn host() -> Result { + let os = if cfg!(windows) { + "windows" + } else if cfg!(target_os = "macos") { + "mac" + } else if cfg!(target_os = "linux") { + "linux" + } else { + return Err(JavaError::new( + JavaErrorKind::Unsupported, + format!("当前系统({})没有 Temurin 产物", std::env::consts::OS), + ) + .with_hint("手动指定 JAVA_HOME,或用 `ull launch --java ` 挑已有 JDK")); + }; + let arch = arch_name(std::env::consts::ARCH).ok_or_else(|| { + JavaError::new( + JavaErrorKind::Unsupported, + format!("当前架构({})没有 Temurin 产物", std::env::consts::ARCH), + ) + .with_hint("手动指定 JAVA_HOME,或用 `ull launch --java ` 挑已有 JDK") + })?; + Ok(Self { os, arch }) + } + + /// 构造(测试注入用)。 + #[must_use] + pub const fn new(os: &'static str, arch: &'static str) -> Self { + Self { os, arch } + } + + /// 该平台的产物是不是 zip(Windows 是;Linux/mac 的 Temurin 只发 `.tar.gz`)。 + #[must_use] + pub fn is_zip(self) -> bool { + self.os == "windows" + } + + /// 归档扩展名(拼「期望文件名」与给出「不支持的形态」提示时用)。 + #[must_use] + pub fn archive_ext(self) -> &'static str { + if self.is_zip() { + "zip" + } else { + "tar.gz" + } + } +} + +/// Rust 架构名 → Adoptium 架构名(不认识返回 `None`)。 +#[must_use] +pub fn arch_name(arch: &str) -> Option<&'static str> { + match arch { + "x86_64" => Some("x64"), + "aarch64" => Some("aarch64"), + "x86" | "i686" => Some("x86-32"), + "arm" => Some("arm"), + "ppc64le" => Some("ppc64le"), + "s390x" => Some("s390x"), + "riscv64" => Some("riscv64"), + _ => None, + } +} diff --git a/crates/core/src/game/java/vendor.rs b/crates/core/src/game/java/vendor.rs new file mode 100644 index 0000000..76dd270 --- /dev/null +++ b/crates/core/src/game/java/vendor.rs @@ -0,0 +1,219 @@ +//! JDK 发行版(vendor)抽象与用户可调优先级链(D23)。 +//! +//! 四家发行版的**默认顺序按地区分区**(用户定案 + research/06 §5.1 实测修正): +//! +//! ```text +//! 国内:Temurin(TUNA) → Oracle OpenJDK(华为云→南大) → MS OpenJDK(境外 CDN) → Zulu +//! 国外:Temurin → MS OpenJDK → Oracle OpenJDK → Zulu +//! ``` +//! +//! 国内把 Oracle 提到 MS 之前的依据是实测差 28×(Oracle 国内 11.5 MB/s vs MS 410 KB/s); +//! 用户原话是「Temurin → MS → Oracle」,但同一句话里给了「优先级可调」,所以这只是默认值, +//! 设置键 `java.vendorPriority` 可以整体覆盖(D23 定稿)。 +//! +//! 这里的表只描述**能力与顺序**,不碰网络:怎么查、怎么下在 `oracle` / `microsoft` / +//! `zulu` / `api`(Temurin)四个模块里。 + +/// 设置键 `java.vendorPriority` 的允许值(顺序即默认序的覆盖来源)。 +pub const VENDOR_IDS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"]; + +/// 一家 JDK 发行版。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JdkVendor { + /// Eclipse Temurin(Adoptium),v1 唯一实现。 + Temurin, + /// Oracle OpenJDK(`download.java.net` GA,只发首个 GA + ≤2 个 update)。 + Oracle, + /// Microsoft OpenJDK(`aka.ms` 模板 + VS CDN,无 8)。 + Microsoft, + /// Azul Zulu(第四备选,API 元数据 + CDN 直链)。 + Zulu, +} + +impl JdkVendor { + /// 全部发行版(枚举顺序,不是优先级顺序)。 + pub const ALL: [Self; 4] = [Self::Temurin, Self::Oracle, Self::Microsoft, Self::Zulu]; + + /// 稳定 id(目录名段、`--vendor` 取值、设置值、JSON 字段)。 + #[must_use] + pub const fn id(self) -> &'static str { + match self { + Self::Temurin => "temurin", + Self::Oracle => "oracle", + Self::Microsoft => "microsoft", + Self::Zulu => "zulu", + } + } + + /// 展示名(人读输出 + `java vendors`)。 + #[must_use] + pub const fn display(self) -> &'static str { + match self { + Self::Temurin => "Eclipse Temurin", + Self::Oracle => "Oracle OpenJDK", + Self::Microsoft => "Microsoft OpenJDK", + Self::Zulu => "Azul Zulu", + } + } + + /// 解析 `--vendor` / 设置值(容忍常见别名,大小写不敏感)。 + #[must_use] + pub fn parse(text: &str) -> Option { + match text.trim().to_ascii_lowercase().as_str() { + "temurin" | "adoptium" | "eclipse" | "eclipse-temurin" => Some(Self::Temurin), + "oracle" | "oracle_openjdk" | "openjdk" | "java.net" => Some(Self::Oracle), + "microsoft" | "ms" | "msopenjdk" | "ms_openjdk" | "microsoft-openjdk" => { + Some(Self::Microsoft) + } + "zulu" | "azul" => Some(Self::Zulu), + _ => None, + } + } + + /// 该发行版能不能装这个大版本。 + /// + /// Temurin / Zulu 覆盖 8 起的全部 GA;Oracle 的 archive 从 9 起(8 由 Temurin 负责); + /// MS 只发 11/17/21/25(**已停发 8**,官方页面直接让去下 Temurin 8)。 + #[must_use] + pub const fn supports(self, major: u32) -> bool { + match self { + Self::Temurin | Self::Zulu => major >= 8, + Self::Oracle => major >= 9, + Self::Microsoft => matches!(major, 11 | 17 | 21 | 25), + } + } + + /// 已知在发的 GA 大版本(`java vendors` 展示与设置页提示用;不是硬上限)。 + #[must_use] + pub const fn hint_majors(self) -> &'static [u32] { + match self { + Self::Temurin => &[8, 11, 17, 21, 25], + Self::Oracle => &[17, 21, 25], + Self::Microsoft => &[11, 17, 21, 25], + Self::Zulu => &[8, 11, 17, 21, 25], + } + } + + /// 国内默认序(用户定案 + 实测修正)。 + #[must_use] + pub const fn domestic_default() -> [Self; 4] { + [Self::Temurin, Self::Oracle, Self::Microsoft, Self::Zulu] + } + + /// 国外默认序(用户原话顺序)。 + #[must_use] + pub const fn global_default() -> [Self; 4] { + [Self::Temurin, Self::Microsoft, Self::Oracle, Self::Zulu] + } + + /// 按地区取默认序。 + #[must_use] + pub const fn default_order(domestic: bool) -> [Self; 4] { + if domestic { + Self::domestic_default() + } else { + Self::global_default() + } + } +} + +/// 用户优先级(设置值)→ 实际尝试顺序。 +/// +/// 语义刻意做成「**补集追加**」而不是「必须写全」:用户写了 `["oracle"]` 就只把 oracle 提上来, +/// 其余按地区默认序跟在后面。理由——写全四家是负担,漏写更不该等于「其余不试」。 +#[must_use] +pub fn order(priority: &[JdkVendor], domestic: bool) -> Vec { + let mut out: Vec = Vec::with_capacity(JdkVendor::ALL.len()); + for vendor in priority { + if !out.contains(vendor) { + out.push(*vendor); + } + } + for vendor in JdkVendor::default_order(domestic) { + if !out.contains(&vendor) { + out.push(vendor); + } + } + out +} + +/// 解析设置值里的字符串数组(未知项忽略;顺序保留)。 +#[must_use] +pub fn parse_priority(items: &[String]) -> Vec { + let mut out = Vec::with_capacity(items.len()); + for item in items { + if let Some(vendor) = JdkVendor::parse(item) { + if !out.contains(&vendor) { + out.push(vendor); + } + } + } + out +} + +/// 尝试顺序 → 稳定 id 列表(JSON / 人读输出)。 +#[must_use] +pub fn ids(vendors: &[JdkVendor]) -> Vec<&'static str> { + vendors.iter().map(|vendor| vendor.id()).collect() +} + +#[cfg(test)] +mod tests { + use super::{ids, order, parse_priority, JdkVendor}; + + #[test] + fn domestic_and_global_defaults_follow_d23() { + assert_eq!( + ids(&order(&[], true)), + vec!["temurin", "oracle", "microsoft", "zulu"], + "国内:Oracle 在 MS 之前(实测差 28×)" + ); + assert_eq!( + ids(&order(&[], false)), + vec!["temurin", "microsoft", "oracle", "zulu"], + "国外:维持用户原话 Temurin→MS→Oracle" + ); + } + + #[test] + fn user_priority_leads_and_the_rest_follows_as_a_complement() { + let priority = parse_priority(&["microsoft".to_string()]); + assert_eq!( + ids(&order(&priority, true)), + vec!["microsoft", "temurin", "oracle", "zulu"], + "只写一家也要把其余按默认序补齐" + ); + let full = parse_priority(&["zulu".to_string(), "oracle".to_string(), "zulu".to_string()]); + assert_eq!( + ids(&order(&full, true)), + vec!["zulu", "oracle", "temurin", "microsoft"], + "重复项去重,未列出的追加在后面" + ); + } + + #[test] + fn unknown_setting_values_are_ignored() { + let parsed = parse_priority(&["graalvm".to_string(), "AZUL".to_string()]); + assert_eq!(parsed, vec![JdkVendor::Zulu]); + } + + #[test] + fn vendor_ids_majors_and_aliases() { + assert_eq!(JdkVendor::parse("Adoptium"), Some(JdkVendor::Temurin)); + assert_eq!(JdkVendor::parse("ms"), Some(JdkVendor::Microsoft)); + assert_eq!(JdkVendor::parse("graalvm"), None); + // MS 没有 8:这是「回退链要跳过某家」的真实判据。 + assert!(!JdkVendor::Microsoft.supports(8)); + assert!(JdkVendor::Microsoft.supports(21)); + assert!(JdkVendor::Oracle.supports(9)); + assert!(!JdkVendor::Oracle.supports(8), "8 交给 Temurin"); + assert!(JdkVendor::Temurin.supports(8)); + for vendor in JdkVendor::ALL { + assert!( + vendor.hint_majors().contains(&21), + "{} 应发 21", + vendor.id() + ); + } + } +} diff --git a/crates/core/src/game/java/zulu.rs b/crates/core/src/game/java/zulu.rs new file mode 100644 index 0000000..b572d7f --- /dev/null +++ b/crates/core/src/game/java/zulu.rs @@ -0,0 +1,275 @@ +//! Azul Zulu:**第四备选**(D23)。API 元数据 + CDN 直链,实现刻意简化。 +//! +//! 为什么排最后:国内没有任何 Zulu 镜像(三家云 + 四家校园源全无 `zulu`/`azul` 目录), +//! 只能吃 `cdn.azul.com` 的 3.3 MB/s(211 MB ≈ 64 s);而前三家在国内都有更好的路。 +//! 留这一家的价值是「三家全挂时的最后一条活路」,以及 Temurin/Oracle/MS 都缺某版本时的兜底。 +//! +//! 两跳(research/06 §4.3): +//! +//! ```text +//! GET {api}?java_version=21&os=windows&arch=x64&java_package_type=jdk&archive_type=zip&latest=true +//! → [{ package_uuid, name, java_version, download_url }] # 注意:不含 sha256 +//! GET {detail({uuid} 占位)} → { sha256_hash, size(不可信,差 133 B) } +//! ``` +//! +//! 三条实测约束:①`latest=true` 的响应**没有** `sha256_hash`,必须再取一次详情; +//! ②`size` 字段与真实字节数差 133 B → 一律不信,只填 0;③文件名必须照抄 API 返回值 +//! (`zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip` 里混着三段与四段两种写法,拼不出来)。 + +use serde::Deserialize; + +use super::error::{JavaError, JavaErrorKind}; +use super::net; +use super::release::{JdkArchive, JdkRelease}; +use super::target::AdoptiumTarget; +use super::vendor::JdkVendor; +use crate::download::ZuluTable; + +/// Zulu 的 os 段(`windows` / `macos` / `linux`)。 +#[must_use] +pub fn zulu_os(target: AdoptiumTarget) -> &'static str { + match target.os { + "mac" => "macos", + other => other, + } +} + +/// 包列表查询地址(`latest=true` 只返回最新一个)。 +#[must_use] +pub fn api_url(table: &ZuluTable, major: u32, target: AdoptiumTarget) -> String { + format!( + "{}?java_version={major}&os={}&arch={}&java_package_type=jdk&archive_type=zip&latest=true", + table.api, + zulu_os(target), + target.arch + ) +} + +/// 包详情地址(`{uuid}` 占位替换)。 +#[must_use] +pub fn detail_url(table: &ZuluTable, uuid: &str) -> String { + table.detail.replace("{uuid}", uuid) +} + +/// 列表响应里我们关心的字段。 +/// +/// ⚠️ 真机形态(2026-10-30 实测):`latest=true` **不止返回一条**,而且 `java_version` 是 +/// **数字数组**(`[21,0,13,0,3]`)而不是字符串;同一批里还混着 `-crac-`(CRaC)与 +/// `-fx-`(JavaFX)变体。所以这里按「字段宽进 + 只挑纯 JDK 包」处理,见 [`parse_packages`]。 +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct ZuluPackage { + /// 包 id(取详情用)。 + pub package_uuid: String, + /// 归档文件名(照抄,不拼)。 + pub name: String, + /// JDK 版本(`[21,0,13,0,3]`,Azul 用数组给)。 + #[serde(default)] + pub java_version: Vec, + /// CDN 直链。 + pub download_url: String, +} + +impl ZuluPackage { + /// 版本串(`[21,0,13,0,3]` → `21.0.13.0.3`;空数组时退回文件名)。 + #[must_use] + pub fn version_text(&self) -> String { + if self.java_version.is_empty() { + return self.name.clone(); + } + self.java_version + .iter() + .map(u32::to_string) + .collect::>() + .join(".") + } +} + +/// 是否是「纯 JDK」包(排除 CRaC / JavaFX / JRE 变体)。 +#[must_use] +pub fn is_plain_jdk(name: &str) -> bool { + name.contains("-jdk") + && !name.contains("-jre") + && !name.contains("crac") + && !name.contains("-fx") +} + +/// 列表响应 → 纯 JDK 包(`latest=true` 会给多条,必须自己挑)。 +/// +/// # Errors +/// +/// 响应不是 JSON、列表为空时返回错误。 +pub fn parse_packages(json: &str) -> Result { + let packages: Vec = serde_json::from_str(json).map_err(|error| { + JavaError::new( + JavaErrorKind::Metadata, + format!("Zulu 列表响应不是预期 JSON:{error}"), + ) + })?; + let picked = packages + .iter() + .find(|package| is_plain_jdk(&package.name)) + .or_else(|| packages.first()) + .ok_or_else(|| { + JavaError::new( + JavaErrorKind::Metadata, + "Zulu 没有该版本/平台的 JDK 包(列表为空)", + ) + })?; + Ok(picked.clone()) +} + +/// 详情响应里的 `sha256_hash`(只信这一个字段,`size` 不可对拍)。 +/// +/// # Errors +/// +/// 响应不是 JSON 或缺可用哈希时返回错误。 +pub fn parse_detail_sha256(json: &str) -> Result { + #[derive(Deserialize)] + struct Detail { + #[serde(default)] + sha256_hash: Option, + } + let detail: Detail = serde_json::from_str(json).map_err(|error| { + JavaError::new( + JavaErrorKind::Metadata, + format!("Zulu 详情响应不是预期 JSON:{error}"), + ) + })?; + detail + .sha256_hash + .map(|hex| hex.trim().to_ascii_lowercase()) + .filter(|hex| hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit())) + .ok_or_else(|| { + JavaError::new( + JavaErrorKind::Integrity, + "Zulu 详情里没有可用的 sha256_hash", + ) + }) +} + +/// 查一次「某大版本的最新 Zulu JDK」。 +/// +/// # Errors +/// +/// 表里没配 Zulu、API 不可达/无包、详情缺哈希时返回错误(回退链到此为止,报总失败)。 +pub async fn fetch_latest( + client: &reqwest::Client, + table: &ZuluTable, + major: u32, + target: AdoptiumTarget, +) -> Result { + let list = net::get_text(client, &api_url(table, major, target), "Zulu 包列表").await?; + let package = parse_packages(&list)?; + let detail = net::get_text( + client, + &detail_url(table, &package.package_uuid), + "Zulu 包详情", + ) + .await?; + let sha256 = parse_detail_sha256(&detail)?; + // Zulu 的版本串用 `java_version` 数组拼(真机是数组不是字符串);文件名照抄, + // 因为文件名里 `jdk21.0.13.0.3` 这类四段/五段写法拼不出来。 + Ok(JdkRelease { + vendor: JdkVendor::Zulu, + major, + version: package.version_text(), + semver: None, + release_name: package.name.clone(), + archive: JdkArchive { + name: package.name, + url: package.download_url, + sha256, + // size 字段不可信(实测差 133 B)→ 填 0,交给 sha256 校验。 + size: 0, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::{api_url, detail_url, is_plain_jdk, parse_detail_sha256, parse_packages, zulu_os}; + use crate::download::MirrorTable; + use crate::game::java::target::AdoptiumTarget; + + const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64"); + + fn table() -> crate::download::ZuluTable { + MirrorTable::builtin() + .zulu() + .expect("内置表应带 Zulu") + .clone() + } + + #[test] + fn urls_carry_every_filter_the_api_needs() { + let table = table(); + let url = api_url(&table, 21, WINDOWS); + assert!(url.starts_with("https://api.azul.com/metadata/v1/zulu/packages/?")); + for needle in [ + "java_version=21", + "os=windows", + "arch=x64", + "java_package_type=jdk", + "archive_type=zip", + "latest=true", + ] { + assert!(url.contains(needle), "{needle} 应在 {url}"); + } + assert_eq!( + detail_url(&table, "abc-123"), + "https://api.azul.com/metadata/v1/zulu/packages/abc-123" + ); + assert_eq!(zulu_os(AdoptiumTarget::new("mac", "aarch64")), "macos"); + } + + #[test] + fn latest_listing_gives_the_uuid_and_the_exact_filename() { + // 真机响应(2026-10-30):`latest=true` 返回多条,`java_version` 是数组, + // 并且混着 `-crac-` / `-fx-` 变体——必须挑出纯 JDK 那条。 + let json = r#"[ + {"package_uuid":"4ee30e3a-b26a-40d3-9d04-2182eae4075e", + "name":"zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip", + "java_version":[21,0,13,0,3], + "download_url":"https://cdn.azul.com/zulu/bin/zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip"}, + {"package_uuid":"2781fb21-9401-40d3-a88e-cf8f36541e3c", + "name":"zulu21.52.203-ca-crac-jdk21.0.12.1-win_x64.zip", + "java_version":[21,0,12,1], + "download_url":"https://cdn.azul.com/zulu/bin/zulu21.52.203-ca-crac-jdk21.0.12.1-win_x64.zip"}, + {"package_uuid":"49851aa2-4fd2-4e54-a903-323989cdf711", + "name":"zulu21.52.203-ca-fx-jdk21.0.12.1-win_x64.zip", + "java_version":[21,0,12,1], + "download_url":"https://cdn.azul.com/zulu/bin/zulu21.52.203-ca-fx-jdk21.0.12.1-win_x64.zip"} + ]"#; + let package = parse_packages(json).expect("应解析"); + assert_eq!(package.package_uuid, "4ee30e3a-b26a-40d3-9d04-2182eae4075e"); + assert_eq!( + package.name, "zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip", + "文件名必须照抄 API(三段/四段混写,拼不出来)" + ); + assert_eq!(package.version_text(), "21.0.13.0.3"); + assert!(is_plain_jdk(&package.name)); + assert!(!is_plain_jdk( + "zulu21.52.203-ca-crac-jdk21.0.12.1-win_x64.zip" + )); + assert!(!is_plain_jdk( + "zulu21.52.203-ca-fx-jdk21.0.12.1-win_x64.zip" + )); + assert!( + parse_packages("[]").is_err(), + "空列表要报错而不是造一个空包" + ); + assert!(parse_packages("502").is_err()); + } + + #[test] + fn detail_gives_a_trustworthy_sha256_only() { + let json = r#"{"package_uuid":"x","size":211151000, + "sha256_hash":"6B0E2B0F4C1D2A3E4F5061728394A5B6C7D8E9F0A1B2C3D4E5F60718293A4B5C"}"#; + assert_eq!( + parse_detail_sha256(json).expect("大写哈希应被接受并转小写"), + "6b0e2b0f4c1d2a3e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c" + ); + assert!(parse_detail_sha256(r#"{"size":1}"#).is_err()); + assert!(parse_detail_sha256("not json").is_err()); + } +} diff --git a/crates/core/src/game/layout.rs b/crates/core/src/game/layout.rs new file mode 100644 index 0000000..408473e --- /dev/null +++ b/crates/core/src/game/layout.rs @@ -0,0 +1,165 @@ +//! `.minecraft` 实例目录布局:`versions/` `libraries/` `assets/` + ULL 扩展 `.ull/`。 +//! +//! 依据:`DevDocs/analyse/03-version-download.md` §6.8(目录布局速查)、 +//! `DevDocs/PlanDoc/04-data-and-config.md` §2(`.minecraft/` 遵循 Mojang 标准 + +//! ULL 扩展 `.ull/`)。 +//! +//! 布局是**纯路径计算**:不建目录、不写文件。所有路径规则集中在这里, +//! 这样将来要支持 MultiMC 家族布局(`instances//minecraft/`)只需再加一个实现 +//! (analyse/01 §3.3 的 `GameRepositoryLayout` 抽象意义)。 + +use std::path::{Path, PathBuf}; + +use super::error::GameError; +use super::library::MavenCoordinate; + +/// 一个游戏根目录(通常就是 `.minecraft/`)的布局规则。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GameLayout { + root: PathBuf, +} + +impl GameLayout { + /// 以给定根目录构造布局。 + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + /// 根目录。 + pub fn root(&self) -> &Path { + &self.root + } + + /// `versions/`。 + pub fn versions_dir(&self) -> PathBuf { + self.root.join("versions") + } + + /// `libraries/`。 + pub fn libraries_dir(&self) -> PathBuf { + self.root.join("libraries") + } + + /// `assets/`。 + pub fn assets_dir(&self) -> PathBuf { + self.root.join("assets") + } + + /// `assets/indexes/`。 + pub fn indexes_dir(&self) -> PathBuf { + self.assets_dir().join("indexes") + } + + /// `assets/objects/`(两级分片,避开 NTFS 单目录塌陷)。 + pub fn objects_dir(&self) -> PathBuf { + self.assets_dir().join("objects") + } + + /// `assets/log_configs/`(日志配置缓存)。 + pub fn log_configs_dir(&self) -> PathBuf { + self.assets_dir().join("log_configs") + } + + /// 实例根目录 `versions//`。 + pub fn version_dir(&self, id: &str) -> PathBuf { + self.versions_dir().join(id) + } + + /// 实例清单 `versions//.json`。 + pub fn version_json(&self, id: &str) -> PathBuf { + self.version_dir(id).join(format!("{id}.json")) + } + + /// 实例 jar `versions//.jar`。 + pub fn version_jar(&self, id: &str) -> PathBuf { + self.version_dir(id).join(format!("{id}.jar")) + } + + /// natives 解压目录 `versions//natives/`(ULL 约定,HMCL 用 `natives/`)。 + pub fn natives_dir(&self, id: &str) -> PathBuf { + self.version_dir(id).join("natives") + } + + /// 实例私有状态目录 `versions//.ull/`(上次会话日志索引、安装收据等)。 + pub fn version_state_dir(&self, id: &str) -> PathBuf { + self.version_dir(id).join(".ull") + } + + /// 实例私有库目录 `versions//libraries/`(HMCL `hint=local` 的落点)。 + pub fn version_libraries_dir(&self, id: &str) -> PathBuf { + self.version_dir(id).join("libraries") + } + + /// 启动器全局私有目录 `/.ull/`。 + pub fn ull_root(&self) -> PathBuf { + self.root.join(".ull") + } + + /// 资源索引 `assets/indexes/.json`。 + pub fn asset_index(&self, assets_id: &str) -> PathBuf { + self.indexes_dir().join(format!("{assets_id}.json")) + } + + /// 资源对象 `assets/objects//`。 + /// + /// # Errors + /// + /// 哈希短于两个字符时返回 [`GameError::InvalidAssetHash`]。 + pub fn asset_object(&self, hash: &str) -> Result { + let shard = hash.get(0..2).ok_or_else(|| GameError::InvalidAssetHash { + hash: hash.to_owned(), + })?; + Ok(self.objects_dir().join(shard).join(hash)) + } + + /// 公共库 `libraries//-[-].`。 + pub fn library(&self, coordinate: &MavenCoordinate) -> PathBuf { + self.libraries_dir().join(coordinate.relative_path()) + } + + /// 实例私有库 `versions//libraries/...`。 + pub fn version_library(&self, version_id: &str, coordinate: &MavenCoordinate) -> PathBuf { + self.version_libraries_dir(version_id) + .join(coordinate.relative_path()) + } + + /// 日志配置文件 `assets/log_configs/`。 + pub fn log_config(&self, file_id: &str) -> PathBuf { + self.log_configs_dir().join(file_id) + } + + /// 该版本是否已安装(判据:清单文件存在)。 + pub fn is_version_installed(&self, id: &str) -> bool { + self.version_json(id).is_file() + } + + /// 列出已安装的版本 id(有 `versions//.json` 的目录),按字典序排列。 + /// + /// # Errors + /// + /// 读 `versions/` 失败时返回 [`GameError::Io`];目录不存在视为空列表。 + pub fn installed_versions(&self) -> Result, GameError> { + let dir = self.versions_dir(); + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(GameError::io(&dir, e)), + }; + let mut ids = Vec::new(); + for entry in entries { + let entry = entry.map_err(|e| GameError::io(&dir, e))?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if path.join(format!("{name}.json")).is_file() { + ids.push(name.to_owned()); + } + } + ids.sort(); + Ok(ids) + } +} diff --git a/crates/core/src/game/library.rs b/crates/core/src/game/library.rs new file mode 100644 index 0000000..230c4b9 --- /dev/null +++ b/crates/core/src/game/library.rs @@ -0,0 +1,213 @@ +//! 依赖库模型:Maven 坐标、natives 选择、库文件相对路径。 +//! +//! 依据:`DevDocs/analyse/03-version-download.md` §6.4(libraries 含 natives 规则)、 +//! §6.8(目录布局速查)。 +//! +//! 这里只做"数据 → 路径/候选"的纯计算,**不碰网络与文件系统**: +//! 真正的下载归 `core::download`,放置归 [`super::layout`]。 + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use super::arguments::{rules_allow, Environment, Rule}; +use super::error::GameError; +use super::manifest::DownloadInfo; + +/// 一条依赖库。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Library { + /// Maven 坐标 `group:artifact:version[:classifier][@ext]`。 + #[serde(default)] + pub name: String, + /// 下载信息;老 JSON/魔改 JSON 可能没有。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub downloads: Option, + /// natives 选择表:`{ "windows": "natives-windows-${arch}" }`。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub natives: Option>, + /// 生效规则(空 = 生效)。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules: Vec, + /// natives 解压规则。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extract: Option, + /// Maven 仓库基地址覆盖。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// HMCL 私有:`local` 表示库放在实例私有的 `versions//libraries/` 下。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hint: Option, + /// 未识别字段原样保留。 + #[serde(flatten)] + pub extra: BTreeMap, +} + +/// 一条依赖库的下载表。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LibraryDownloads { + /// 主构件。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact: Option, + /// 分类器构件(natives 走这里)。 + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub classifiers: BTreeMap, +} + +/// natives 解压规则:命中 `exclude` 的条目不解压。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Extract { + /// 排除的路径模式(`META-INF/`)。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub exclude: Vec, +} + +/// Maven 坐标(`group:artifact:version[:classifier][@ext]`)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MavenCoordinate { + /// group,如 `org.lwjgl`。 + pub group: String, + /// artifact,如 `lwjgl`。 + pub artifact: String, + /// version,如 `3.3.2`。 + pub version: String, + /// classifier(natives 常用),如 `natives-windows`。 + pub classifier: Option, + /// 扩展名,缺省 `jar`。 + pub extension: String, +} + +impl MavenCoordinate { + /// 解析坐标字符串。 + pub fn parse(raw: &str) -> Result { + let invalid = |reason: &'static str| GameError::InvalidCoordinate { + raw: raw.to_owned(), + reason, + }; + let (body, extension) = match raw.split_once('@') { + Some((body, ext)) if !ext.is_empty() => (body, ext.to_owned()), + Some(_) => return Err(invalid("扩展名为空")), + None => (raw, String::from("jar")), + }; + let parts: Vec<&str> = body.split(':').collect(); + if parts.len() < 3 { + return Err(invalid("至少需要 group:artifact:version 三段")); + } + if parts.iter().any(|p| p.is_empty()) { + return Err(invalid("存在空字段")); + } + match parts.len() { + 3 => Ok(Self { + group: parts[0].to_owned(), + artifact: parts[1].to_owned(), + version: parts[2].to_owned(), + classifier: None, + extension, + }), + 4 => Ok(Self { + group: parts[0].to_owned(), + artifact: parts[1].to_owned(), + version: parts[2].to_owned(), + classifier: Some(parts[3].to_owned()), + extension, + }), + _ => Err(invalid("字段多于四段")), + } + } + + /// group 转目录形式(`org.lwjgl` → `org/lwjgl`)。 + pub fn group_path(&self) -> String { + self.group.replace('.', "/") + } + + /// 文件名:`-[-].`。 + pub fn file_name(&self) -> String { + match &self.classifier { + Some(classifier) => format!( + "{}-{}-{}.{}", + self.artifact, self.version, classifier, self.extension + ), + None => format!("{}-{}.{}", self.artifact, self.version, self.extension), + } + } + + /// 相对 `libraries/` 的路径: + /// `///-[-].`。 + pub fn relative_path(&self) -> PathBuf { + PathBuf::from(self.group_path()) + .join(&self.artifact) + .join(&self.version) + .join(self.file_name()) + } + + /// 换一个 classifier 的副本(natives 用)。 + pub fn with_classifier(&self, classifier: impl Into) -> Self { + Self { + classifier: Some(classifier.into()), + ..self.clone() + } + } +} + +impl Library { + /// 解析本库的 Maven 坐标。 + pub fn coordinate(&self) -> Result { + MavenCoordinate::parse(&self.name) + } + + /// 该库在当前环境下是否生效。 + pub fn applies(&self, env: &Environment) -> bool { + rules_allow(&self.rules, env) + } + + /// 是否是实例私有库(HMCL `hint=local`)。 + pub fn is_local(&self) -> bool { + self.hint.as_deref() == Some("local") + } + + /// 主构件下载信息。 + pub fn artifact(&self) -> Option<&DownloadInfo> { + self.downloads.as_ref()?.artifact.as_ref() + } + + /// natives 分类器名:按 OS 查 `natives` 表并展开 `${arch}`(64 位为 `64`)。 + pub fn native_classifier(&self, env: &Environment) -> Option { + let raw = self.natives.as_ref()?.get(&env.os)?; + let arch = if matches!(env.arch.as_str(), "x86_64" | "arm64") { + "64" + } else { + "" + }; + Some(raw.replace("${arch}", arch)) + } + + /// natives 构件下载信息(按分类器查 `downloads.classifiers`)。 + pub fn native_artifact(&self, classifier: &str) -> Option<&DownloadInfo> { + self.downloads.as_ref()?.classifiers.get(classifier) + } + + /// 主构件相对 `libraries/` 的路径。 + /// + /// 优先用 JSON 里给出的 `artifact.path`(Mojang 官方的权威值),否则按坐标推导。 + pub fn relative_path(&self) -> Result { + if let Some(path) = self.artifact().and_then(|a| a.path.as_deref()) { + return Ok(PathBuf::from(path)); + } + Ok(self.coordinate()?.relative_path()) + } + + /// natives 构件相对 `libraries/` 的路径。 + pub fn native_relative_path(&self, classifier: &str) -> Result { + if let Some(path) = self + .native_artifact(classifier) + .and_then(|a| a.path.as_deref()) + { + return Ok(PathBuf::from(path)); + } + Ok(self + .coordinate()? + .with_classifier(classifier) + .relative_path()) + } +} diff --git a/crates/core/src/game/manifest.rs b/crates/core/src/game/manifest.rs new file mode 100644 index 0000000..0fca0d6 --- /dev/null +++ b/crates/core/src/game/manifest.rs @@ -0,0 +1,265 @@ +//! 版本 JSON(`versions//.json`)的数据模型。 +//! +//! 依据:`DevDocs/analyse/03-version-download.md` §4.1(Manifest + Patch 两层模型)、 +//! §4.2(宽松解析 + `rawJson` 保真)、§4.6(旧版 JSON 的四个特殊分支)。 +//! +//! 一个类型吃两种形态:磁盘上的实例清单(`root`/`hidden`/`patches`)与可合并的补丁层 +//! (多一个 `priority`)。合并语义不在这里,而在 [`super::inherit`]——模型只管形状。 +//! +//! **保真**:未知字段全部落进 `extra`(`#[serde(flatten)]`),回写时原样带出。 +//! 第三方启动器的私有字段(`MMC-*`、`tlauncherVersion`…)因此不会丢。 + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::arguments::Arguments; +use super::error::GameError; +use super::library::Library; +use super::version::ReleaseType; + +/// 单个文件的下载信息(`downloads.client`、库 artifact、资源索引、日志配置都用它)。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DownloadInfo { + /// 内容 id(资源索引/日志配置用;例如日志配置的 `client-1.12.xml`)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// SHA-1 校验值。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha1: Option, + /// 字节数。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, + /// 下载地址(老版本的版本 JSON 可能完全没有)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// 库文件相对 `libraries/` 的路径(Mojang 在 artifact 里给出)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +impl DownloadInfo { + /// URL 是否可用。 + pub fn has_url(&self) -> bool { + self.url.as_deref().is_some_and(|u| !u.is_empty()) + } +} + +/// 版本 JSON 的 `downloads` 对象:`client` / `server` / `client_mappings` / `server_mappings`。 +/// +/// 用"字符串键 + 映射"而不是枚举,是因为魔改 JSON 里会出现未知键 +/// (analyse/03 §4.2:HMCL 对未知键 `catch` 后忽略),枚举键会让整份 JSON 解析失败。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Downloads(pub BTreeMap); + +impl Downloads { + /// 客户端 jar 的下载信息。 + pub fn client(&self) -> Option<&DownloadInfo> { + self.0.get("client") + } + + /// 服务端 jar 的下载信息。 + pub fn server(&self) -> Option<&DownloadInfo> { + self.0.get("server") + } + + /// 按原始键取。 + pub fn get(&self, key: &str) -> Option<&DownloadInfo> { + self.0.get(key) + } + + /// 条目数。 + pub fn len(&self) -> usize { + self.0.len() + } + + /// 是否为空。 + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +/// 日志配置(`logging.client`)。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LoggingInfo { + /// JVM 参数模板,含 `${path}` 占位符。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub argument: Option, + /// 配置文件本体。 + #[serde(default)] + pub file: DownloadInfo, + /// `log4j2-xml` 之类。 + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +/// 版本 JSON 的 `logging` 对象(键通常是 `client`)。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Logging(pub BTreeMap); + +impl Logging { + /// 客户端日志配置。 + pub fn client(&self) -> Option<&LoggingInfo> { + self.0.get("client") + } +} + +/// `javaVersion`:组件名 + 主版本号(D13 的 MC↔Java 映射用)。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaVersion { + /// 官方 Java 运行时组件名,例如 `java-runtime-gamma`。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub component: Option, + /// 主版本号,例如 `17`。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub major_version: Option, +} + +/// `assetIndex`:资源索引的引用。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetIndexRef { + /// 索引 id(`1.20`、`legacy`…),对应 `assets/indexes/.json`。 + #[serde(default)] + pub id: String, + /// 索引文件 SHA-1。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha1: Option, + /// 索引文件字节数。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, + /// 全部对象的总字节数(`totalSize`,进度估算用)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_size: Option, + /// 索引下载地址。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +/// 一份版本 JSON(磁盘上的 `.json`,或一个可合并的补丁层)。 +/// +/// 字段几乎全部可空——这是"容忍残缺清单"的前提(analyse/03 §4.1:HMCL 22 个字段全可空)。 +/// 解析后的不变量(由 [`super::inherit::resolve`] 保证):`inherits_from == None`、 +/// `jar` 已回退成实例 id、`patches` 已清空、`root == Some(true)`。 +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct VersionJson { + /// 版本 id。 + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + /// 展示名(部分整合包写入)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// 继承的父版本 id。 + #[serde(skip_serializing_if = "Option::is_none")] + pub inherits_from: Option, + /// 启动用的 jar 所在版本 id;`None` 表示用本实例的 jar。 + #[serde(skip_serializing_if = "Option::is_none")] + pub jar: Option, + /// 主类。 + #[serde(skip_serializing_if = "Option::is_none")] + pub main_class: Option, + /// 旧版单串游戏参数(pre-1.13),与 `arguments` 并存。 + #[serde(skip_serializing_if = "Option::is_none")] + pub minecraft_arguments: Option, + /// 新版结构化参数。 + #[serde(skip_serializing_if = "Option::is_none")] + pub arguments: Option, + /// 资源版本名(`1.20`、`legacy`…);缺失按 `legacy` 处理(analyse/03 §4.6)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub assets: Option, + /// 资源索引引用。 + #[serde(skip_serializing_if = "Option::is_none")] + pub asset_index: Option, + /// 兼容级别。 + #[serde(skip_serializing_if = "Option::is_none")] + pub compliance_level: Option, + /// 需要的 Java 版本。 + #[serde(skip_serializing_if = "Option::is_none")] + pub java_version: Option, + /// 依赖库列表。 + #[serde(skip_serializing_if = "Option::is_none")] + pub libraries: Option>, + /// 下载信息。 + #[serde(skip_serializing_if = "Option::is_none")] + pub downloads: Option, + /// 日志配置。 + #[serde(skip_serializing_if = "Option::is_none")] + pub logging: Option, + /// 版本分类。 + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub version_type: Option, + /// 最后修改时间。 + #[serde(skip_serializing_if = "Option::is_none")] + pub time: Option, + /// 发布时间。 + #[serde(skip_serializing_if = "Option::is_none")] + pub release_time: Option, + /// 启动器最低版本要求。 + #[serde(skip_serializing_if = "Option::is_none")] + pub minimum_launcher_version: Option, + /// 是否为根清单(HMCL 私有字段)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub root: Option, + /// 是否在 UI 中隐藏(HMCL 私有字段)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub hidden: Option, + /// 补丁层,按 `priority` 升序合并(HMCL 私有字段)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub patches: Option>, + /// 补丁优先级;`None` 视作最小值(最先合并,analyse/03 §4.1)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, + /// 未识别的字段,原样保留(`rawJson` 保真)。 + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl VersionJson { + /// 解析一份版本 JSON。 + pub fn from_json_str(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| GameError::json("version_json", e)) + } + + /// 从字节解析(直接吃 HTTP 响应体)。 + pub fn from_json_slice(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|e| GameError::json("version_json", e)) + } + + /// 序列化回 JSON。 + pub fn to_json_string(&self) -> Result { + serde_json::to_string(self).map_err(|e| GameError::json("version_json", e)) + } + + /// 取 `id`,缺失则报错。 + pub fn require_id(&self) -> Result<&str, GameError> { + self.id.as_deref().ok_or(GameError::MissingVersionId) + } + + /// 客户端 jar 的下载信息;老版本 JSON 没有 `downloads.client`(analyse/03 §4.6), + /// 兜底 URL 由 download/launch 侧按镜像表拼,不在模型里硬编码 BMCLAPI。 + pub fn client_jar(&self) -> Option<&DownloadInfo> { + self.downloads.as_ref().and_then(Downloads::client) + } + + /// 资源版本名,缺失时按 `legacy`(analyse/03 §4.6 第 3 条)。 + pub fn assets_id(&self) -> &str { + self.assets.as_deref().unwrap_or("legacy") + } + + /// 是否已是被解析过的扁平清单。 + pub fn is_resolved(&self) -> bool { + self.inherits_from.is_none() && self.patches.is_none() && self.jar.is_some() + } + + /// 版本是否带 Vulkan 后端(`libraries` 含 `org.lwjgl:lwjgl-vulkan`)。 + /// + /// 只是**必要条件**:`26.2-snapshot-1` 有库但还没有 `--graphicsBackend` + /// (差两天,实测),下发门限见 [`crate::game::capability`] 的模块文档。 + pub fn has_vulkan_backend(&self) -> bool { + super::capability::VersionCapabilities::from_json(self).has_vulkan_backend() + } +} diff --git a/crates/core/src/game/mod.rs b/crates/core/src/game/mod.rs new file mode 100644 index 0000000..e05e441 --- /dev/null +++ b/crates/core/src/game/mod.rs @@ -0,0 +1,64 @@ +//! 版本与实例仓库:不可变快照 + 单写者草稿、版本 JSON 继承解析、实例目录布局。 +//! +//! 设计定稿:`DevDocs/PlanDoc/04-data-and-config.md` §4(实例模型)、 +//! `DevDocs/PlanDoc/01-tech-stack-and-architecture.md` §3(Snapshot/Draft 纪律), +//! 源证据 `DevDocs/analyse/01-architecture-overview.md` §3.3。 +//! +//! 与其它模块的契约:快照(Snapshot)类型与继承解析结果要与 launch 对齐 +//! (`DevDocs/SubAgentsTalk/README.md` 的对齐点;详细约定见 `SubAgentsTalk/game-接口约定.md`)。 +//! +//! 公共面(M14): +//! - 版本清单:[`VersionList`] / [`RemoteVersion`] / [`ReleaseType`] / [`VersionCategory`], +//! 外加内嵌补充清单 [`unlisted`]; +//! - 版本 JSON 与继承:[`VersionJson`]、[`resolve`]、[`merge`]、[`apply_patches`]; +//! - 参数与库:[`Arguments`] / [`Environment`] / [`Rule`]、[`Library`] / [`MavenCoordinate`]; +//! - 能力位:[`capability`](版本 JSON 库表 → `has_vulkan_backend`); +//! - 实例:目录布局 [`GameLayout`]、档案 [`ProfileList`] / [`InstanceProfile`] / [`ProfileId`]; +//! - 发布:快照仓库 [`Store`] / [`Snapshot`] / [`Draft`]。 +//! +//! 安装层(M27,[`install`]):版本链 → 下载计划 → 分区落盘 → 实例登记。 +//! 它把上面这些"模型"串成可执行的流程,但**自己不碰 UI、不拼镜像 URL**: +//! 下载全部交给 `core::download` 的引擎(镜像/健康度/续传/校验都在那边)。 +//! +//! Java 管理(M33,[`java`]):托管 JDK 的查询/下载/解压/探测(`.ULL/data/java-managed/`) +//! 与 MC↔Java 映射表。同样只借 `core::download` 的引擎做下载,自己不拼镜像 URL。 +//! +//! 模块分工按"数据形状 / 纯计算 / 状态"切开,任何模块都不碰网络与 UI: +//! 下载归 `core::download`、进程归 `core::launch`、配置落盘归 `core::setting`。 + +pub mod arguments; +pub mod capability; +pub mod error; +pub mod inherit; +pub mod install; +pub mod isolation; +pub mod java; +pub mod layout; +pub mod library; +pub mod manifest; +pub mod profiles; +pub mod snapshot; +pub mod unlisted; +pub mod version; + +pub use arguments::{rules_allow, Arguments, Environment, ExpandedArguments, Rule, RuleAction}; +pub use capability::{ + is_vulkan_library, library_names_have_vulkan, VersionCapabilities, VULKAN_LIBRARY_ARTIFACT, + VULKAN_LIBRARY_GROUP, +}; +pub use error::GameError; +pub use inherit::{apply_patches, merge, resolve, sorted_patches, VersionSource}; +pub use isolation::{ + derive_game_dir, observe_mode, probe_both_sides, probe_side, Derivation, IsolationMode, + IsolationSource, ModeChange, ModeChangeProbe, ModeChangeWarning, SideContent, + PROBED_CONTENT_DIRS, SHARED_LAYERS, +}; +pub use layout::GameLayout; +pub use library::{Library, MavenCoordinate}; +pub use manifest::{AssetIndexRef, DownloadInfo, Downloads, JavaVersion, Logging, VersionJson}; +pub use profiles::{InstanceProfile, ProfileId, ProfileList, PROFILE_SCHEMA_VERSION}; +pub use snapshot::{Draft, Snapshot, Store}; +pub use version::{ + LatestVersions, ReleaseType, RemoteVersion, VersionCategory, VersionList, + VERSION_MANIFEST_V2_URL, +}; diff --git a/crates/core/src/game/profiles.rs b/crates/core/src/game/profiles.rs new file mode 100644 index 0000000..db222db --- /dev/null +++ b/crates/core/src/game/profiles.rs @@ -0,0 +1,244 @@ +//! 实例清单(`profiles.json`)模型:稳定 UUID + 实例档案。 +//! +//! 依据:`DevDocs/PlanDoc/04-data-and-config.md` §4(实例模型预告)、§2(`.ULL/config/profiles.json`)。 +//! +//! 两条硬约束(来自 analyse/01 §7.6 的教训): +//! 1. **实例 id 用稳定 UUID,不用路径**——路径会变(用户挪目录、迁移数据目录), +//! 而 id 一旦写进档案就不能变; +//! 2. 档案只存**绑定关系**(版本 id、独立目录、Java 覆盖),不存派生的绝对路径结论; +//! 全局/实例/临时三层设置的合并归 `core::setting`,这里不重复。 +//! +//! 落盘归 `core::setting` 的原子写 + schema 迁移(M12),本模块只提供模型与不变量。 + +use std::collections::BTreeMap; +use std::fmt; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::{Deserialize, Serialize}; + +/// `profiles.json` 的 schema 版本(迁移链由 `core::setting` 驱动)。 +pub const PROFILE_SCHEMA_VERSION: u32 = 1; + +/// 实例 id:UUID 形状的稳定标识。 +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProfileId(String); + +impl ProfileId { + /// 生成一个新 id(v4 形状)。 + /// + /// **不是密码学随机**:由纳秒时间戳 + 进程 id + 进程内计数器经 SplitMix64 混合而来, + /// 足以保证单机唯一;删除实例后不复用即可。真要密码学随机,阶段 B 引 `uuid` crate。 + pub fn generate() -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos() as u64); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let mut seed = nanos ^ u64::from(std::process::id()).rotate_left(17) ^ seq.rotate_left(41); + + let mut bytes = [0u8; 16]; + for chunk in bytes.chunks_mut(8) { + seed = split_mix64(seed); + let source = seed.to_le_bytes(); + chunk.copy_from_slice(&source[..chunk.len()]); + } + // RFC 4122 v4 形状:版本位 + 变体位。 + bytes[6] = (bytes[6] & 0x0F) | 0x40; + bytes[8] = (bytes[8] & 0x3F) | 0x80; + + let mut hex = String::with_capacity(36); + for (index, byte) in bytes.iter().enumerate() { + if matches!(index, 4 | 6 | 8 | 10) { + hex.push('-'); + } + hex.push_str(&format!("{byte:02x}")); + } + Self(hex) + } + + /// 从字符串构造(不校验,用于读回历史档案;校验用 [`ProfileId::is_uuid_shaped`])。 + pub fn new(raw: impl Into) -> Self { + Self(raw.into()) + } + + /// 字符串视图。 + pub fn as_str(&self) -> &str { + &self.0 + } + + /// 是否是 UUID 形状(`8-4-4-4-12` 十六进制)。 + pub fn is_uuid_shaped(&self) -> bool { + let bytes = self.0.as_bytes(); + if bytes.len() != 36 { + return false; + } + bytes.iter().enumerate().all(|(index, byte)| match index { + 8 | 13 | 18 | 23 => *byte == b'-', + _ => byte.is_ascii_hexdigit(), + }) + } +} + +fn split_mix64(mut state: u64) -> u64 { + state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +impl fmt::Display for ProfileId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl FromStr for ProfileId { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(Self(s.to_owned())) + } +} + +impl From for ProfileId { + fn from(value: String) -> Self { + Self(value) + } +} + +/// 一个实例档案。 +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct InstanceProfile { + /// 稳定 id。 + pub id: ProfileId, + /// 展示名。 + #[serde(default)] + pub name: String, + /// 绑定的版本 id(`versions//`)。 + #[serde(default)] + pub version_id: String, + /// 独立游戏目录;`None` 表示使用全局 `.minecraft`。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub game_dir: Option, + /// 工作目录覆盖(HMCL 的"版本隔离")。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub running_dir: Option, + /// Java 路径覆盖。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub java_path: Option, + /// 创建时间(RFC-3339 字符串,由调用方填写)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option, + /// 最近启动时间。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_played_at: Option, + /// 未识别字段原样保留(向前兼容)。 + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl InstanceProfile { + /// 建一个最小档案(新实例默认值)。 + pub fn new(name: impl Into, version_id: impl Into) -> Self { + Self { + id: ProfileId::generate(), + name: name.into(), + version_id: version_id.into(), + ..Self::default() + } + } +} + +/// `profiles.json` 的内容。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProfileList { + /// schema 版本。 + #[serde(default = "default_schema_version")] + pub schema_version: u32, + /// 档案列表(顺序即 UI 顺序)。 + #[serde(default)] + pub profiles: Vec, +} + +fn default_schema_version() -> u32 { + PROFILE_SCHEMA_VERSION +} + +impl Default for ProfileList { + fn default() -> Self { + Self { + schema_version: PROFILE_SCHEMA_VERSION, + profiles: Vec::new(), + } + } +} + +impl ProfileList { + /// 按 id 查。 + pub fn get(&self, id: &ProfileId) -> Option<&InstanceProfile> { + self.profiles.iter().find(|p| &p.id == id) + } + + /// 按 id 改。 + pub fn get_mut(&mut self, id: &ProfileId) -> Option<&mut InstanceProfile> { + self.profiles.iter_mut().find(|p| &p.id == id) + } + + /// 插入或替换同 id 档案,返回被替换的旧档案。 + pub fn upsert(&mut self, profile: InstanceProfile) -> Option { + match self.profiles.iter().position(|p| p.id == profile.id) { + Some(index) => Some(std::mem::replace(&mut self.profiles[index], profile)), + None => { + self.profiles.push(profile); + None + } + } + } + + /// 删除档案,返回被删的档案。 + pub fn remove(&mut self, id: &ProfileId) -> Option { + let index = self.profiles.iter().position(|p| &p.id == id)?; + Some(self.profiles.remove(index)) + } + + /// 绑定到某版本的档案(同一版本可以有多个实例:隔离目录不同)。 + pub fn for_version(&self, version_id: &str) -> Vec<&InstanceProfile> { + self.profiles + .iter() + .filter(|p| p.version_id == version_id) + .collect() + } + + /// 档案数。 + pub fn len(&self) -> usize { + self.profiles.len() + } + + /// 是否没有档案。 + pub fn is_empty(&self) -> bool { + self.profiles.is_empty() + } + + /// 解析 `profiles.json`。 + /// + /// # Errors + /// + /// JSON 非法时返回 [`super::error::GameError::Json`]。 + pub fn from_json_str(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| super::error::GameError::json("profiles", e)) + } + + /// 序列化(格式化输出,便于用户手改与 diff)。 + /// + /// # Errors + /// + /// 序列化失败时返回 [`super::error::GameError::Json`]。 + pub fn to_json_string(&self) -> Result { + serde_json::to_string_pretty(self).map_err(|e| super::error::GameError::json("profiles", e)) + } +} diff --git a/crates/core/src/game/snapshot.rs b/crates/core/src/game/snapshot.rs new file mode 100644 index 0000000..c224721 --- /dev/null +++ b/crates/core/src/game/snapshot.rs @@ -0,0 +1,174 @@ +//! 不可变快照 + 单写者草稿(Snapshot / Draft)。 +//! +//! 依据:`DevDocs/analyse/01-architecture-overview.md` §3.3(HMCL 最值得抄的一处设计)、 +//! `DevDocs/PlanDoc/01-tech-stack-and-architecture.md` 公理 2。 +//! +//! 收益:**读端永远看到一整份自洽的数据**,不会出现"遍历到一半列表被改" +//! (HMCL 老式 `synchronized` 实现的典型 bug)。写端同一时刻只允许一个草稿, +//! 发布 = 指针交换,读端持有的旧快照继续有效直到自己放手。 +//! +//! **实现说明**:读路径持一把共享锁,只为把 `Arc` 克隆出来(纳秒级),随后所有遍历都在锁外; +//! 真正的无锁读需要 `arc-swap` 这类依赖,P0 不引入(core 依赖面越窄越好)。 +//! 与 HMCL 的另一处差异:**发布不需要 UI 线程桥接**——发布是纯指针交换, +//! 订阅通知属于 app 层(`crates/app`),core 不做线程模型假设。 + +use std::fmt; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, RwLock}; + +use super::error::GameError; + +/// 一份不可变快照:克隆只是 `Arc` 计数,内容只读。 +pub struct Snapshot { + data: Arc, +} + +impl Snapshot { + /// 用给定内容构造快照。 + pub fn new(data: T) -> Self { + Self { + data: Arc::new(data), + } + } + + /// 读内容。 + pub fn get(&self) -> &T { + &self.data + } + + /// 共享同一份内容的强引用。 + pub fn shared(&self) -> Arc { + Arc::clone(&self.data) + } +} + +impl Clone for Snapshot { + fn clone(&self) -> Self { + Self { + data: Arc::clone(&self.data), + } + } +} + +impl fmt::Debug for Snapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("Snapshot").field(&self.data).finish() + } +} + +impl PartialEq for Snapshot { + fn eq(&self, other: &Self) -> bool { + self.data == other.data + } +} + +/// 写权凭证:唯一的写权持有者,`drop` 即释放(发布与放弃都走它)。 +struct WriteToken<'a> { + store: &'a AtomicBool, +} + +impl<'a> WriteToken<'a> { + fn acquire(flag: &'a AtomicBool) -> Result { + if flag + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(GameError::DraftBusy); + } + Ok(Self { store: flag }) + } +} + +impl Drop for WriteToken<'_> { + fn drop(&mut self) { + self.store.store(false, Ordering::Release); + } +} + +/// 快照仓库:持有当前快照,串行化写入。 +pub struct Store { + current: RwLock>, + writing: AtomicBool, +} + +impl Store { + /// 以初始内容建立仓库。 + pub fn new(initial: T) -> Self { + Self { + current: RwLock::new(Arc::new(initial)), + writing: AtomicBool::new(false), + } + } + + /// 取当前快照(读路径:一次共享锁 + `Arc` 克隆,锁内不做别的)。 + pub fn snapshot(&self) -> Snapshot { + let guard = self.current.read().unwrap_or_else(|e| e.into_inner()); + Snapshot { + data: Arc::clone(&guard), + } + } + + /// 打开草稿(写会话)。同一时刻只允许一个,违反单写者约束返回 + /// [`GameError::DraftBusy`]。 + pub fn open_draft(&self) -> Result, GameError> { + let token = WriteToken::acquire(&self.writing)?; + let working = self.snapshot().get().clone(); + Ok(Draft { + store: self, + token, + working, + }) + } + + /// 整表替换(加载完成后一次性发布,仍受单写者约束)。 + pub fn replace(&self, value: T) -> Result, GameError> { + let mut draft = self.open_draft()?; + *draft.edit() = value; + Ok(draft.commit()) + } +} + +/// 草稿:持有写权的可变副本。 +/// +/// 未 [`commit`](Draft::commit) 就 `drop` 会**放弃改动并释放写权**(不会污染快照)。 +pub struct Draft<'a, T> { + store: &'a Store, + token: WriteToken<'a>, + working: T, +} + +impl Draft<'_, T> { + /// 读草稿内容。 + pub fn view(&self) -> &T { + &self.working + } + + /// 改草稿内容。 + pub fn edit(&mut self) -> &mut T { + &mut self.working + } + + /// 发布:把草稿内容换成仓库当前快照,并释放写权。 + pub fn commit(self) -> Snapshot { + let Draft { + store, + token, + working, + } = self; + let snapshot = Snapshot::new(working); + { + let mut guard = store.current.write().unwrap_or_else(|e| e.into_inner()); + *guard = snapshot.shared(); + } + drop(token); + snapshot + } +} + +impl fmt::Debug for Draft<'_, T> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Draft") + .field("working", &self.working) + .finish() + } +} diff --git a/crates/core/src/game/unlisted.rs b/crates/core/src/game/unlisted.rs new file mode 100644 index 0000000..6d939bc --- /dev/null +++ b/crates/core/src/game/unlisted.rs @@ -0,0 +1,54 @@ +//! 内嵌补充清单 `unlisted-versions.json`(205 条,编进二进制)。 +//! +//! 官方清单本来就有 `old_beta`/`old_alpha`,但**不完整**:大量 alpha/classic 构建、 +//! `pending` 实验快照、`_unobfuscated` 版本从未进官方清单。 +//! HMCL 的做法是把社区维护的补充清单编进发行包,换取"零网络依赖也能列出旧版", +//! 依据见 `DevDocs/analyse/03-version-download.md` §2.3。 +//! +//! 数据来源:HMCL `HMCLCore/src/main/resources/assets/game/unlisted-versions.json` +//! (实测 205 条:`old_alpha 88` / `old_beta 16` / `snapshot 70` / `pending 20` / +//! `unobfuscated 11`),原样拷进 `game/data/` 由 `include_str!` 内嵌。 +//! +//! **与 HMCL 的差异**:同 id 冲突时我们让**官方条目胜出**。理由是官方 v2 清单带 +//! `sha1`/`size` 且更新鲜;HMCL 因先插 unlisted 进 `TreeSet` 而让社区条目抢了同 id, +//! 会把官方 URL 换成社区镜像 URL(analyse/03 §2.5)。补充清单只用来补官方缺口。 +//! 后续计划(analyse/03 §2.3 建议):把该清单改为可远程更新的数据 + ETag 缓存, +//! 内嵌版本降级为离线回退。 + +use std::sync::OnceLock; + +use super::version::{RemoteVersion, VersionList}; + +/// 内嵌清单的原始 JSON(`crates/core/src/game/data/unlisted-versions.json`)。 +const EMBEDDED_UNLISTED_JSON: &str = include_str!("data/unlisted-versions.json"); + +/// 内嵌清单预期的条目数,用于数据完整性自检与文档口径一致。 +pub const EMBEDDED_VERSION_COUNT: usize = 205; + +static UNLISTED: OnceLock = OnceLock::new(); + +/// 内嵌补充清单(进程内只解析一次)。 +/// +/// # Panics +/// +/// 内嵌数据是编译期常量,可解析性由 `tests/game/unlisted.rs` 守门; +/// 若构建产物被破坏导致解析失败则 panic——这是构建错误,不是运行时输入错误。 +pub fn versions() -> &'static VersionList { + UNLISTED.get_or_init(|| { + VersionList::from_json_str(EMBEDDED_UNLISTED_JSON) + .expect("内嵌 unlisted-versions.json 必须可解析") + }) +} + +/// 内嵌清单的条目(借用)。 +pub fn entries() -> &'static [RemoteVersion] { + &versions().versions +} + +/// 把内嵌补充清单合并进给定清单,返回新增条目数。 +/// +/// 同 id 时**保留已有条目**(官方胜出,理由见模块注释);新条目按数据文件原顺序追加, +/// 调用方如需展示顺序再调用 [`VersionList::sort_newest_first`]。 +pub fn merge_into(target: &mut VersionList) -> usize { + target.merge_missing(entries().iter().cloned()) +} diff --git a/crates/core/src/game/version.rs b/crates/core/src/game/version.rs new file mode 100644 index 0000000..bdd6c91 --- /dev/null +++ b/crates/core/src/game/version.rs @@ -0,0 +1,238 @@ +//! 版本清单(`version_manifest_v2.json`)模型、分类与合并。 +//! +//! 设计依据:`DevDocs/analyse/03-version-download.md` §2(清单处理)、§2.4(三层分类)、 +//! §2.5(排序与相等)、§2.2(v2 的 `sha1`/`size` 校验字段)。 +//! +//! 与 HMCL 的差异(有意为之): +//! - **直接用 v2 端点**并保留 `sha1`/`size`,让"清单被镜像篡改/截断"在下载阶段就暴露; +//! - 分类枚举保留 `pending`/`unobfuscated`/`modified`,不再压进 `OLD` 桶后又让过滤器不一致; +//! - 未知 `type` 值不丢弃整条记录,落到 [`ReleaseType::Unknown`](对应 HMCL 的 null)。 + +use std::fmt; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use super::error::GameError; + +/// Mojang 官方清单 v2 端点(带 `sha1`/`size`)。 +pub const VERSION_MANIFEST_V2_URL: &str = + "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; + +/// 版本 JSON 里 `type` 字段的取值,见 analyse/03 §2.4。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub enum ReleaseType { + /// 正式版。 + Release, + /// 快照(含愚人节、`pending` 之外的实验构建)。 + Snapshot, + /// 远古 beta。 + OldBeta, + /// 远古 alpha(含 classic、`rd-`/`inf-` 系列归此)。 + OldAlpha, + /// `pending`:已推送 JSON 但未正式发布的构建。 + Pending, + /// `_unobfuscated` 形式的反混淆构建(官方 CDN 有、普通清单没有)。 + Unobfuscated, + /// 第三方/魔改清单自定义分类。 + Modified, + /// 未知取值——保留记录但无法分类(HMCL 里是 `null`)。 + #[default] + Unknown, +} + +impl ReleaseType { + /// 从 JSON 原始字符串解析(未知值落到 `Unknown`,不报错)。 + pub fn from_json_str(raw: &str) -> Self { + match raw { + "release" => Self::Release, + "snapshot" => Self::Snapshot, + "old_beta" => Self::OldBeta, + "old_alpha" => Self::OldAlpha, + "pending" => Self::Pending, + "unobfuscated" => Self::Unobfuscated, + "modified" => Self::Modified, + _ => Self::Unknown, + } + } + + /// 回到 JSON 原始字符串。 + pub fn as_json_str(self) -> &'static str { + match self { + Self::Release => "release", + Self::Snapshot => "snapshot", + Self::OldBeta => "old_beta", + Self::OldAlpha => "old_alpha", + Self::Pending => "pending", + Self::Unobfuscated => "unobfuscated", + Self::Modified => "modified", + Self::Unknown => "unknown", + } + } + + /// UI 侧的归类(analyse/03 §2.4 的五态,去掉 `uncategorized` 之外的分歧)。 + pub fn category(self) -> VersionCategory { + match self { + Self::Release => VersionCategory::Release, + Self::Snapshot => VersionCategory::Snapshot, + Self::OldBeta | Self::OldAlpha | Self::Modified => VersionCategory::Old, + Self::Pending => VersionCategory::Pending, + Self::Unobfuscated => VersionCategory::Unobfuscated, + Self::Unknown => VersionCategory::Uncategorized, + } + } +} + +impl fmt::Display for ReleaseType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_json_str()) + } +} + +impl Serialize for ReleaseType { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_json_str()) + } +} + +impl<'de> Deserialize<'de> for ReleaseType { + fn deserialize>(deserializer: D) -> Result { + let raw = String::deserialize(deserializer)?; + Ok(Self::from_json_str(&raw)) + } +} + +/// 版本列表的 UI 归类(对应 HMCL `ComponentRemoteVersion.Type`,见 analyse/03 §2.4)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum VersionCategory { + /// 正式版。 + Release, + /// 快照。 + Snapshot, + /// 远古版(old_beta / old_alpha / modified)。 + Old, + /// 未正式发布的 pending 构建。 + Pending, + /// 反混淆构建。 + Unobfuscated, + /// 无法归类。 + Uncategorized, +} + +/// 清单里的 `latest` 对象。Mojang 用它标注"当前最新正式版/快照",与条目列表无关。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LatestVersions { + /// 最新正式版 id。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub release: Option, + /// 最新快照 id。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snapshot: Option, +} + +/// 版本清单里的一条记录。 +/// +/// `sha1`/`size` 只在 v2 端点出现(v1 与内嵌 unlisted 清单没有),因此都是可选的。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoteVersion { + /// 版本 id(`1.20.1`、`12w01a`、`a1.1.1`…)。 + pub id: String, + /// 分类。 + #[serde(rename = "type", default)] + pub version_type: ReleaseType, + /// 该版本 JSON 的下载地址。 + pub url: String, + /// 该版本 JSON 的最后修改时间(ISO-8601 字符串)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub time: Option, + /// 该版本的发布时刻(ISO-8601 字符串,排序键)。 + #[serde( + rename = "releaseTime", + default, + skip_serializing_if = "Option::is_none" + )] + pub release_time: Option, + /// 版本 JSON 的 SHA-1(v2 才有)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha1: Option, + /// 版本 JSON 的字节数(v2 才有)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +impl RemoteVersion { + /// 该条目是否带 v2 的校验信息。 + pub fn is_verifiable(&self) -> bool { + self.sha1.is_some() + } +} + +/// 一份版本清单(官方 `version_manifest_v2.json` 或内嵌 unlisted 清单)。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct VersionList { + /// `latest` 对象;unlisted 清单没有。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latest: Option, + /// 条目列表。 + #[serde(default)] + pub versions: Vec, +} + +impl VersionList { + /// 解析清单 JSON。 + pub fn from_json_str(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| GameError::json("version_manifest", e)) + } + + /// 序列化回 JSON(便于缓存落盘/测试对拍)。 + pub fn to_json_string(&self) -> Result { + serde_json::to_string(self).map_err(|e| GameError::json("version_manifest", e)) + } + + /// 按 id 查条目。 + pub fn find(&self, id: &str) -> Option<&RemoteVersion> { + self.versions.iter().find(|v| v.id == id) + } + + /// 已存在的版本 id 数。 + pub fn len(&self) -> usize { + self.versions.len() + } + + /// 是否为空清单。 + pub fn is_empty(&self) -> bool { + self.versions.is_empty() + } + + /// 合并补充清单:**同 id 时先插入者胜**(analyse/03 §2.5 的 `TreeSet` 语义)。 + /// + /// 调用方应先合并 unlisted 再合并官方清单,这样社区清单里那些"官方没有/官方 URL 更差" + /// 的条目会被保留。返回新插入的条目数。 + pub fn merge_missing(&mut self, extra: impl IntoIterator) -> usize { + let mut added = 0; + for item in extra { + if self.find(&item.id).is_some() { + continue; + } + self.versions.push(item); + added += 1; + } + added + } + + /// 按发布时间**倒序**排列;同时间再按 id 倒序(analyse/03 §2.5)。 + pub fn sort_newest_first(&mut self) { + self.versions.sort_by(|a, b| { + let at = a.release_time.as_deref().unwrap_or_default(); + let bt = b.release_time.as_deref().unwrap_or_default(); + bt.cmp(at).then_with(|| b.id.cmp(&a.id)) + }); + } + + /// 按 UI 归类过滤(保持当前顺序)。 + pub fn filter_category(&self, category: VersionCategory) -> Vec<&RemoteVersion> { + self.versions + .iter() + .filter(|v| v.version_type.category() == category) + .collect() + } +} diff --git a/crates/core/src/launch/adapter.rs b/crates/core/src/launch/adapter.rs new file mode 100644 index 0000000..0b4729f --- /dev/null +++ b/crates/core/src/launch/adapter.rs @@ -0,0 +1,368 @@ +//! game 快照 → [`LaunchRequest`] 的适配层。 +//! +//! launch 不 import `crate::game` 的**清单类型**(避免对 M14 的模型产生编译期耦合,见 +//! `DevDocs/SubAgentsTalk/launch-接口约定.md` §6):本模块只吃 game 侧**已经算好**的纯数据—— +//! `arguments.expand(&env)` 的展开结果(rules 已在 game 求值)、库文件路径、目录布局, +//! 把它们装配成命令行组装所需的结构。 +//! +//! 唯一例外是 [`crate::game::capability`] 里的 `&str → bool` 纯谓词:能力位的判据属于 +//! 版本 JSON 的语义、必须只有一处实现,抄第二份必然漂移。它不带任何 game 结构体。 +//! +//! 三个职责: +//! 1. classpath 过滤 + **缺失库显式记录**(analyse/02 §8.4-3:不再静默跳过,否则只能靠 +//! `NoClassDefFoundError` 事后猜); +//! 2. features 开关的派生(`has_custom_resolution` / `has_quick_plays_support`); +//! 3. 组装 `LaunchRequest`(内存/账户/加载器/分辨率/QuickPlay/代理/日志配置)。 + +use std::path::PathBuf; + +use crate::game::capability::library_names_have_vulkan; +use crate::launch::argument::ArgumentEntry; +use crate::launch::error::MissingComponent; +use crate::launch::game_version::GameVersion; +use crate::launch::natives::{NativeJar, NativesEra, NativesPlan}; +use crate::launch::request::{ + AuthPlaceholders, FeatureSet, InstanceInfo, JavaSelection, LaunchDirs, LaunchRequest, + LauncherInfo, Log4jOptions, ManifestArgs, MemoryOptions, ModLoaderKind, OptimizationOptions, + QuickPlay, RendererOptions, Resolution, SocksProxy, UserArgs, +}; + +/// 一个待进 classpath 的库(game 侧已按环境过滤)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LibraryInput { + /// 落盘路径(`GameLayout::library(coord)` 的结果)。 + pub path: PathBuf, + /// 坐标或展示名(缺失时报错用,例 `org.lwjgl:lwjgl:3.3.1`)。 + pub name: String, +} + +impl LibraryInput { + /// 构造。 + pub fn new(path: impl Into, name: impl Into) -> Self { + Self { + path: path.into(), + name: name.into(), + } + } +} + +/// 版本快照里 launch 需要的部分(全部是纯数据,不依赖 game 类型)。 +#[derive(Debug, Clone)] +pub struct SnapshotInput<'a> { + /// 版本 id(`versions//`)。 + pub version_id: &'a str, + /// 展示名(缺省用 version_id)。 + pub version_name: Option<&'a str>, + /// 版本类型(`release` / `snapshot`)。 + pub version_type: Option<&'a str>, + /// 游戏版本串(门限判断用;缺省用 version_id)。 + pub game_version: Option<&'a str>, + /// 主类。 + pub main_class: Option<&'a str>, + /// `arguments.jvm` 展开后的 token。 + pub jvm_arguments: &'a [String], + /// `arguments.game` 展开后的 token。 + pub game_arguments: &'a [String], + /// 旧版 `minecraftArguments` 整串。 + pub legacy_arguments: Option<&'a str>, + /// 待进 classpath 的库(已过滤环境、不含 native)。 + pub libraries: &'a [LibraryInput], + /// natives 分类器构件(`natives` 表命中的 jar;1.19+ 为空,natives jar 在 classpath 里)。 + pub natives: &'a [NativeJar], + /// 主 jar 路径。 + pub primary_jar: PathBuf, + /// 运行目录(`${game_directory}`)。 + pub game_dir: PathBuf, + /// 资产索引 id。 + pub assets_index: Option<&'a str>, + /// 原生库目录。 + pub natives_dir: PathBuf, + /// 库根目录。 + pub library_dir: PathBuf, + /// 资产根。 + pub assets_root: PathBuf, + /// 虚拟资产目录(`${game_assets}`;无虚拟化时等于 assets_root)。 + pub game_assets: PathBuf, +} + +impl<'a> SnapshotInput<'a> { + /// 最小构造:只给版本 id 与主类,其余留空(测试/CLI 组装用)。 + pub fn minimal(version_id: &'a str, main_class: &'a str) -> Self { + Self { + version_id, + version_name: None, + version_type: None, + game_version: None, + main_class: Some(main_class), + jvm_arguments: &[], + game_arguments: &[], + legacy_arguments: None, + libraries: &[], + natives: &[], + primary_jar: PathBuf::new(), + game_dir: PathBuf::new(), + assets_index: None, + natives_dir: PathBuf::new(), + library_dir: PathBuf::new(), + assets_root: PathBuf::new(), + game_assets: PathBuf::new(), + } + } + + fn resolved_version(&self) -> GameVersion { + GameVersion::parse(self.game_version.unwrap_or(self.version_id)) + } +} + +/// 适配选项(账户/内存/加载器/一键进入等运行期选择)。 +#[derive(Debug, Clone, Default)] +pub struct AdapterOptions { + /// Java 运行时。 + pub java: JavaSelection, + /// 内存。 + pub memory: MemoryOptions, + /// 账户占位符。 + pub auth: AuthPlaceholders, + /// 启动器名(`${launcher_name}`)。 + pub launcher_name: String, + /// 启动器版本(`${launcher_version}`)。 + pub launcher_version: String, + /// 模组加载器。 + pub mod_loader: ModLoaderKind, + /// 分辨率(`Some` 时自动打开 `has_custom_resolution`)。 + pub resolution: Option, + /// 全屏。 + pub fullscreen: bool, + /// 一键进入。 + pub quick_play: Option, + /// SOCKS 代理。 + pub proxy: Option, + /// 图形后端。 + pub graphics_backend: Option, + /// log4j 配置文件(launch 侧注入)。 + pub log4j_config: Option, + /// `-Dfile.encoding` 指定值。 + pub encoding: Option, + /// 界面语言。 + pub language: String, + /// 额外环境变量。 + pub environment: Vec<(String, String)>, + /// JVM 自动优化。 + pub optimization: OptimizationOptions, + /// 渲染器开关。 + pub renderer: RendererOptions, + /// 用户自定义参数。 + pub user: UserArgs, + /// 是否要求库文件真实存在。 + /// + /// - `true`(默认,严格):缺库/缺主 jar → 记 `AdapterWarning::MissingLibrary` 并填进 + /// `missing_components`,组装前显式失败(analyse/02 §8.4-3); + /// - `false`(宽松,HMCL 行为):**不检查存在性、不跳过、不告警**,库照进 classpath, + /// 交给运行时(`NoClassDefFoundError`)——仅在用户明确选“跳过校验”时用。 + pub check_classpath: bool, +} + +impl AdapterOptions { + /// 默认严格模式:库必须存在(analyse/02 §8.4-3)。 + pub fn strict() -> Self { + Self { + check_classpath: true, + language: "zh-CN".to_string(), + ..Self::default() + } + } +} + +/// 适配告警(非致命)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AdapterWarning { + /// 库文件缺失(严格模式下同时会变成 `missing_components` → 组装失败)。 + MissingLibrary { + /// 坐标/展示名。 + name: String, + /// 期望路径。 + path: PathBuf, + }, + /// 既没有展开后的游戏参数,也没有 legacy 整串。 + MissingGameArguments, + /// 没有资产索引 id(`${assets_index_name}` 会是空串)。 + MissingAssetsIndex, + /// 主类缺失(组装一定会报错,这里提前给出中文提示)。 + MissingMainClass, + /// natives 分类器 jar 缺失(严格模式下同时会变成 `missing_components` → 组装失败)。 + MissingNativeJar { + /// 坐标 + 分类器。 + name: String, + /// 期望路径。 + path: PathBuf, + }, +} + +/// 适配结果。 +#[derive(Debug, Clone)] +pub struct AdapterOutput { + /// 组装输入。 + pub request: LaunchRequest, + /// 告警。 + pub warnings: Vec, + /// 实际进 classpath 的条目数(库 + 主 jar)。 + pub classpath_entries: usize, + /// 缺失库清单。 + pub missing: Vec, +} + +/// 把快照 + 选项装配成 [`LaunchRequest`]。 +pub fn build_request(snapshot: &SnapshotInput<'_>, options: AdapterOptions) -> AdapterOutput { + let mut warnings = Vec::new(); + let mut missing = Vec::new(); + let mut classpath = Vec::new(); + for library in snapshot.libraries { + if options.check_classpath && !library.path.is_file() { + warnings.push(AdapterWarning::MissingLibrary { + name: library.name.clone(), + path: library.path.clone(), + }); + missing.push(MissingComponent::at_path( + library.name.clone(), + library.path.clone(), + )); + continue; + } + if options.check_classpath && classpath.contains(&library.path) { + continue; + } + classpath.push(library.path.clone()); + } + if !snapshot.primary_jar.as_os_str().is_empty() + && options.check_classpath + && !snapshot.primary_jar.is_file() + { + let name = snapshot + .primary_jar + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "client.jar".to_string()); + warnings.push(AdapterWarning::MissingLibrary { + name: name.clone(), + path: snapshot.primary_jar.clone(), + }); + missing.push(MissingComponent::at_path( + name, + snapshot.primary_jar.clone(), + )); + } + if snapshot.main_class.is_none() { + warnings.push(AdapterWarning::MissingMainClass); + } + if snapshot.assets_index.is_none() { + warnings.push(AdapterWarning::MissingAssetsIndex); + } + if snapshot.game_arguments.is_empty() && snapshot.legacy_arguments.is_none() { + warnings.push(AdapterWarning::MissingGameArguments); + } + // natives jar 缺失同样属「组件缺失」:解压期才发现会只剩一句 UnsatisfiedLinkError。 + for native in snapshot.natives { + if options.check_classpath && !native.path.is_file() { + warnings.push(AdapterWarning::MissingNativeJar { + name: native.name.clone(), + path: native.path.clone(), + }); + missing.push(MissingComponent::at_path( + native.name.clone(), + native.path.clone(), + )); + } + } + + let version = snapshot.resolved_version(); + let features = FeatureSet { + has_custom_resolution: options.resolution.is_some(), + is_demo_user: false, + has_quick_plays_support: version.is_at_least("1.20"), + }; + let manifest = ManifestArgs { + main_class: snapshot.main_class.map(str::to_string), + jvm: entries(snapshot.jvm_arguments), + game: entries(snapshot.game_arguments), + legacy_arguments: snapshot.legacy_arguments.map(str::to_string), + has_vulkan_backend: library_names_have_vulkan( + snapshot + .libraries + .iter() + .map(|library| library.name.as_str()), + ), + }; + + let classpath_entries = + classpath.len() + usize::from(!snapshot.primary_jar.as_os_str().is_empty()); + let era = NativesEra::detect(snapshot.jvm_arguments.len(), snapshot.natives.len()); + let natives = NativesPlan { + jars: snapshot.natives.to_vec(), + era, + custom: false, + }; + let request = LaunchRequest { + instance: InstanceInfo { + id: snapshot.version_id.to_string(), + name: snapshot + .version_name + .unwrap_or(snapshot.version_id) + .to_string(), + version, + version_type: snapshot.version_type.unwrap_or("release").to_string(), + }, + launcher: LauncherInfo { + name: options.launcher_name, + version: options.launcher_version, + }, + java: options.java, + memory: options.memory, + manifest, + dirs: LaunchDirs { + game_dir: snapshot.game_dir.clone(), + assets_root: snapshot.assets_root.clone(), + game_assets: snapshot.game_assets.clone(), + assets_index: snapshot.assets_index.unwrap_or("legacy").to_string(), + natives_dir: snapshot.natives_dir.clone(), + library_dir: snapshot.library_dir.clone(), + primary_jar: snapshot.primary_jar.clone(), + }, + natives, + auth: options.auth, + user: options.user, + features, + resolution: options.resolution, + quick_play: options.quick_play, + proxy: options.proxy, + graphics_backend: options.graphics_backend, + fullscreen: options.fullscreen, + mod_loader: options.mod_loader, + log4j: Log4jOptions { + enabled: true, + config_file: options.log4j_config, + debug: false, + }, + renderer: options.renderer, + optimization: options.optimization, + encoding: options.encoding, + language: options.language, + classpath, + missing_components: missing.clone(), + extra_jvm: Vec::new(), + ..LaunchRequest::default() + }; + AdapterOutput { + request, + warnings, + classpath_entries, + missing, + } +} + +fn entries(tokens: &[String]) -> Vec { + if tokens.is_empty() { + Vec::new() + } else { + vec![ArgumentEntry::plain(tokens.iter().cloned())] + } +} diff --git a/crates/core/src/launch/args_table.rs b/crates/core/src/launch/args_table.rs new file mode 100644 index 0000000..0cc9370 --- /dev/null +++ b/crates/core/src/launch/args_table.rs @@ -0,0 +1,253 @@ +//! 命令行组装总表:`analyse/02` §4.1 的 33 项顺序表的 Rust 化。 +//! +//! 顺序本身有语义(后加的默认值会被先加的显式值抑制): +//! +//! | # | 内容 | 本文件落点 | +//! |---|------|-----------| +//! | 1~3 | `nice` / 用户包装器 / java 可执行文件 | [`assemble`] | +//! | 4 | 最高优先级 JVM 参数(含 `@argfile` 展开) | [`assemble`] | +//! | 5~7 | `-Xmx`/`-Xms`、Metaspace、用户 JVM 参数(按默认值) | [`assemble`] | +//! | 8~17 | 编码、Log4j、主 jar、代理、自动优化、版本开关、FML、渲染器 | `jvm_args` | +//! | 18~19 | classpath 计算与占位符表(`${classpath}` 等 26 项) | [`assemble`] + `placeholders` | +//! | 20~32 | 清单 JVM 参数、主类、游戏参数、QuickPlay、代理、图形后端、用户参数 | `game_args` | +//! | 33 | 删除被禁止参数(`-Xincgc`) | [`assemble`] | +//! +//! 组装是**纯函数**:给定 `LaunchRequest` + `PlatformCtx` 一定得到同样的命令行, +//! 因此可以整体快照测试(`crates/core/tests/launch/cmdline_snapshot.rs`)。 + +use std::fmt; +use std::path::PathBuf; + +use crate::launch::command_builder::{quote_for_display, CommandBuilder}; +use crate::launch::defaults; +use crate::launch::error::{AssemblyError, Result}; +use crate::launch::game_args; +use crate::launch::jvm_args; +use crate::launch::placeholders::{join_classpath, PlaceholderMap}; +use crate::launch::platform::PlatformCtx; +use crate::launch::request::{LaunchRequest, MemoryOptions}; + +/// 组装过程中的“非致命”告警(不中断启动,但要告诉用户我们改了什么)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AssemblyWarning { + /// 注入了 log4j 参数却没有配置文件。 + Log4jConfigMissing, + /// 用户自己写了代理参数,我们跳过自动代理参数。 + ProxyJvmArgsSkipped, + /// `-Xms` 大于 `-Xmx`,已忽略 `-Xms`。 + MemoryMinIgnored { + /// 请求的最小堆。 + min_mb: u32, + /// 请求的最大堆。 + max_mb: u32, + }, + /// 版本不支持 QuickPlay,已退化成 `--server/--port`。 + QuickPlayDegraded { + /// 原始地址。 + address: String, + }, + /// 服务器地址非法,QuickPlay 被丢弃。 + QuickPlayAddressInvalid { + /// 原始地址。 + address: String, + }, + /// 版本不支持 QuickPlay 且无法退化(存档/Realms)。 + QuickPlayUnsupported { + /// 游戏版本。 + version: String, + }, + /// 版本用不了 `--graphicsBackend`,已丢弃。 + /// + /// 两种来源:可解析版本低于 `26.2-snapshot-2`;或周快照(`26w14a`)但版本库表里 + /// 没有 `lwjgl-vulkan`(见 `game_args::graphics_backend_allowed`)。 + GraphicsBackendSkipped { + /// 游戏版本。 + version: String, + }, +} + +impl fmt::Display for AssemblyWarning { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Log4jConfigMissing => f.write_str("log4j 注入已启用但缺少配置文件,已跳过"), + Self::ProxyJvmArgsSkipped => f.write_str("用户已指定代理参数,跳过自动代理 JVM 参数"), + Self::MemoryMinIgnored { min_mb, max_mb } => { + write!(f, "-Xms{min_mb}m 大于 -Xmx{max_mb}m,已忽略 -Xms") + } + Self::QuickPlayDegraded { address } => { + write!(f, "版本不支持 QuickPlay,已退化为 --server {address}") + } + Self::QuickPlayAddressInvalid { address } => { + write!(f, "服务器地址非法,已丢弃 QuickPlay:{address}") + } + Self::QuickPlayUnsupported { version } => { + write!(f, "版本 {version} 不支持该 QuickPlay 目标,已丢弃") + } + Self::GraphicsBackendSkipped { version } => { + write!(f, "版本 {version} 不支持 --graphicsBackend,已丢弃") + } + } + } +} + +/// 组装结果。 +#[derive(Debug, Clone)] +pub struct AssembledCommand { + /// 实际要 spawn 的程序(通常是 java,带包装器时是包装器)。 + pub program: PathBuf, + /// 参数(不含 `program`)。 + pub args: Vec, + /// 完整 classpath(库 + 主 jar 末位)。 + pub classpath: Vec, + /// 日志解码编码(决定 pump 用什么解码子进程输出)。 + pub encoding: String, + /// 内存设置(与 `-Xmx`/`-Xms` 一致)。 + pub memory: MemoryOptions, + /// 环境变量(实例标识/渲染/加载器/用户自定义)。 + pub env: Vec<(String, String)>, + /// 组装告警。 + pub warnings: Vec, + /// 主类。 + pub main_class: String, +} + +impl AssembledCommand { + /// 完整 argv(`program` 在第一个)。 + pub fn argv(&self) -> Vec { + let mut argv = Vec::with_capacity(self.args.len() + 1); + argv.push(self.program.display().to_string()); + argv.extend(self.args.iter().cloned()); + argv + } + + /// 人读形式(含空格的参数加引号),用于日志与快照测试。 + pub fn display(&self) -> String { + let mut parts = Vec::with_capacity(self.args.len() + 1); + parts.push(quote_for_display(&self.program.display().to_string())); + for arg in &self.args { + parts.push(quote_for_display(arg)); + } + parts.join(" ") + } +} + +/// 组装完整命令行。 +pub fn assemble(req: &LaunchRequest, ctx: &PlatformCtx) -> Result { + let main_class = req + .manifest + .main_class + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or(AssemblyError::MissingMainClass)? + .to_string(); + if req.java.binary.as_os_str().is_empty() { + return Err(AssemblyError::EmptyJavaPath.into()); + } + if !req.missing_components.is_empty() { + return Err(AssemblyError::MissingComponents(req.missing_components.clone()).into()); + } + let classpath = effective_classpath(req); + if classpath.is_empty() { + return Err(AssemblyError::EmptyClasspath.into()); + } + + // 第 19 项:占位符表(`${classpath}` 用最终 classpath,主 jar 已在末位)。 + let mut placeholders = PlaceholderMap::from_request(req, ctx); + placeholders.insert( + "classpath", + join_classpath(&classpath, ctx.path_separator()), + ); + + // 第 1~3 项:优先级包装 → 用户包装器 → java 可执行文件。 + let mut head: Vec = Vec::new(); + if let Some(nice) = req.nice { + if ctx.os.is_posix() { + head.push("nice".to_string()); + head.push("-n".to_string()); + head.push(nice.to_string()); + } + } + head.extend(req.wrapper.iter().cloned()); + head.push(req.java.binary.display().to_string()); + let program = PathBuf::from(head.remove(0)); + + let mut builder = CommandBuilder::with_placeholders(placeholders); + for token in &head { + builder.add_without_parsing(token.clone()); + } + + // 第 4 项:最高优先级 JVM 参数(原样追加,且登记 @argfile 展开结果)。 + builder.add_all_without_parsing_and_read_external( + &req.user.override_jvm, + &crate::launch::command_builder::read_argfile, + ); + + let mut warnings = Vec::new(); + + // 第 5 项:`-Xmx` / `-Xms`(`-Xms` 必须 ≤ `-Xmx`)。 + if let Some(max_mb) = req.memory.max_mb { + let _ = builder.add_default("-Xmx", &format!("{max_mb}m")); + } + if let Some(min_mb) = req.memory.min_mb { + match req.memory.max_mb { + Some(max_mb) if min_mb > max_mb => { + warnings.push(AssemblyWarning::MemoryMinIgnored { min_mb, max_mb }); + } + _ => { + let _ = builder.add_default("-Xms", &format!("{min_mb}m")); + } + } + } + + // 第 6 项:Metaspace(Java ≥8)或 PermSize(更早)。 + if req.java.major >= 8 { + let _ = builder.add_default("-XX:MetaspaceSize=", "64m"); + } else { + let _ = builder.add_default("-XX:PermSize=", "64m"); + } + + // 第 7 项:用户 JVM 参数——整体按“默认值”处理。 + builder.add_all_default(&req.user.jvm); + + // 第 8~17 项。 + let (encoding, jvm_warnings) = jvm_args::apply_jvm_arguments(req, ctx, &mut builder); + warnings.extend(jvm_warnings); + + // 第 20~32 项。 + warnings.extend(game_args::apply_game_arguments(req, ctx, &mut builder)); + + // 第 33 项:删除禁用参数。 + builder.remove_forbidden(&defaults::forbidden_args(req.java.major)); + + let env = defaults::environment(req, ctx.os); + Ok(AssembledCommand { + program, + args: builder.into_args(), + classpath, + encoding, + memory: req.memory, + env, + warnings, + main_class, + }) +} + +/// 最终 classpath:库按给定顺序去重,主 jar 永远追加在末位(Mojang 惯例, +/// 影响同名类优先级,`analyse/02` §4.5)。 +pub fn effective_classpath(req: &LaunchRequest) -> Vec { + let mut classpath: Vec = Vec::new(); + let candidates = req + .classpath + .iter() + .chain(std::iter::once(&req.dirs.primary_jar)); + for path in candidates { + if path.as_os_str().is_empty() { + continue; + } + if !classpath.contains(path) { + classpath.push(path.clone()); + } + } + classpath +} diff --git a/crates/core/src/launch/argument.rs b/crates/core/src/launch/argument.rs new file mode 100644 index 0000000..351ba52 --- /dev/null +++ b/crates/core/src/launch/argument.rs @@ -0,0 +1,205 @@ +//! 清单参数模型:`arguments.jvm` / `arguments.game` 的 `{rules, value}` 求值。 +//! +//! 平移自 `analyse/02` §4.7:`Argument` 是 `StringArgument | RuledArgument`, +//! `CompatibilityRule` 默认 `DISALLOW`、按顺序取最后一个命中的 action; +//! `os` 不匹配的规则整体“无效”(不是“不满足”),`features` 必须逐项相等。 + +use crate::launch::request::FeatureSet; + +/// 规则动作。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuleAction { + /// 允许。 + Allow, + /// 禁止。 + Disallow, +} + +/// `rules.os` 子句。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OsRule { + /// `windows` / `osx` / `linux`。 + pub name: Option, + /// `x86` / `x86_64` / `arm64`。 + pub arch: Option, + /// 系统版本模式(Mojang 用正则,这里只做前缀/子串匹配)。 + pub version: Option, +} + +/// 单条规则。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rule { + /// 命中后的动作。 + pub action: RuleAction, + /// 系统条件。 + pub os: Option, + /// 特性条件(逐项相等才算命中)。 + pub features: Vec<(String, bool)>, +} + +impl Rule { + /// 允许规则。 + pub fn allow() -> Self { + Self { + action: RuleAction::Allow, + os: None, + features: Vec::new(), + } + } + + /// 禁止规则。 + pub fn disallow() -> Self { + Self { + action: RuleAction::Disallow, + os: None, + features: Vec::new(), + } + } + + /// 加系统条件。 + pub fn with_os_name(mut self, name: impl Into) -> Self { + let mut os = self.os.take().unwrap_or_default(); + os.name = Some(name.into()); + self.os = Some(os); + self + } + + /// 加特性条件。 + pub fn with_feature(mut self, name: impl Into, expected: bool) -> Self { + self.features.push((name.into(), expected)); + self + } + + /// 求值:`Some(true)` 命中且允许,`Some(false)` 命中且禁止,`None` 不适用(规则无效)。 + pub fn evaluate(&self, ctx: &RuleContext<'_>) -> Option { + if let Some(os) = &self.os { + if let Some(name) = &os.name { + if name != ctx.os_name { + return None; + } + } + if let Some(arch) = &os.arch { + if !arch_matches(arch, ctx.os_arch) { + return None; + } + } + if let Some(pattern) = &os.version { + if !version_matches(pattern, ctx.os_version) { + return None; + } + } + } + for (name, expected) in &self.features { + if ctx.features.get(name) != Some(*expected) { + return None; + } + } + Some(self.action == RuleAction::Allow) + } +} + +/// 一个参数项:一组规则 + 一组取值(取值里可含 `${...}` 占位符)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArgumentEntry { + /// 规则(空表示无条件适用)。 + pub rules: Vec, + /// 取值(每个元素是一个独立命令行 token)。 + pub values: Vec, +} + +impl ArgumentEntry { + /// 无条件参数项。 + pub fn plain(values: impl IntoIterator>) -> Self { + Self { + rules: Vec::new(), + values: values.into_iter().map(Into::into).collect(), + } + } + + /// 带规则参数项。 + pub fn ruled(rules: Vec, values: impl IntoIterator>) -> Self { + Self { + rules, + values: values.into_iter().map(Into::into).collect(), + } + } + + /// 是否在当前环境下适用。 + pub fn applies(&self, ctx: &RuleContext<'_>) -> bool { + if self.rules.is_empty() { + return true; + } + let mut allowed = false; + for rule in &self.rules { + if let Some(action) = rule.evaluate(ctx) { + allowed = action; + } + } + allowed + } +} + +/// rules 求值上下文。 +#[derive(Debug, Clone, Copy)] +pub struct RuleContext<'a> { + /// `rules.os.name` 取值。 + pub os_name: &'a str, + /// `rules.os.arch` 取值。 + pub os_arch: &'a str, + /// `rules.os.version` 取值。 + pub os_version: &'a str, + /// 特性开关。 + pub features: &'a FeatureSet, +} + +impl<'a> RuleContext<'a> { + /// 构造。 + pub fn new( + os_name: &'a str, + os_arch: &'a str, + os_version: &'a str, + features: &'a FeatureSet, + ) -> Self { + Self { + os_name, + os_arch, + os_version, + features, + } + } +} + +fn arch_matches(rule_arch: &str, actual: &str) -> bool { + if rule_arch == actual { + return true; + } + // Mojang 用 "x86" 表示 32 位,我们的实际值可能是 "i686" 之类。 + matches!( + (rule_arch, actual), + ("x86", "i686") | ("x86_64", "amd64") | ("arm64", "aarch64") + ) +} + +/// 只做保守匹配:支持 `^10\.` / `^10\.15` 这类前缀模式与纯子串,无法识别的模式按“不匹配”处理 +/// (宁可少加一个参数,也不要给错误平台加参数)。 +/// +/// 处理:去掉前置 `^` 与结尾 `$`、`\.` 反转义成 `.`、去掉结尾的点(`^10\.` 的语义就是 +/// “以 `10.` 开头”,点本身不参与前缀比较)。含 `[ ( { | * + ?` 等真需要正则的模式一律不匹配。 +fn version_matches(pattern: &str, actual: &str) -> bool { + if actual.is_empty() { + return false; + } + let trimmed = pattern.trim(); + let stripped = trimmed.strip_prefix('^').unwrap_or(trimmed); + if stripped.contains(['[', '(', '{', '|', '*', '+', '?']) { + return false; + } + let mut literal = stripped.replace("\\.", "."); + while literal.ends_with(['$', '.']) { + literal.pop(); + } + if literal.is_empty() { + return false; + } + actual.starts_with(&literal) || actual.contains(&literal) +} diff --git a/crates/core/src/launch/command_builder.rs b/crates/core/src/launch/command_builder.rs new file mode 100644 index 0000000..da54304 --- /dev/null +++ b/crates/core/src/launch/command_builder.rs @@ -0,0 +1,335 @@ +//! `CommandBuilder`:命令行组装器与“默认值抑制”语义。 +//! +//! 平移自 `analyse/02` §4.2(`CommandBuilder.java`)——这是 HMCL 命令行正确性的基石, +//! 也是“用户参数永远优先、我们只补缺”的唯一可靠实现: +//! +//! - [`CommandBuilder::add`] 无条件追加(占位符已替换); +//! - [`CommandBuilder::add_without_parsing`] 原样追加(已带引号或变量的串); +//! - [`CommandBuilder::add_default`] 若已存在同前缀参数则不追加(opt=前缀,如 `-Xmx`); +//! - [`CommandBuilder::add_unstable_flag`] / [`CommandBuilder::add_unstable_default`] +//! 按 `-XX:key=value` 与 `-XX:±key` 两种形态判重,避免同时出现 `+UseG1GC` 与 `-UseG1GC`; +//! - [`CommandBuilder::add_all_default`] 把一组参数整体按“默认值”处理(`-D` 拆键值、 +//! `-XX:` 走 unstable 分支、`-Xmx/-Xms/-Xmn/-Xss` 拆前缀、`-cp` 成对处理,其余要求完全相同); +//! - `@argfile` 感知:Java 9+ 的 `@options-file` 展开后的参数登记进 `external`, +//! 让后续默认值同样被抑制。 +//! +//! 与 HMCL 的差异(已记录在 `DevDocs/SubAgentsTalk/launch-接口约定.md` §7):我们直接 spawn +//! 子进程、不经过 shell,因此不做批处理/Shell 转义;转义只在“生成启动脚本”(P1)时需要。 + +use std::path::Path; + +use crate::launch::placeholders::PlaceholderMap; + +/// 命令行组装器。 +#[derive(Debug, Clone, Default)] +pub struct CommandBuilder { + args: Vec, + external: Vec, + placeholders: PlaceholderMap, +} + +impl CommandBuilder { + /// 空构造器。 + pub fn new() -> Self { + Self::default() + } + + /// 带占位符表的构造器。 + pub fn with_placeholders(placeholders: PlaceholderMap) -> Self { + Self { + args: Vec::new(), + external: Vec::new(), + placeholders, + } + } + + /// 占位符表。 + pub fn placeholders(&self) -> &PlaceholderMap { + &self.placeholders + } + + /// 追加一个参数(替换占位符)。 + pub fn add(&mut self, arg: &str) { + let rendered = self.placeholders.render(arg); + self.args.push(rendered); + } + + /// 追加一组参数(替换占位符)。 + pub fn add_all(&mut self, args: &[String]) { + for arg in args { + self.add(arg); + } + } + + /// 原样追加(不替换占位符)。 + pub fn add_without_parsing(&mut self, arg: impl Into) { + self.args.push(arg.into()); + } + + /// 原样追加一组。 + pub fn add_all_without_parsing(&mut self, args: &[String]) { + for arg in args { + self.add_without_parsing(arg.clone()); + } + } + + /// 原样追加一组,并读取其中的 `@argfile`: + /// 文件里展开出来的参数登记为 `external`,后续 [`Self::add_default`] 会被它们抑制。 + pub fn add_all_without_parsing_and_read_external( + &mut self, + args: &[String], + read_argfile: &dyn Fn(&str) -> Option, + ) { + for arg in args { + self.add_without_parsing(arg.clone()); + if let Some(path) = arg.strip_prefix('@') { + if let Some(contents) = read_argfile(path) { + for extra in parse_argfile(&contents) { + self.external.push(extra); + } + } + } + } + } + + /// 已存在以 `prefix` 开头的参数则返回它,否则追加 `prefix + value` 并返回 `None`。 + /// + /// 返回值的语义与 HMCL 一致:`None` = “是我加的”,`Some(已有值)` = “用户已经指定过”。 + pub fn add_default(&mut self, prefix: &str, value: &str) -> Option { + if let Some(existing) = self.find_starting_with(prefix) { + return Some(existing.to_string()); + } + let rendered = self.placeholders.render(&format!("{prefix}{value}")); + self.args.push(rendered); + None + } + + /// `-XX:key=value` 形态的默认值;已存在该 key 的任何形态则不追加。 + pub fn add_unstable_default(&mut self, key: &str, value: &str) -> Option { + if let Some(existing) = self.find_unstable(key) { + return Some(existing.to_string()); + } + let rendered = self.placeholders.render(&format!("-XX:{key}={value}")); + self.args.push(rendered); + None + } + + /// `-XX:+key` / `-XX:-key` 形态的默认值;已存在该 key 的任何形态则不追加。 + pub fn add_unstable_flag(&mut self, key: &str, enabled: bool) -> Option { + if let Some(existing) = self.find_unstable(key) { + return Some(existing.to_string()); + } + let sign = if enabled { '+' } else { '-' }; + self.args.push(format!("-XX:{sign}{key}")); + None + } + + /// 一组参数整体按“默认值”处理。 + pub fn add_all_default(&mut self, args: &[String]) { + let mut index = 0; + while index < args.len() { + let raw = args[index].trim(); + if raw.is_empty() { + index += 1; + continue; + } + let resolved = self.placeholders.render(raw); + if matches!(raw, "-cp" | "-classpath") { + let exists = self.find_starting_with(raw).is_some(); + if !exists { + self.args.push(resolved); + if let Some(value) = args.get(index + 1) { + let value = self.placeholders.render(value); + self.args.push(value); + } + } + index += 2; + continue; + } + if self.try_add_key_value_default(&resolved) { + index += 1; + continue; + } + let exists = self + .args + .iter() + .chain(self.external.iter()) + .any(|arg| arg == &resolved); + if !exists { + self.args.push(resolved); + } + index += 1; + } + } + + /// 已存在的、以 `prefix` 开头的参数。 + pub fn find_starting_with(&self, prefix: &str) -> Option<&str> { + self.args + .iter() + .chain(self.external.iter()) + .find(|arg| arg.starts_with(prefix)) + .map(String::as_str) + } + + /// 已存在的、提到 `key` 的 `-XX:` 参数(两种形态都算)。 + pub fn find_unstable(&self, key: &str) -> Option<&str> { + self.args + .iter() + .chain(self.external.iter()) + .find(|arg| unstable_key(arg) == Some(key)) + .map(String::as_str) + } + + /// 是否已存在完全相同的参数。 + pub fn contains_exact(&self, arg: &str) -> bool { + self.args.iter().any(|existing| existing == arg) + } + + /// 删除禁用参数(例如 Java ≥9 上的 `-Xincgc`),返回被删掉的项。 + pub fn remove_forbidden(&mut self, forbidden: &[&str]) -> Vec { + let mut removed = Vec::new(); + self.args.retain(|arg| { + if forbidden.contains(&arg.as_str()) { + removed.push(arg.clone()); + false + } else { + true + } + }); + removed + } + + /// 当前参数列表。 + pub fn args(&self) -> &[String] { + &self.args + } + + /// 取走参数列表。 + pub fn into_args(self) -> Vec { + self.args + } + + /// 人读形式(含空格的参数加双引号),用于日志与快照测试。 + pub fn display_args(&self) -> String { + self.args + .iter() + .map(|arg| quote_for_display(arg)) + .collect::>() + .join(" ") + } + + fn try_add_key_value_default(&mut self, resolved: &str) -> bool { + if let Some(rest) = resolved.strip_prefix("-D") { + if let Some(eq) = rest.find('=') { + let split = 2 + eq + 1; + let prefix = resolved[..split].to_string(); + let value = resolved[split..].to_string(); + let _ = self.add_default(&prefix, &value); + return true; + } + } + if let Some(rest) = resolved.strip_prefix("-XX:") { + if let Some(eq) = rest.find('=') { + let _ = self.add_unstable_default(&rest[..eq], &rest[eq + 1..]); + return true; + } + if let Some(signless) = rest.strip_prefix(['+', '-']) { + let _ = self.add_unstable_flag(signless, rest.starts_with('+')); + return true; + } + } + for prefix in ["-Xmx", "-Xms", "-Xmn", "-Xss"] { + if let Some(value) = resolved.strip_prefix(prefix) { + let _ = self.add_default(prefix, value); + return true; + } + } + false + } +} + +fn unstable_key(arg: &str) -> Option<&str> { + let rest = arg.strip_prefix("-XX:")?; + let body = rest.strip_prefix(['+', '-']).unwrap_or(rest); + Some(body.split('=').next().unwrap_or(body)) +} + +/// 把 `minecraftArguments` 之类的一整串拆成参数(引号感知,反斜杠转义 `"` 与 `\`)。 +pub fn tokenize(text: &str) -> Vec { + let mut out = Vec::new(); + let mut current = String::new(); + let mut has_token = false; + let mut in_single = false; + let mut in_double = false; + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '\'' if !in_double => { + in_single = !in_single; + has_token = true; + } + '"' if !in_single => { + in_double = !in_double; + has_token = true; + } + '\\' if !in_single => match chars.peek() { + Some(&next) if next == '"' || next == '\\' => { + current.push(next); + chars.next(); + has_token = true; + } + _ => { + current.push(ch); + has_token = true; + } + }, + c if c.is_whitespace() && !in_single && !in_double => { + if has_token { + out.push(std::mem::take(&mut current)); + has_token = false; + } + } + c => { + current.push(c); + has_token = true; + } + } + } + if has_token { + out.push(current); + } + out +} + +/// 解析 `@argfile` 内容:忽略空行与 `#` 注释行,每行再走 [`tokenize`]。 +pub fn parse_argfile(contents: &str) -> Vec { + let mut out = Vec::new(); + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + out.extend(tokenize(line)); + } + out +} + +/// 从磁盘读取 `@argfile`(读不到就当没有;不因用户文件缺失而中断启动)。 +pub fn read_argfile(path: &str) -> Option { + let path = path.strip_prefix('@').unwrap_or(path); + std::fs::read(Path::new(path)) + .ok() + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) +} + +/// 人读引用:含空白或引号的参数加双引号并转义内部引号。 +pub fn quote_for_display(arg: &str) -> String { + if arg.is_empty() { + return "\"\"".to_string(); + } + if arg.contains([' ', '\t', '"']) { + format!("\"{}\"", arg.replace('"', "\\\"")) + } else { + arg.to_string() + } +} diff --git a/crates/core/src/launch/crash_scan.rs b/crates/core/src/launch/crash_scan.rs new file mode 100644 index 0000000..66da94b --- /dev/null +++ b/crates/core/src/launch/crash_scan.rs @@ -0,0 +1,152 @@ +//! 崩溃现场扫描:`crash-reports/*.txt`、`hs_err_pid*.log`、`logs/latest.log`。 +//! +//! 按 `DevDocs/SubAgentsTalk/launch-接口约定.md` §3:`crash_found` 事件的 `path` 是 +//! **权威字段**(M15 按路径自己读盘,`excerpt` 仅供人读)。所以这里只做“找到并给出路径”, +//! 不做解析——解析(hs_err 的失败帧/内存段)是 M15 的 `hs_err` 解析器。 +//! +//! 只收**本次会话之后**动过的文件(`since` = 进程启动时刻),避免把上一局的崩溃报告 +//! 算到这一局头上(这是 HMCL 会犯的错)。 + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +/// 崩溃现场文件来源。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CrashSource { + /// `crash-reports/crash-*.txt`(游戏自己写的权威 Description + 栈)。 + CrashReport, + /// JVM 致命错误 `hs_err_pid*.log`(HMCL 完全不解析的空白区)。 + HsErr, + /// `logs/latest.log`(内存缓冲不够长时的兜底全文)。 + LatestLog, +} + +impl CrashSource { + /// 事件流里的字符串。 + pub fn as_str(self) -> &'static str { + match self { + Self::CrashReport => "crash_report", + Self::HsErr => "hs_err", + Self::LatestLog => "latest_log", + } + } +} + +/// 一个现场文件。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CrashArtifact { + /// 来源类型。 + pub source: CrashSource, + /// 绝对/相对路径(交给 M15)。 + pub path: PathBuf, + /// 供人读的开头片段(`latest.log` 不给,太大)。见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §4(写入前脱敏)。 + pub excerpt: Option, +} + +/// 默认摘录上限(字符)。 +pub const MAX_EXCERPT_CHARS: usize = 2000; + +/// 扫描一次运行目录,返回按修改时间倒序的现场文件(最多 `limit` 个)。 +/// +/// `since` 之前的文件视为上一局遗留,忽略;给 `since` 留 1 秒容差以吸收文件系统时间精度。 +pub fn scan(run_dir: &Path, since: SystemTime, limit: usize) -> Vec { + let since = since.checked_sub(Duration::from_secs(1)).unwrap_or(since); + let mut found: Vec<(SystemTime, CrashArtifact)> = Vec::new(); + + collect_matching( + &run_dir.join("crash-reports"), + CrashSource::CrashReport, + since, + &mut found, + ); + collect_matching(run_dir, CrashSource::HsErr, since, &mut found); + if let Some(artifact) = latest_log(run_dir, since) { + let modified = artifact_time(&artifact.path).unwrap_or(since); + found.push((modified, artifact)); + } + + found.sort_by_key(|item| std::cmp::Reverse(item.0)); + found + .into_iter() + .take(limit) + .map(|(_, artifact)| artifact) + .collect() +} + +fn collect_matching( + dir: &Path, + source: CrashSource, + since: SystemTime, + out: &mut Vec<(SystemTime, CrashArtifact)>, +) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !is_candidate(&path, source) { + continue; + } + let Some(modified) = artifact_time(&path) else { + continue; + }; + if modified < since { + continue; + } + out.push(( + modified, + CrashArtifact { + source, + path: path.clone(), + excerpt: read_excerpt(&path, MAX_EXCERPT_CHARS), + }, + )); + } +} + +fn is_candidate(path: &Path, source: CrashSource) -> bool { + if !path.is_file() { + return false; + } + let name = path + .file_name() + .map(|name| name.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + match source { + CrashSource::CrashReport => name.ends_with(".txt"), + CrashSource::HsErr => name.starts_with("hs_err_pid") && name.ends_with(".log"), + CrashSource::LatestLog => false, + } +} + +fn latest_log(run_dir: &Path, since: SystemTime) -> Option { + let path = run_dir.join("logs").join("latest.log"); + let modified = artifact_time(&path)?; + if modified < since { + return None; + } + Some(CrashArtifact { + source: CrashSource::LatestLog, + path, + excerpt: None, + }) +} + +fn artifact_time(path: &Path) -> Option { + std::fs::metadata(path).ok()?.modified().ok() +} + +/// 读文件开头 `max_chars` 个字符(读不到就 `None`,不因权限/编码问题中断收尾)。 +pub fn read_excerpt(path: &Path, max_chars: usize) -> Option { + let bytes = std::fs::read(path).ok()?; + let text = String::from_utf8_lossy(&bytes); + let mut excerpt = String::new(); + for (count, ch) in text.chars().enumerate() { + if count >= max_chars { + excerpt.push('…'); + break; + } + excerpt.push(ch); + } + Some(excerpt) +} diff --git a/crates/core/src/launch/defaults.rs b/crates/core/src/launch/defaults.rs new file mode 100644 index 0000000..ffcc157 --- /dev/null +++ b/crates/core/src/launch/defaults.rs @@ -0,0 +1,203 @@ +//! 默认参数表:JVM 优化、编码、log4j、禁用参数、环境变量。 +//! +//! 平移自 `analyse/02` §4.3(自动优化)、§4.4(编码/Log4j)、§4.1 #33(禁用参数)、 +//! §4.10(环境变量)。所有函数都是纯函数——同样的输入永远给同样的参数,便于快照测试。 + +use crate::launch::platform::OsFamily; +use crate::launch::request::{LaunchRequest, ModLoaderKind, RendererOptions, Resolution}; + +/// 旧版清单的默认 JVM 参数(`analyse/02` §4.7 的 `DEFAULT_JVM_ARGUMENTS`)。 +/// +/// 现代清单把这两项写在自己的 `arguments.jvm` 里,只有 legacy 需要补。 +pub fn legacy_jvm_arguments() -> Vec { + vec![ + "-Djava.library.path=${natives_directory}".to_string(), + "-cp".to_string(), + "${classpath}".to_string(), + ] +} + +/// stdout/stderr 编码参数。 +/// +/// Java ≥19 用 `-Dstdout.encoding`,更早用 `-Dsun.stdout.encoding`(`analyse/02` §4.4: +/// 用户指定的 `-Dfile.encoding` 会决定日志泵的解码方式,这里必须跟随)。 +pub fn stream_encoding_args(java_major: u32, encoding: &str) -> Vec { + if java_major >= 19 { + vec![ + format!("-Dstdout.encoding={encoding}"), + format!("-Dstderr.encoding={encoding}"), + ] + } else { + vec![ + format!("-Dsun.stdout.encoding={encoding}"), + format!("-Dsun.stderr.encoding={encoding}"), + ] + } +} + +/// Log4j2 RCE 缓解三件套(`analyse/02` §4.1 #9)。 +pub fn log4j_mitigation_args() -> Vec { + vec![ + "-Djava.rmi.server.useCodebaseOnly=true".to_string(), + "-Dcom.sun.jndi.rmi.object.trustURLCodebase=false".to_string(), + "-Dcom.sun.jndi.cosnaming.object.trustURLCodebase=false".to_string(), + ] +} + +/// 自动优化参数(`analyse/02` §4.3)。 +/// +/// `user_specified_gc` 为真时不注入 G1 整组(用户显式挑了 GC 就别打架); +/// 其它每一条都还会被 `CommandBuilder` 的默认值抑制再筛一遍。 +pub fn optimization_args( + java_major: u32, + bits: u32, + physical_memory_mb: Option, + user_specified_gc: bool, +) -> Vec { + let mut args = vec![ + "-XX:+UnlockExperimentalVMOptions".to_string(), + "-XX:+UnlockDiagnosticVMOptions".to_string(), + ]; + if java_major >= 8 && !user_specified_gc { + for arg in [ + "-XX:+UseG1GC", + "-XX:G1MixedGCCountTarget=5", + "-XX:G1NewSizePercent=20", + "-XX:G1ReservePercent=20", + "-XX:MaxGCPauseMillis=50", + "-XX:G1HeapRegionSize=32m", + ] { + args.push(arg.to_string()); + } + } + // 崩溃诊断关键项:关掉“快速抛出时省略栈”,保证日志里有完整堆栈。 + args.push("-XX:-OmitStackTraceInFastThrow".to_string()); + if java_major <= 8 { + args.push("-XX:MaxInlineLevel=15".to_string()); + } + if bits >= 64 && physical_memory_mb.is_some_and(|mb| mb > 4096) { + for arg in [ + "-XX:-DontCompileHugeMethods", + "-XX:MaxNodeLimit=240000", + "-XX:NodeLimitFudgeFactor=8000", + "-XX:TieredCompileTaskTimeout=10000", + "-XX:ReservedCodeCacheSize=400M", + "-XX:NonNMethodCodeHeapSize=12M", + "-XX:ProfiledCodeHeapSize=194M", + "-XX:NmethodSweepActivity=1", + ] { + args.push(arg.to_string()); + } + } + if bits >= 64 && (25..=26).contains(&java_major) { + args.push("-XX:+UseCompactObjectHeaders".to_string()); + } + args +} + +/// 版本特定的 JVM 开关(`analyse/02` §4.1 #15)。 +pub fn version_specific_args(java_major: u32) -> Vec { + match java_major { + 16 => vec!["--illegal-access=permit".to_string()], + 24 | 25 => vec!["--sun-misc-unsafe-memory-access=allow".to_string()], + _ => Vec::new(), + } +} + +/// Forge/NeoForge 的证书校验豁免(`analyse/02` §4.1 #16)。 +pub fn fml_args() -> Vec { + vec![ + "-Dfml.ignoreInvalidMinecraftCertificates=true".to_string(), + "-Dfml.ignorePatchDiscrepancies=true".to_string(), + ] +} + +/// 渲染器相关 `-D` 参数(`analyse/02` §4.1 #17,v1 只做 native 路径覆盖)。 +/// +/// 软件渲染的细节开关走环境变量([`environment`]),因为 Mesa/llvmpipe 读的是 env。 +pub fn renderer_args(renderer: &RendererOptions) -> Vec { + let mut args = Vec::new(); + if let Some(dir) = &renderer.mesa_native_dir { + args.push(format!("-Dorg.lwjgl.librarypath={}", dir.display())); + } + args +} + +/// 被禁止的参数(最后统一清理,`analyse/02` §4.1 #33)。 +pub fn forbidden_args(java_major: u32) -> Vec<&'static str> { + if java_major >= 9 { + vec!["-Xincgc"] + } else { + Vec::new() + } +} + +/// 旧版清单的默认游戏参数(分辨率,`analyse/02` §4.1 #26)。 +pub fn legacy_game_args(resolution: Option<&Resolution>) -> Vec { + let mut args = Vec::new(); + if let Some(res) = resolution { + args.push("--width".to_string()); + args.push(res.width.to_string()); + args.push("--height".to_string()); + args.push(res.height.to_string()); + } + args +} + +/// 进程环境变量(`analyse/02` §4.10)。 +/// +/// 顺序:实例标识 → 渲染器开关 → 加载器标记 → 用户自定义(最后覆盖)。 +pub fn environment(req: &LaunchRequest, os: OsFamily) -> Vec<(String, String)> { + let mut env: Vec<(String, String)> = Vec::new(); + set_env(&mut env, "INST_NAME", &req.instance.name); + set_env(&mut env, "INST_ID", &req.instance.id); + set_env( + &mut env, + "INST_DIR", + &req.dirs.game_dir.display().to_string(), + ); + set_env( + &mut env, + "INST_MC_DIR", + &req.dirs.game_dir.display().to_string(), + ); + set_env( + &mut env, + "INST_JAVA", + &req.java.binary.display().to_string(), + ); + if req.mod_loader == ModLoaderKind::OptiFine { + set_env(&mut env, "INST_OPTIFINE", "1"); + } + if req.renderer.software { + set_env(&mut env, "LIBGL_ALWAYS_SOFTWARE", "1"); + set_env(&mut env, "MESA_LOADER_DRIVER_OVERRIDE", "llvmpipe"); + set_env(&mut env, "GALLIUM_DRIVER", "llvmpipe"); + } + if os == OsFamily::MacOs { + if let Some(icd) = &req.renderer.vulkan_icd { + set_env(&mut env, "VK_ICD_FILENAMES", &icd.display().to_string()); + set_env(&mut env, "VK_DRIVER_FILES", &icd.display().to_string()); + } + } + match req.mod_loader { + ModLoaderKind::Forge => set_env(&mut env, "FORGE_INSTALL", "1"), + ModLoaderKind::NeoForge => set_env(&mut env, "NEOFORGE_INSTALL", "1"), + ModLoaderKind::Fabric => set_env(&mut env, "FABRIC_INSTALL", "1"), + ModLoaderKind::Quilt => set_env(&mut env, "QUILT_INSTALL", "1"), + ModLoaderKind::LiteLoader => set_env(&mut env, "LITELOADER_INSTALL", "1"), + ModLoaderKind::OptiFine | ModLoaderKind::Vanilla => {} + } + for (key, value) in &req.user.environment { + set_env(&mut env, key, value); + } + env +} + +fn set_env(env: &mut Vec<(String, String)>, key: &str, value: &str) { + if let Some(slot) = env.iter_mut().find(|(existing, _)| existing == key) { + slot.1 = value.to_string(); + } else { + env.push((key.to_string(), value.to_string())); + } +} diff --git a/crates/core/src/launch/diagnosis_hook.rs b/crates/core/src/launch/diagnosis_hook.rs new file mode 100644 index 0000000..889c7c6 --- /dev/null +++ b/crates/core/src/launch/diagnosis_hook.rs @@ -0,0 +1,97 @@ +//! 诊断挂钩:launch 与 M15(`core::diagnose`)之间的唯一接缝。 +//! +//! 分工(`DevDocs/SubAgentsTalk/launch-接口约定.md` §3):**launch 报事实、M15 下结论**。 +//! launch 在进程结束后把「退出判定 + 日志环形缓冲 + 崩溃现场文件 + 环境快照」打包成 +//! [`DiagnosisRequest`] 交给挂钩;挂钩返回结构化 payload(`candidates[]` 等),由 launch +//! 脱敏后写进同一个 JSONL(单一真相源)。 +//! +//! 这样 M15 不需要 import launch 的类型,launch 也不需要 import diagnose 的类型—— +//! 阶段 B 的 diagnose 尚未合并,P0 先用 [`NoopDiagnosis`] 占位。 + +use std::path::PathBuf; + +use serde_json::Value; + +use crate::launch::crash_scan::CrashArtifact; +use crate::launch::exit_analysis::ExitAnalysis; +use crate::launch::pump::LogLine; + +/// 诊断挂钩。 +pub trait DiagnosisHook: Send + Sync { + /// 需要诊断时调用;返回 `None` 表示“这次没有结论”(事件流里就不会有 `diagnosis` 事件)。 + fn analyze(&self, request: &DiagnosisRequest) -> Option; +} + +/// 空挂钩:不诊断(占位 / 用户关闭 AI)。 +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopDiagnosis; + +impl DiagnosisHook for NoopDiagnosis { + fn analyze(&self, _request: &DiagnosisRequest) -> Option { + None + } +} + +/// 交给诊断挂钩的完整事实包。 +#[derive(Debug, Clone)] +pub struct DiagnosisRequest { + /// 会话 id(M15 可据此回读 jsonl)。 + pub session_id: String, + /// 实例 id。 + pub instance_id: String, + /// 实例名。 + pub instance_name: String, + /// 游戏版本。 + pub game_version: String, + /// 退出判定。 + pub exit: ExitAnalysis, + /// 存活毫秒数。 + pub uptime_ms: u64, + /// 环形缓冲里的日志行(按时间顺序,已脱敏)。 + pub log_lines: Vec, + /// 崩溃现场文件(crash-report / hs_err / latest.log)。 + pub artifacts: Vec, + /// 脱敏后的完整 argv。 + pub argv: Vec, + /// 最大堆(MiB)。 + pub memory_mb: Option, + /// Java 主版本。 + pub java_major: u32, + /// Java 可执行文件。 + pub java_binary: PathBuf, + /// 运行目录。 + pub game_dir: PathBuf, +} + +impl DiagnosisRequest { + /// 错误级日志行(M15 规则匹配的主输入)。 + pub fn error_lines(&self) -> Vec { + self.log_lines + .iter() + .filter(|line| line.is_error()) + .map(|line| line.text.clone()) + .collect() + } + + /// 全部日志拼成文本(M15 自己的 `LogBuffer` 也能直接用 `log_lines`)。 + pub fn log_text(&self) -> String { + let mut text = String::new(); + for line in &self.log_lines { + text.push_str(&line.text); + text.push('\n'); + } + text + } + + /// 一句话摘要(日志/UI 展示)。 + pub fn summary(&self) -> String { + format!( + "会话 {} 退出:[{}];错误行 {};现场文件 {};需诊断={}", + self.session_id, + self.exit.summary(), + self.error_lines().len(), + self.artifacts.len(), + self.exit.needs_diagnosis + ) + } +} diff --git a/crates/core/src/launch/error.rs b/crates/core/src/launch/error.rs new file mode 100644 index 0000000..1d5d435 --- /dev/null +++ b/crates/core/src/launch/error.rs @@ -0,0 +1,165 @@ +//! launch 模块统一错误类型。 +//! +//! 设计依据:`DevDocs/PlanDoc/06-launch-and-diagnostics.md` §1、`analyse/02` §8.4-3 +//! (缺库/缺组件必须结构化报错并显式失败,而不是交给运行时 `NoClassDefFoundError`)。 + +use std::fmt; +use std::path::PathBuf; + +/// launch 相关操作的统一结果类型。 +pub type Result = std::result::Result; + +/// 缺失的组件(库 / natives / 资产),供 UI 与 agent 直接展示与修复。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MissingComponent { + /// 逻辑名,例如 `org.lwjgl:lwjgl:3.3.1` 或 `libraries/foo/bar.jar`。 + pub name: String, + /// 期望落盘路径(未知时为 `None`)。 + pub path: Option, +} + +impl MissingComponent { + /// 只按名字构造(路径未知)。 + pub fn named(name: impl Into) -> Self { + Self { + name: name.into(), + path: None, + } + } + + /// 带期望路径构造。 + pub fn at_path(name: impl Into, path: impl Into) -> Self { + Self { + name: name.into(), + path: Some(path.into()), + } + } +} + +/// 命令行组装阶段的失败原因(全部是“启动前就能确定”的错误)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AssemblyError { + /// 清单里没有主类,无法启动(HMCL 在此直接抛异常)。 + MissingMainClass, + /// classpath 为空(一个库和主 jar 都没有)。 + EmptyClasspath, + /// Java 可执行文件路径为空。 + EmptyJavaPath, + /// 组件不完整:启动前显式失败,附结构化清单。 + MissingComponents(Vec), + /// 清单参数畸形(例如空参数项)。 + MalformedArgument { + /// 原始参数项文本。 + entry: String, + /// 原因说明。 + reason: &'static str, + }, + /// natives 解压失败(jar 缺失/损坏、目录不可写)。 + /// + /// 归到「启动前失败」而不是运行时错误:native 库没落地时游戏只会给出 + /// `UnsatisfiedLinkError`,那时候已经离「到底哪个 jar 坏了」太远了。 + NativesExtraction { + /// 人读原因(含失败路径)。 + message: String, + }, +} + +impl fmt::Display for AssemblyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingMainClass => f.write_str("版本清单缺少主类(mainClass)"), + Self::EmptyClasspath => f.write_str("classpath 为空:没有任何库或主 jar"), + Self::EmptyJavaPath => f.write_str("Java 可执行文件路径为空"), + Self::MissingComponents(items) => { + write!(f, "组件缺失 {} 项", items.len())?; + for item in items.iter().take(8) { + write!(f, "; {}", item.name)?; + } + Ok(()) + } + Self::MalformedArgument { entry, reason } => { + write!(f, "参数畸形({reason}):{entry}") + } + Self::NativesExtraction { message } => { + write!(f, "natives 解压失败:{message}") + } + } + } +} + +impl std::error::Error for AssemblyError {} + +/// launch 模块的统一错误。 +#[derive(Debug)] +pub enum LaunchError { + /// 命令行组装失败(纯计算,可单测)。 + Assembly(AssemblyError), + /// 文件系统操作失败。 + Io { + /// 操作名(`create_session` / `read_argfile` 等)。 + op: &'static str, + /// 相关路径。 + path: PathBuf, + /// 底层错误。 + source: std::io::Error, + }, + /// 进程派生失败(Java 不存在、权限不足等)。 + Spawn { + /// 被执行的程序。 + program: PathBuf, + /// 底层错误。 + source: std::io::Error, + }, + /// 会话事件流写入/解析失败。 + Session { + /// 会话文件路径。 + path: PathBuf, + /// 原因说明。 + reason: String, + }, + /// 进程树 kill 失败。 + Kill { + /// 目标进程号。 + pid: u32, + /// 底层错误。 + source: std::io::Error, + }, +} + +impl fmt::Display for LaunchError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Assembly(err) => write!(f, "命令行组装失败:{err}"), + Self::Io { op, path, source } => { + write!(f, "{op} 失败({}):{source}", path.display()) + } + Self::Spawn { program, source } => { + write!(f, "启动进程失败({}):{source}", program.display()) + } + Self::Session { path, reason } => { + write!(f, "会话事件流错误({}):{reason}", path.display()) + } + Self::Kill { pid, source } => { + write!(f, "结束进程树失败(pid {pid}):{source}") + } + } + } +} + +impl std::error::Error for LaunchError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Assembly(err) => Some(err), + Self::Io { source, .. } | Self::Spawn { source, .. } | Self::Kill { source, .. } => { + Some(source) + } + Self::Session { .. } => None, + } + } +} + +impl From for LaunchError { + fn from(err: AssemblyError) -> Self { + Self::Assembly(err) + } +} diff --git a/crates/core/src/launch/exit_analysis.rs b/crates/core/src/launch/exit_analysis.rs new file mode 100644 index 0000000..a4bc842 --- /dev/null +++ b/crates/core/src/launch/exit_analysis.rs @@ -0,0 +1,264 @@ +//! 退出分析:退出码语义 + `exit 0` 静默失败检测。 +//! +//! 平移自 `analyse/02` §6.3/§7.2(`ExitWaiter` 的三源判定),并按 §8.4-2 / §7.10-10 做两处升级: +//! +//! 1. **完整退出码表**:不只认 Linux 的 `137`,Windows 的 NTSTATUS +//! (`0xC0000005` 等)也要能说清是什么; +//! 2. **静默失败单独立标**:退出码 0 但存活 <30s 或 stderr 带错误特征时标记 +//! `silent_failure`——HMCL 会判 NORMAL 放过(LaunchWrapper 吞异常后 `exit 0` 是 +//! 真实存在的失败形态)。 + +use crate::launch::pump::LogLine; + +/// 退出判定类型。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExitType { + /// 正常退出。 + Normal, + /// 应用层崩溃(游戏起来了但崩了)。 + ApplicationError, + /// JVM 都没起来(`-Xmx` 过大、JVM 参数非法等)。 + JvmError, + /// 被 `SIGKILL` 结束(POSIX 137)。 + SigKill, + /// 被 `SIGTERM` 结束(POSIX 143)。 + SigTerm, + /// 被启动器主动结束(用户点取消 / 看门狗强制)。 + Killed, + /// 等待被中断。 + Interrupted, +} + +impl ExitType { + /// 事件流里的字符串形式(与 M15 `ExitType` 刻意保持一致)。 + pub fn as_str(self) -> &'static str { + match self { + Self::Normal => "normal", + Self::ApplicationError => "application_error", + Self::JvmError => "jvm_error", + Self::SigKill => "sigkill", + Self::SigTerm => "sigterm", + Self::Killed => "killed", + Self::Interrupted => "interrupted", + } + } + + /// 是否属于“非正常退出”。 + pub fn is_abnormal(self) -> bool { + self != Self::Normal + } +} + +/// JVM 未能启动的三条关键字(`analyse/02` §7.2)。 +pub const JVM_FAILURE_KEYWORDS: &[&str] = &[ + "Could not create the Java Virtual Machine.", + "Error occurred during initialization of VM", + "A fatal exception has occurred. Program will exit.", +]; + +/// 应用层崩溃的五条关键字(`analyse/02` §7.2,覆盖三种崩法)。 +pub const CRASH_KEYWORDS: &[&str] = &[ + "Crash report saved to", + "Could not save crash report to", + "This crash report has been saved to:", + "Unable to launch", + "An exception was thrown, the game will display an error screen and halt.", +]; + +/// 静默失败的存活阈值(毫秒):<30s 视为“没真正跑起来”。 +pub const SHORT_LIVED_MS: u64 = 30_000; + +/// 退出分析的输入。 +#[derive(Debug, Clone, Copy)] +pub struct ExitSignals<'a> { + /// 退出码。 + pub code: i32, + /// 进程存活毫秒数。 + pub uptime_ms: u64, + /// 环形缓冲里的日志行。 + pub lines: &'a [LogLine], + /// 是否启动器主动结束。 + pub killed: bool, + /// 等待是否被中断。 + pub interrupted: bool, + /// 是否 POSIX(决定 137/143 是否按信号解读)。 + pub is_posix: bool, +} + +impl<'a> ExitSignals<'a> { + /// 构造。 + pub fn new(code: i32, uptime_ms: u64, lines: &'a [LogLine]) -> Self { + Self { + code, + uptime_ms, + lines, + killed: false, + interrupted: false, + is_posix: cfg!(unix), + } + } +} + +/// 静默失败标记。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SilentFailure { + /// 存活时间过短。 + pub short_lived: bool, + /// stderr/日志里有错误特征。 + pub error_output: bool, +} + +impl SilentFailure { + /// 人读说明。 + pub fn describe(&self) -> String { + match (self.short_lived, self.error_output) { + (true, true) => "退出码 0,但存活不足 30 秒且输出含错误特征".to_string(), + (true, false) => "退出码 0,但存活不足 30 秒".to_string(), + (false, true) => "退出码 0,但输出含错误特征".to_string(), + (false, false) => "退出码 0".to_string(), + } + } +} + +/// 退出分析结果(`exit` 事件的 payload 来源)。 +#[derive(Debug, Clone)] +pub struct ExitAnalysis { + /// 退出码。 + pub code: i32, + /// 判定类型。 + pub exit_type: ExitType, + /// 存活毫秒数。 + pub uptime_ms: u64, + /// 错误级日志行数。 + pub error_lines: u32, + /// 命中的崩溃关键字。 + pub crash_keywords: Vec, + /// 命中的 JVM 失败关键字。 + pub jvm_keywords: Vec, + /// 静默失败标记(仅 `Normal` 时可能非空)。 + pub silent_failure: Option, + /// Windows NTSTATUS 解释(例 `0xC0000005 ACCESS_VIOLATION`)。 + pub windows_status: Option, + /// 是否应该进入诊断流水线(M15)。 + pub needs_diagnosis: bool, +} + +impl ExitAnalysis { + /// 一句话摘要(UI/agent 可直接展示)。 + pub fn summary(&self) -> String { + let mut text = format!( + "退出码 {}({}),存活 {:.1}s,错误行 {}", + self.code, + self.exit_type.as_str(), + self.uptime_ms as f64 / 1000.0, + self.error_lines + ); + if let Some(status) = &self.windows_status { + text.push_str(&format!(",{status}")); + } + if !self.crash_keywords.is_empty() { + text.push_str(&format!(",崩溃关键字 {}", self.crash_keywords.len())); + } + if let Some(silent) = &self.silent_failure { + text.push_str(&format!(",{}", silent.describe())); + } + text + } +} + +/// 做退出分析。 +pub fn analyze(signals: &ExitSignals<'_>) -> ExitAnalysis { + let error_lines = signals.lines.iter().filter(|line| line.is_error()).count(); + let crash_keywords = matched_keywords(signals.lines, CRASH_KEYWORDS); + let jvm_keywords = matched_keywords(signals.lines, JVM_FAILURE_KEYWORDS); + let error_output = signals + .lines + .iter() + .any(|line| line.is_error() && looks_like_failure(&line.text)); + + let exit_type = if signals.interrupted { + ExitType::Interrupted + } else if signals.killed { + ExitType::Killed + } else if signals.code != 0 && !jvm_keywords.is_empty() { + ExitType::JvmError + } else if signals.is_posix && signals.code == 137 { + ExitType::SigKill + } else if signals.is_posix && signals.code == 143 { + ExitType::SigTerm + } else if signals.code != 0 || !crash_keywords.is_empty() { + ExitType::ApplicationError + } else { + ExitType::Normal + }; + + let silent_failure = if exit_type == ExitType::Normal { + let short_lived = signals.uptime_ms < SHORT_LIVED_MS; + if short_lived || error_output { + Some(SilentFailure { + short_lived, + error_output, + }) + } else { + None + } + } else { + None + }; + + ExitAnalysis { + code: signals.code, + exit_type, + uptime_ms: signals.uptime_ms, + error_lines: u32::try_from(error_lines).unwrap_or(u32::MAX), + crash_keywords: crash_keywords.clone(), + jvm_keywords, + silent_failure, + windows_status: windows_status_name(signals.code), + needs_diagnosis: exit_type.is_abnormal() + || silent_failure.is_some() + || !crash_keywords.is_empty(), + } +} + +/// Windows NTSTATUS → 解释。 +pub fn windows_status_name(code: i32) -> Option { + let (hex, name) = match code { + -1_073_741_819 => ("0xC0000005", "ACCESS_VIOLATION"), + -1_073_740_791 => ("0xC0000409", "STACK_BUFFER_OVERRUN"), + -1_073_741_515 => ("0xC0000135", "DLL_NOT_FOUND"), + -1_073_741_571 => ("0xC00000FD", "STACK_OVERFLOW"), + -1_073_741_510 => ("0xC000013A", "CONTROL_C_EXIT"), + _ => return None, + }; + Some(format!("{hex} {name}")) +} + +/// 退出码的人工提示(附录 C 速查表的裁剪版)。 +pub fn exit_code_hint(code: i32, is_posix: bool) -> Option<&'static str> { + match code { + 0 => Some("正常退出"), + 1 => Some("通用失败(含 JVM 启动失败)"), + 137 if is_posix => Some("SIGKILL:被强杀"), + 143 if is_posix => Some("SIGTERM:被请求退出"), + _ => None, + } +} + +fn matched_keywords(lines: &[LogLine], keywords: &[&str]) -> Vec { + keywords + .iter() + .filter(|keyword| lines.iter().any(|line| line.text.contains(**keyword))) + .map(|keyword| (*keyword).to_string()) + .collect() +} + +/// “像失败的输出”:显式异常/错误特征(不是任何 stderr 输出都算)。 +fn looks_like_failure(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + lower.contains("exception") + || lower.contains("caused by") + || lower.contains("fatal") + || lower.contains("error:") + || lower.contains("failed") +} diff --git a/crates/core/src/launch/game_args.rs b/crates/core/src/launch/game_args.rs new file mode 100644 index 0000000..4d33865 --- /dev/null +++ b/crates/core/src/launch/game_args.rs @@ -0,0 +1,206 @@ +//! 参数后段:清单 JVM 参数尾部(第 20~23 项)与游戏参数(第 24~32 项)。 +//! +//! 顺序本身有语义(`analyse/02` §4.1):清单 JVM 参数在用户 `javaArguments` 之后 +//! **无条件追加**,因此能覆盖用户参数,但覆盖不了用户的 override 参数; +//! 用户自定义游戏参数永远最后追加,是可以覆盖前面一切游戏参数的“逃生门”。 + +use crate::launch::args_table::AssemblyWarning; +use crate::launch::argument::{ArgumentEntry, RuleContext}; +use crate::launch::command_builder::{tokenize, CommandBuilder}; +use crate::launch::defaults; +use crate::launch::game_version::GameVersion; +use crate::launch::platform::PlatformCtx; +use crate::launch::request::{LaunchRequest, QuickPlay}; + +/// `--graphicsBackend` 的最低版本(实测首发:`26.2-snapshot-2`,2026-04-09)。 +pub const GRAPHICS_BACKEND_MIN_VERSION: &str = "26.2-snapshot-2"; + +/// `--graphicsBackend` 能否下发给这个版本。 +/// +/// 两条判据**不要合并**(`research/05-mc-vulkan.md` §3.2/§4.2 的实测结论): +/// +/// - **可解析版本**走精确门限 `26.2-snapshot-2`。`26.2-snapshot-1` 已经有 `lwjgl-vulkan` +/// 库但 `Main.class` 还没声明这个参数,用能力位放行会让它 `UnrecognizedOptionException` +/// 直接起不来——库早了两天。 +/// - **周快照**(`26w14a` 形态)无法与 `26.2-snapshot-N` 排序,只能信版本库表: +/// 带 `lwjgl-vulkan` 就放行(26.1 全线含周快照都没有该库,所以这个推断安全)。 +pub fn graphics_backend_allowed(version: &GameVersion, has_vulkan_backend: bool) -> bool { + if version.is_known() { + return version.is_at_least(GRAPHICS_BACKEND_MIN_VERSION); + } + version.is_weekly_snapshot() && has_vulkan_backend +} + +/// 应用第 20~32 项,返回告警。 +pub(crate) fn apply_game_arguments( + req: &LaunchRequest, + ctx: &PlatformCtx, + builder: &mut CommandBuilder, +) -> Vec { + let mut warnings = Vec::new(); + let rule_ctx = RuleContext::new( + ctx.os.manifest_name(), + &ctx.arch, + &ctx.os_version, + &req.features, + ); + + // 第 20 项:清单 JVM 参数(现代)或 legacy 默认 JVM 参数。 + if req.manifest.jvm.is_empty() { + builder.add_all_default(&defaults::legacy_jvm_arguments()); + } else { + let values = collect_applicable(&req.manifest.jvm, &rule_ctx); + builder.add_all(&values); + } + + // 第 21 项:登录源提供的 JVM 参数(authlib-injector 等)。 + for arg in &req.auth_jvm_args { + builder.add(arg); + } + + // 第 22 项:`-javaagent:` × N。 + for agent in &req.java_agents { + builder.add(&format!("-javaagent:{agent}")); + } + + // 第 23 项:主类(args_table 已保证非空)。 + if let Some(main_class) = &req.manifest.main_class { + builder.add(main_class); + } + + // 第 24 项:legacy `minecraftArguments` 整串分词。 + if let Some(legacy) = &req.manifest.legacy_arguments { + for token in tokenize(legacy) { + builder.add(&token); + } + } + + // 第 25 项:现代 `arguments.game`(带 rules/features)。 + if !req.manifest.game.is_empty() { + let values = collect_applicable(&req.manifest.game, &rule_ctx); + builder.add_all(&values); + } + + // 第 26 项:legacy 默认游戏参数(分辨率)。 + if req.manifest.legacy_arguments.is_some() { + builder.add_all(&defaults::legacy_game_args(req.resolution.as_ref())); + } + + // 第 27 项:登录源提供的游戏参数。 + for arg in &req.auth_game_args { + builder.add(arg); + } + + // 第 28 项:QuickPlay / `--server --port`。 + apply_quick_play(req, builder, &mut warnings); + + // 第 29 项:全屏。 + if req.fullscreen { + builder.add("--fullscreen"); + } + + // 第 30 项:SOCKS 代理的游戏侧参数。 + if let Some(proxy) = &req.proxy { + builder.add("--proxyHost"); + builder.add(&proxy.host); + builder.add("--proxyPort"); + builder.add(&proxy.port.to_string()); + if let Some(username) = &proxy.username { + builder.add("--proxyUser"); + builder.add(username); + } + if let Some(password) = &proxy.password { + builder.add("--proxyPass"); + builder.add(password); + } + } + + // 第 31 项:图形后端(精确门限 ≥26.2-snapshot-2,周快照走库表兜底)。 + if let Some(backend) = &req.graphics_backend { + if graphics_backend_allowed(&req.instance.version, req.manifest.has_vulkan_backend) { + builder.add("--graphicsBackend"); + builder.add(backend); + } else { + warnings.push(AssemblyWarning::GraphicsBackendSkipped { + version: req.instance.version.raw().to_string(), + }); + } + } + + // 第 32 项:用户自定义游戏参数(最后追加,可覆盖一切)。 + builder.add_all(&req.user.game); + + warnings +} + +fn apply_quick_play( + req: &LaunchRequest, + builder: &mut CommandBuilder, + warnings: &mut Vec, +) { + let Some(quick_play) = &req.quick_play else { + return; + }; + let supports = req.instance.version.is_at_least("1.20"); + match quick_play { + QuickPlay::Singleplayer { world } if supports => { + builder.add("--quickPlaySingleplayer"); + builder.add(world); + } + QuickPlay::Multiplayer { address } if supports => { + builder.add("--quickPlayMultiplayer"); + builder.add(address); + } + QuickPlay::Realms { realm_id } if supports => { + builder.add("--quickPlayRealms"); + builder.add(realm_id); + } + QuickPlay::Multiplayer { address } => match parse_server_address(address) { + Some((host, port)) => { + builder.add("--server"); + builder.add(&host); + if let Some(port) = port { + builder.add("--port"); + builder.add(&port.to_string()); + } + warnings.push(AssemblyWarning::QuickPlayDegraded { + address: address.clone(), + }); + } + None => warnings.push(AssemblyWarning::QuickPlayAddressInvalid { + address: address.clone(), + }), + }, + QuickPlay::Singleplayer { .. } | QuickPlay::Realms { .. } => { + warnings.push(AssemblyWarning::QuickPlayUnsupported { + version: req.instance.version.raw().to_string(), + }); + } + } +} + +/// 解析 `host[:port]`;端口非法或为空时该段视为不存在。 +pub fn parse_server_address(address: &str) -> Option<(String, Option)> { + let trimmed = address.trim(); + if trimmed.is_empty() { + return None; + } + match trimmed.rsplit_once(':') { + Some((host, port)) if !host.is_empty() => match port.parse::() { + Ok(port) if port > 0 => Some((host.to_string(), Some(port))), + _ => None, + }, + Some(_) => None, + None => Some((trimmed.to_string(), None)), + } +} + +fn collect_applicable(entries: &[ArgumentEntry], ctx: &RuleContext<'_>) -> Vec { + let mut values = Vec::new(); + for entry in entries { + if entry.applies(ctx) { + values.extend(entry.values.iter().cloned()); + } + } + values +} diff --git a/crates/core/src/launch/game_version.rs b/crates/core/src/launch/game_version.rs new file mode 100644 index 0000000..05e348b --- /dev/null +++ b/crates/core/src/launch/game_version.rs @@ -0,0 +1,178 @@ +//! 游戏版本号解析与比较(供命令行组装的分支门限使用)。 +//! +//! 依据 `analyse/02` §5.2(版本号驱动的分支:legacy/modern 参数模板、log4j 注入、 +//! `--quickPlay*` 门限、`--graphicsBackend` 门限)。 +//! +//! 说明:这是 launch 侧的最小比较器。若 M14(game)后续提供权威 `GameVersion` 比较器, +//! 只需替换本文件内部实现,`is_at_least` / `is_before` 的调用点不用动 +//! (见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §7-3)。 + +use std::cmp::Ordering; +use std::fmt; + +/// 版本的发布阶段。派生 `Ord` 的变体顺序即排序顺序:Snapshot < PreRelease < ReleaseCandidate < Release。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Stage { + /// 快照(`26.2-snapshot-2`),数字为快照序号。 + Snapshot(u32), + /// 预发布(`1.20-pre1`)。 + PreRelease(u32), + /// 候选发布(`1.20-rc1`)。 + ReleaseCandidate(u32), + /// 正式版。 + Release, +} + +/// 游戏版本号。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GameVersion { + raw: String, + parts: Vec, + stage: Stage, +} + +impl GameVersion { + /// 解析版本串;无法识别(如 `20w14a`)时返回 [`GameVersion::unknown`]。 + pub fn parse(raw: &str) -> Self { + let trimmed = raw.trim(); + let (base, stage) = split_stage(trimmed); + let parts = base + .split('.') + .map_while(|piece| piece.parse::().ok()) + .collect::>(); + if parts.is_empty() { + return Self::unknown(trimmed); + } + Self { + raw: trimmed.to_string(), + parts, + stage, + } + } + + /// 未知/无法解析的版本(比较结果为“不满足任何门限”,由调用方给默认分支)。 + pub fn unknown(raw: &str) -> Self { + Self { + raw: raw.to_string(), + parts: Vec::new(), + stage: Stage::Release, + } + } + + /// 原始版本串。 + pub fn raw(&self) -> &str { + &self.raw + } + + /// 是否解析出了数字版本(`false` 表示未知形状,例如周快照)。 + pub fn is_known(&self) -> bool { + !self.parts.is_empty() + } + + /// 是否是**周快照**形态(`20w14a` / `26w14a`)。 + /// + /// 周快照是「年(2 位) + `w` + 周(2 位) + 字母」的独立命名族,与 `26.2-snapshot-N` + /// 分属两套编号,**无法**可靠排序;所以 [`Self::is_known`] 对它恒为 `false` + /// (不猜,免得误触发「老版本」分支去套 legacy 参数模板)。要判能力请改看版本 JSON 的 + /// 库表([`crate::game::capability`]),别在这里编一套排序规则。 + pub fn is_weekly_snapshot(&self) -> bool { + is_weekly_snapshot_str(&self.raw) + } + + /// 主版本号(`1.20.1` → 1,`26.2` → 26)。 + pub fn major(&self) -> u32 { + self.parts.first().copied().unwrap_or(0) + } + + /// 是否 `>= other`。自身或 `other` 不可解析时返回 `false`。 + pub fn is_at_least(&self, other: &str) -> bool { + let other = Self::parse(other); + if !self.is_known() || !other.is_known() { + return false; + } + self.cmp(&other) != Ordering::Less + } + + /// 是否 `< other`。自身不可解析时返回 `false`(未知版本不触发“老版本”分支)。 + pub fn is_before(&self, other: &str) -> bool { + let other = Self::parse(other); + if !self.is_known() || !other.is_known() { + return false; + } + self.cmp(&other) == Ordering::Less + } + + /// 去掉尾部 0 后的分量序列(`1.20.0` 与 `1.20` 等价)。 + fn trimmed_parts(&self) -> &[u32] { + let mut end = self.parts.len(); + while end > 0 && self.parts[end - 1] == 0 { + end -= 1; + } + &self.parts[..end] + } +} + +impl Ord for GameVersion { + fn cmp(&self, other: &Self) -> Ordering { + self.trimmed_parts() + .cmp(other.trimmed_parts()) + .then_with(|| self.stage.cmp(&other.stage)) + } +} + +impl PartialOrd for GameVersion { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl fmt::Display for GameVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.raw) + } +} + +impl Default for GameVersion { + fn default() -> Self { + Self::unknown("") + } +} + +fn split_stage(raw: &str) -> (&str, Stage) { + for marker in ["-snapshot", " snapshot"] { + if let Some(pos) = raw.find(marker) { + let number = trailing_number(&raw[pos + marker.len()..]); + return (&raw[..pos], Stage::Snapshot(number)); + } + } + for marker in ["-pre", "-preview"] { + if let Some(pos) = raw.find(marker) { + let number = trailing_number(&raw[pos + marker.len()..]); + return (&raw[..pos], Stage::PreRelease(number)); + } + } + if let Some(pos) = raw.find("-rc") { + let number = trailing_number(&raw[pos + 3..]); + return (&raw[..pos], Stage::ReleaseCandidate(number)); + } + (raw, Stage::Release) +} + +fn trailing_number(text: &str) -> u32 { + let digits = text.trim_start_matches(['-', '.', '_']); + digits + .split(|ch: char| !ch.is_ascii_digit()) + .next() + .and_then(|piece| piece.parse::().ok()) + .unwrap_or(0) +} + +/// `20w14a` / `26w14a` 形态判定(纯函数,便于单测)。 +fn is_weekly_snapshot_str(raw: &str) -> bool { + let bytes = raw.trim().as_bytes(); + bytes.len() == 6 + && bytes[..2].iter().all(u8::is_ascii_digit) + && bytes[2].eq_ignore_ascii_case(&b'w') + && bytes[3..5].iter().all(u8::is_ascii_digit) + && bytes[5].is_ascii_alphabetic() +} diff --git a/crates/core/src/launch/jvm_args.rs b/crates/core/src/launch/jvm_args.rs new file mode 100644 index 0000000..0794cb2 --- /dev/null +++ b/crates/core/src/launch/jvm_args.rs @@ -0,0 +1,149 @@ +//! JVM 参数段(`analyse/02` §4.1 的第 8~17 项)。 +//! +//! 这一段全部走“默认值抑制”:用户/清单显式写过的键我们一律不覆盖。 +//! 返回最终生效的日志解码编码——它要传给日志泵(`analyse/02` §4.4)。 + +use crate::launch::args_table::AssemblyWarning; +use crate::launch::command_builder::CommandBuilder; +use crate::launch::defaults; +use crate::launch::platform::{OsFamily, PlatformCtx}; +use crate::launch::request::LaunchRequest; + +/// 应用第 8~17 项,返回(日志解码编码,告警)。 +pub(crate) fn apply_jvm_arguments( + req: &LaunchRequest, + ctx: &PlatformCtx, + builder: &mut CommandBuilder, +) -> (String, Vec) { + let mut warnings = Vec::new(); + let java = &req.java; + + // 第 8 项:编码(`-Dfile.encoding=` + stdout/stderr 编码)。 + let requested = match req.encoding.as_deref() { + Some(value) if !value.eq_ignore_ascii_case("COMPAT") => value.to_string(), + _ => ctx.native_charset.clone(), + }; + let existing = builder.add_default("-Dfile.encoding=", &requested); + let mut encoding = existing + .as_deref() + .and_then(|arg| arg.strip_prefix("-Dfile.encoding=")) + .unwrap_or(&requested) + .to_string(); + if encoding.eq_ignore_ascii_case("COMPAT") { + encoding = ctx.native_charset.clone(); + } + builder.add_all_default(&defaults::stream_encoding_args(java.major, &encoding)); + + // 第 9 项:Log4j2 RCE 缓解三件套。 + builder.add_all_default(&defaults::log4j_mitigation_args()); + + // 第 10 项:formatMsgNoLookups + 日志配置注入。 + let lookups = builder.add_default("-Dlog4j2.formatMsgNoLookups=", "true"); + let user_disabled_lookups = lookups.as_deref() == Some("-Dlog4j2.formatMsgNoLookups=false"); + if req.log4j.enabled && (!user_disabled_lookups || req.log4j.debug) { + match &req.log4j.config_file { + Some(path) => { + let value = path.display().to_string(); + let _ = builder.add_default("-Dlog4j.configurationFile=", &value); + } + None => warnings.push(AssemblyWarning::Log4jConfigMissing), + } + } + + // 第 11 项:品牌/特性钩子注入的额外 JVM 参数。 + for arg in &req.extra_jvm { + builder.add(arg); + } + + // 第 12 项:主 jar、macOS dock、`-Duser.home`(仅非 Windows)。 + let primary_jar = req.dirs.primary_jar.display().to_string(); + let _ = builder.add_default("-Dminecraft.client.jar=", &primary_jar); + if ctx.os == OsFamily::MacOs && !req.launcher.name.is_empty() { + let name = req.launcher.name.clone(); + let _ = builder.add_default("-Xdock:name=", &name); + } + if ctx.os != OsFamily::Windows { + if let Some(home) = &ctx.home_dir { + let value = home.display().to_string(); + let _ = builder.add_default("-Duser.home=", &value); + } + } + + // 第 13 项:代理(用户已写任意代理参数则整体跳过,`analyse/02` §4.1 #13)。 + if let Some(proxy) = &req.proxy { + if user_has_proxy_args(req) { + warnings.push(AssemblyWarning::ProxyJvmArgsSkipped); + } else { + apply_proxy(builder, proxy); + } + } + + // 第 14 项:自动优化。 + if req.optimization.enabled { + let args = defaults::optimization_args( + java.major, + java.bits, + req.optimization.physical_memory_mb, + user_specified_gc(req), + ); + builder.add_all_default(&args); + } + + // 第 15 项:版本特定开关。 + builder.add_all_default(&defaults::version_specific_args(java.major)); + + // 第 16 项:FML 证书豁免。 + if req.mod_loader.is_fml_like() { + builder.add_all_default(&defaults::fml_args()); + } + + // 第 17 项:渲染器。 + builder.add_all_default(&defaults::renderer_args(&req.renderer)); + + (encoding, warnings) +} + +fn apply_proxy(builder: &mut CommandBuilder, proxy: &crate::launch::request::SocksProxy) { + let port = proxy.port.to_string(); + for key in ["http.proxyHost", "https.proxyHost", "socksProxyHost"] { + let _ = builder.add_default(&format!("-D{key}="), &proxy.host); + } + for key in ["http.proxyPort", "https.proxyPort", "socksProxyPort"] { + let _ = builder.add_default(&format!("-D{key}="), &port); + } + if let Some(username) = &proxy.username { + for key in ["http.proxyUser", "https.proxyUser"] { + let _ = builder.add_default(&format!("-D{key}="), username); + } + } + if let Some(password) = &proxy.password { + for key in ["http.proxyPassword", "https.proxyPassword"] { + let _ = builder.add_default(&format!("-D{key}="), password); + } + } +} + +fn user_has_proxy_args(req: &LaunchRequest) -> bool { + req.user + .override_jvm + .iter() + .chain(req.user.jvm.iter()) + .any(|arg| arg.starts_with("-D") && arg.to_ascii_lowercase().contains("proxy")) +} + +fn user_specified_gc(req: &LaunchRequest) -> bool { + req.user + .override_jvm + .iter() + .chain(req.user.jvm.iter()) + .chain( + req.manifest + .jvm + .iter() + .flat_map(|entry| entry.values.iter()), + ) + .any(|arg| { + let lower = arg.to_ascii_lowercase(); + lower.starts_with("-xx:+use") || lower.starts_with("-xx:-use") + }) +} diff --git a/crates/core/src/launch/launch_session.rs b/crates/core/src/launch/launch_session.rs new file mode 100644 index 0000000..529a406 --- /dev/null +++ b/crates/core/src/launch/launch_session.rs @@ -0,0 +1,329 @@ +//! 运行中的启动会话与收尾流水线。 +//! +//! 一次启动的生命周期(`DevDocs/PlanDoc/06-launch-and-diagnostics.md` §1 的第 7~10 阶段): +//! `spawn` → 用户可能 [`LaunchSession::stop`] → [`LaunchSession::finish`] +//! (等进程 → 等日志泵(有预算)→ 退出分析 → 扫崩溃现场 → 诊断挂钩 → 写事件 + 索引)。 + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::SystemTime; + +use serde_json::Value; + +use crate::launch::args_table::AssembledCommand; +use crate::launch::crash_scan::{self, CrashArtifact}; +use crate::launch::diagnosis_hook::{DiagnosisHook, DiagnosisRequest}; +use crate::launch::error::Result; +use crate::launch::exit_analysis::{self, ExitAnalysis, ExitSignals}; +use crate::launch::launcher::LauncherConfig; +use crate::launch::process::{self, RunningProcess}; +use crate::launch::pump::{LineSink, LogLine}; +use crate::launch::redact::Redactor; +use crate::launch::request::LaunchRequest; +use crate::launch::session::{EndReason, SessionSink}; +use crate::launch::session_index::SessionIndexEntry; + +/// 一次运行中的启动会话。 +pub struct LaunchSession { + config: LauncherConfig, + hook: Arc, + redactor: Redactor, + request: LaunchRequest, + session_id: String, + log_path: PathBuf, + command: AssembledCommand, + pid: u32, + process: RunningProcess, + sink: Arc, + started_wall: SystemTime, + killed: bool, +} + +impl std::fmt::Debug for LaunchSession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LaunchSession") + .field("session_id", &self.session_id) + .field("pid", &self.pid) + .field("log_path", &self.log_path) + .finish() + } +} + +impl LaunchSession { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + config: LauncherConfig, + hook: Arc, + redactor: Redactor, + request: LaunchRequest, + session_id: String, + log_path: PathBuf, + command: AssembledCommand, + pid: u32, + process: RunningProcess, + sink: Arc, + ) -> Self { + Self { + config, + hook, + redactor, + request, + session_id, + log_path, + command, + pid, + process, + sink, + started_wall: SystemTime::now(), + killed: false, + } + } + + /// 进程号。 + pub fn pid(&self) -> u32 { + self.pid + } + + /// 会话 id。 + pub fn session_id(&self) -> &str { + &self.session_id + } + + /// 事件流文件路径。 + pub fn log_path(&self) -> &Path { + &self.log_path + } + + /// 组装结果。 + pub fn command(&self) -> &AssembledCommand { + &self.command + } + + /// 是否已请求停止。 + pub fn is_killed(&self) -> bool { + self.killed + } + + /// 日志 sink(可再叠加 UI/测试消费者,见 [`crate::launch::sink`])。 + pub fn sink(&self) -> Arc { + self.sink.clone() as Arc + } + + /// 已运行毫秒数。 + pub fn uptime_ms(&self) -> u64 { + self.process.uptime_ms() + } + + /// 用户/看门狗请求停止:三段式回收进程树,并记 `killed` 事件。 + pub fn stop(&mut self) -> Result<()> { + if self.killed { + return Ok(()); + } + self.killed = true; + if let Some(result) = self.sink.with_writer(|writer| writer.killed("user", None)) { + let _ = result; + } + self.process.stop() + } + + /// 收尾:等进程 → 等日志泵 → 退出分析 → 现场扫描 → 诊断 → 事件 + 索引。 + pub fn finish(mut self) -> Result { + let status = self.process.wait()?; + let code = process::exit_code(&status); + // uptime 必须在等日志泵之前取(泵的等待预算不算进游戏存活时间)。 + let uptime_ms = self.process.uptime_ms(); + let verdict = self.process.supervise(); + let pump_stalled = !verdict.is_complete(); + let lines = self.process.lines(); + + let mut signals = ExitSignals::new(code, uptime_ms, &lines); + signals.killed = self.killed; + signals.is_posix = self.config.platform.os.is_posix(); + let exit = exit_analysis::analyze(&signals); + + let artifacts = self.collect_artifacts(); + // 脱敏只算一次:诊断包与 `SessionHandle::redacted_argv()` 共用。 + let redacted_argv = self.redactor.scrub_all(&self.command.argv()); + let diagnosis_request = self.build_diagnosis_request( + &exit, + &lines, + &artifacts, + uptime_ms, + redacted_argv.clone(), + ); + let should_diagnose = exit.needs_diagnosis || !artifacts.is_empty(); + let diagnosis = if should_diagnose { + self.hook.analyze(&diagnosis_request) + } else { + None + }; + + let mut event_write_errors = Vec::new(); + let mut diagnostics = None; + let mut index = None; + let end_reason = if self.killed { + EndReason::Killed + } else { + EndReason::Exit + }; + let completed = self.sink.with_writer(|writer| { + if let Err(err) = writer.exit(&exit) { + event_write_errors.push(err.to_string()); + } + for artifact in &artifacts { + let result = writer.crash_found( + artifact.source.as_str(), + &artifact.path, + artifact.excerpt.as_deref(), + ); + if let Err(err) = result { + event_write_errors.push(err.to_string()); + } + } + if let Some(record) = diagnosis { + if let Err(err) = writer.diagnosis(record.clone()) { + event_write_errors.push(err.to_string()); + } + diagnostics = Some(record); + } + match writer.finish(end_reason) { + Ok(entry) => index = Some(entry), + Err(err) => event_write_errors.push(err.to_string()), + } + }); + if completed.is_none() { + event_write_errors.push("会话写入器不可用(锁中毒)".to_string()); + } + + Ok(SessionHandle { + session_id: self.session_id, + log_path: self.log_path, + command: self.command, + redacted_argv, + exit, + diagnostics, + index, + artifacts, + uptime_ms, + pump_stalled, + event_write_errors, + }) + } + + fn collect_artifacts(&self) -> Vec { + if !self.config.scan_crash_reports { + return Vec::new(); + } + let run_dir = self.run_dir(); + crash_scan::scan(&run_dir, self.started_wall, self.config.max_crash_artifacts) + } + + fn build_diagnosis_request( + &self, + exit: &ExitAnalysis, + lines: &[LogLine], + artifacts: &[CrashArtifact], + uptime_ms: u64, + argv: Vec, + ) -> DiagnosisRequest { + DiagnosisRequest { + session_id: self.session_id.clone(), + instance_id: self.request.instance.id.clone(), + instance_name: self.request.instance.name.clone(), + game_version: self.request.instance.version.raw().to_string(), + exit: exit.clone(), + uptime_ms, + log_lines: lines + .iter() + .map(|line| LogLine::new(line.stream, self.redactor.scrub(&line.text).into_owned())) + .collect(), + artifacts: artifacts.to_vec(), + argv, + memory_mb: self.command.memory.max_mb, + java_major: self.request.java.major, + java_binary: self.request.java.binary.clone(), + game_dir: self.run_dir(), + } + } + + fn run_dir(&self) -> PathBuf { + if self.request.dirs.game_dir.as_os_str().is_empty() { + std::env::current_dir().unwrap_or_default() + } else { + self.request.dirs.game_dir.clone() + } + } +} + +/// 一次启动的结果。 +#[derive(Debug, Clone)] +pub struct SessionHandle { + /// 会话 id。 + pub session_id: String, + /// 事件流文件。 + pub log_path: PathBuf, + /// 组装结果(`argv()` 含凭据明文;对外展示/上报请用 [`Self::redacted_argv`])。 + pub command: AssembledCommand, + /// 脱敏后的完整 argv(accessToken → ``,主目录 → ``)。 + redacted_argv: Vec, + /// 退出判定。 + pub exit: ExitAnalysis, + /// 诊断结论(M15 产出,已写入事件流)。 + pub diagnostics: Option, + /// 索引条目(写入 `index.jsonl` 的那一行)。 + pub index: Option, + /// 崩溃现场文件。 + pub artifacts: Vec, + /// 游戏存活毫秒数。 + pub uptime_ms: u64, + /// 日志泵是否超时(watchdog 触发 = 有子进程没关 stdout)。 + pub pump_stalled: bool, + /// 会话写入错误(磁盘问题;不影响进程结果)。 + pub event_write_errors: Vec, +} + +impl SessionHandle { + /// 脱敏后的完整 argv(UI/日志/上报都用它,别直接外发 `command.argv()`)。 + pub fn redacted_argv(&self) -> &[String] { + &self.redacted_argv + } + + /// 脱敏后的一行人读命令行。 + pub fn redacted_display(&self) -> String { + self.redacted_argv + .iter() + .map(|arg| crate::launch::command_builder::quote_for_display(arg)) + .collect::>() + .join(" ") + } + + /// 是否“正常且无异常”:退出判定 NORMAL 且没有静默失败标记。 + pub fn succeeded(&self) -> bool { + !self.exit.exit_type.is_abnormal() && self.exit.silent_failure.is_none() + } + + /// 是否需要进诊断(等价于 `exit.needs_diagnosis`,保留给上层判空)。 + pub fn needs_diagnosis(&self) -> bool { + self.exit.needs_diagnosis + } + + /// 一句话摘要。 + pub fn summary(&self) -> String { + let mut text = format!("会话 {}:{}", self.session_id, self.exit.summary()); + if self.pump_stalled { + text.push_str(";日志泵超时"); + } + if let Some(top) = &self.diagnostics { + if let Some(id) = top + .get("candidates") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(|item| item.get("id")) + .and_then(Value::as_str) + { + text.push_str(&format!(";诊断首因 {id}")); + } + } + text + } +} diff --git a/crates/core/src/launch/launcher.rs b/crates/core/src/launch/launcher.rs new file mode 100644 index 0000000..5abe519 --- /dev/null +++ b/crates/core/src/launch/launcher.rs @@ -0,0 +1,298 @@ +//! 顶层启动管线:组装 → 会话 → 进程 → 退出分析 → 诊断 → 索引。 +//! +//! 对应 `DevDocs/PlanDoc/06-launch-and-diagnostics.md` §1 的 10 阶段: +//! 1~2(快照/账户)由调用方与 `adapter` 完成,3(Java 选择)由 D13 给出 `JavaSelection`, +//! 4(组件就绪检查)由 download/game 给出 `missing_components`,6(环境准备的 natives 段) +//! 由 [`crate::launch::natives`] 在本模块收口,5、7~10 见下。 +//! +//! 两个入口: +//! - [`Launcher::dry_run`](D18 接线点):只组装、不 spawn、不写会话文件,返回完整命令行; +//! - [`Launcher::launch`]:完整跑一局,返回 [`SessionHandle`](退出判定 + 诊断结论 + 索引条目)。 +//! +//! 阶段 B 说明:diagnose(M15)尚未合并,诊断走 [`DiagnosisHook`] 接缝,默认 [`NoopDiagnosis`]。 + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; + +use crate::launch::args_table::{assemble, AssembledCommand}; +use crate::launch::diagnosis_hook::{DiagnosisHook, NoopDiagnosis}; +use crate::launch::error::{AssemblyError, Result}; +use crate::launch::launch_session::{LaunchSession, SessionHandle}; +use crate::launch::natives::{self, NativesReport}; +use crate::launch::platform::PlatformCtx; +use crate::launch::process::{ProcessConfig, RunningProcess}; +use crate::launch::redact::Redactor; +use crate::launch::request::LaunchRequest; +use crate::launch::session::{EndReason, SessionMeta, SessionSink, SessionWriter}; + +/// 启动器配置。 +#[derive(Debug, Clone)] +pub struct LauncherConfig { + /// 会话文件根目录(`/agent/sessions`)。 + pub sessions_root: PathBuf, + /// 平台上下文(命令行分支 + 退出码语义)。 + pub platform: PlatformCtx, + /// 进程管理配置(看门狗/宽限期/环形缓冲)。 + pub process: ProcessConfig, + /// 启动器名(`${launcher_name}`,请求里非空时以请求为准)。 + pub launcher_name: String, + /// 启动器版本。 + pub launcher_version: String, + /// 用户主目录(脱敏用)。 + pub home_dir: Option, + /// 结束后是否扫描崩溃现场文件。 + pub scan_crash_reports: bool, + /// 现场文件最多收几个。 + pub max_crash_artifacts: usize, +} + +impl LauncherConfig { + /// 以会话根目录构造(其余取默认)。 + pub fn new(sessions_root: impl Into) -> Self { + let platform = PlatformCtx::current(); + Self { + sessions_root: sessions_root.into(), + home_dir: platform.home_dir.clone(), + platform, + process: ProcessConfig::default(), + launcher_name: "UnluckyLauncher".to_string(), + launcher_version: "0.1.0".to_string(), + scan_crash_reports: true, + max_crash_artifacts: 4, + } + } + + /// 补启动器名/版本。 + pub fn with_launcher(mut self, name: impl Into, version: impl Into) -> Self { + self.launcher_name = name.into(); + self.launcher_version = version.into(); + self + } +} + +/// `--dry-run` 的输出(D18):完整命令行,但**不 spawn、不写会话文件**。 +/// +/// 会做的事与真实启动**一致到「环境准备」为止**:natives 解压照做(幂等,`natives` 字段 +/// 回报结果),因为「装完能不能起」的一半答案就在「native 库有没有落地」上。 +#[derive(Debug, Clone)] +pub struct DryRun { + /// 实际要执行的程序。 + pub program: PathBuf, + /// 参数(已脱敏:accessToken 一定是 ``)。 + pub argv: Vec, + /// 人读单行(可直接 `println!`,含空格的参数带引号)。 + pub display: String, + /// 组装告警。 + pub warnings: Vec, + /// classpath 条目数。 + pub classpath_entries: usize, + /// 日志解码编码。 + pub encoding: String, + /// natives 解压结果(没有 natives 目录、即请求未配置时为 `None`)。 + pub natives: Option, +} + +impl DryRun { + /// 一行命令行(D18 打印用)。 + pub fn line(&self) -> &str { + &self.display + } +} + +/// 启动器。 +#[derive(Clone)] +pub struct Launcher { + config: LauncherConfig, + hook: Arc, +} + +impl std::fmt::Debug for Launcher { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Launcher") + .field("config", &self.config) + .field("hook", &"") + .finish() + } +} + +impl Launcher { + /// 构造(无诊断挂钩)。 + pub fn new(config: LauncherConfig) -> Self { + Self { + config, + hook: Arc::new(NoopDiagnosis), + } + } + + /// 接上诊断挂钩(M15 适配器)。 + pub fn with_diagnosis_hook(mut self, hook: Arc) -> Self { + self.hook = hook; + self + } + + /// 配置。 + pub fn config(&self) -> &LauncherConfig { + &self.config + } + + /// 只组装命令行(纯计算,可单测/可透传)。 + pub fn plan(&self, request: &LaunchRequest) -> Result { + assemble(request, &self.config.platform) + } + + /// 环境准备(管线第 6 段):natives 解压。 + /// + /// 目标目录取 `request.dirs.natives_dir`(与 `${natives_directory}` 同一真相源); + /// 目录为空视为调用方不需要,返回 `Ok(None)`。 + fn prepare_environment(&self, request: &LaunchRequest) -> Result> { + if request.dirs.natives_dir.as_os_str().is_empty() { + return Ok(None); + } + natives::extract(&request.dirs.natives_dir, &request.natives) + .map(Some) + .map_err(|err| AssemblyError::NativesExtraction { + message: err.message(), + }) + .map_err(Into::into) + } + + /// D18:组装完整命令行并返回,**不 spawn 进程、不写会话文件**。 + /// + /// 供 `unlucky-launcher --dry-run ` 打印一行命令行后退出; + /// accessToken 已脱敏(绝不把凭据打到终端/日志里)。 + pub fn dry_run(&self, request: &LaunchRequest) -> Result { + let natives = self.prepare_environment(request)?; + let command = self.plan(request)?; + let redactor = self.redactor(request); + let warnings = command + .warnings + .iter() + .map(ToString::to_string) + .collect::>(); + Ok(DryRun { + program: command.program.clone(), + argv: redactor.scrub_all(&command.argv()), + display: redactor.scrub(&command.display()).into_owned(), + warnings, + classpath_entries: command.classpath.len(), + encoding: command.encoding.clone(), + natives, + }) + } + + /// 启动一局并返回会话(可中途 [`LaunchSession::stop`])。 + pub fn spawn(&self, request: &LaunchRequest) -> Result { + let redactor = self.redactor(request); + let meta = self.session_meta(request); + let mut writer = SessionWriter::create(&self.config.sessions_root, meta, redactor.clone())?; + let session_id = writer.session_id().to_string(); + let log_path = writer.path().to_path_buf(); + writer.session_start()?; + + // 阶段 6「环境准备」:natives 必须落到 java.library.path 指向的目录,且在组装前完成 + // (HMCL 同序),失败即启动前失败——native 库缺了游戏只会给 UnsatisfiedLinkError。 + let natives = match self.prepare_environment(request) { + Ok(report) => report, + Err(err) => { + let _ = writer.assembly_failed(&launch_error_assembly(&err)); + let _ = writer.finish(EndReason::AssemblyFailed); + return Err(err); + } + }; + if let Some(report) = &natives { + writer.natives_extracted(report)?; + } + + let command = match self.plan(request) { + Ok(command) => command, + Err(err) => { + // 启动前失败也要留下证据:assembly_failed + session_end + 索引。 + let _ = writer.assembly_failed(&launch_error_assembly(&err)); + let _ = writer.finish(EndReason::AssemblyFailed); + return Err(err); + } + }; + writer.cmd_built(&command)?; + + let sink = Arc::new(SessionSink::with_start(writer, Instant::now())); + let working_dir = if request.dirs.game_dir.as_os_str().is_empty() { + None + } else { + Some(request.dirs.game_dir.as_path()) + }; + let process = + match RunningProcess::spawn(&command, working_dir, sink.clone(), self.config.process) { + Ok(process) => process, + Err(err) => { + let _ = sink.with_writer(|writer| writer.finish(EndReason::SpawnFailed)); + return Err(err); + } + }; + let pid = process.pid(); + if let Some(result) = sink.with_writer(|writer| writer.proc_start(pid)) { + result?; + } + Ok(LaunchSession::new( + self.config.clone(), + self.hook.clone(), + redactor, + request.clone(), + session_id, + log_path, + command, + pid, + process, + sink, + )) + } + + /// 启动并阻塞到进程结束(P0 主入口)。 + pub fn launch(&self, request: &LaunchRequest) -> Result { + self.spawn(request)?.finish() + } + + fn redactor(&self, request: &LaunchRequest) -> Redactor { + let mut redactor = Redactor::new().with_secret(request.auth.access_token.clone()); + if let Some(home) = &self.config.home_dir { + redactor = redactor.with_home(home); + } + if !request.auth.uuid.is_empty() { + redactor = redactor.with_secret(request.auth.uuid.clone()); + } + redactor + } + + fn session_meta(&self, request: &LaunchRequest) -> SessionMeta { + let launcher_name = if request.launcher.name.is_empty() { + self.config.launcher_name.clone() + } else { + request.launcher.name.clone() + }; + let launcher_version = if request.launcher.version.is_empty() { + self.config.launcher_version.clone() + } else { + request.launcher.version.clone() + }; + SessionMeta::new( + request.instance.id.clone(), + request.instance.name.clone(), + request.instance.version.raw().to_string(), + ) + .with_launcher(launcher_name, launcher_version) + .with_platform(&self.config.platform) + } +} + +/// 把错误拆成 `assembly_failed` 事件需要的结构化原因。 +pub(crate) fn launch_error_assembly( + error: &crate::launch::error::LaunchError, +) -> crate::launch::error::AssemblyError { + match error { + crate::launch::error::LaunchError::Assembly(assembly) => assembly.clone(), + other => crate::launch::error::AssemblyError::MalformedArgument { + entry: other.to_string(), + reason: "非组装类失败", + }, + } +} diff --git a/crates/core/src/launch/log_level.rs b/crates/core/src/launch/log_level.rs new file mode 100644 index 0000000..5fa1a95 --- /dev/null +++ b/crates/core/src/launch/log_level.rs @@ -0,0 +1,114 @@ +//! 日志级别猜测与“关键行”判定。 +//! +//! 平移自 `analyse/02` §6.4(`Log4jLevel.guessLevel`):先认 log4j 注入的 +//! `[HH:mm:ss] [thread/LEVEL]` 格式,再退到 JUL 的 `[INFO]` 形态;`stderr` 一律当 +//! ERROR(`[authlib-injector]` 例外)。 +//! +//! 目的有两个:给事件流打 `level`,以及让环形缓冲能“关键行永久保留” +//! (`analyse/02` §7.10-3:2000 行截断会把根因行挤掉)。 + +/// 日志级别(认知顺序:Error 最严重)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Level { + /// 错误。 + Error, + /// 警告。 + Warn, + /// 信息。 + Info, + /// 调试。 + Debug, + /// 无法判断。 + Unknown, +} + +impl Level { + /// 事件流里用的字符串形式。 + pub fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warn => "warn", + Self::Info => "info", + Self::Debug => "debug", + Self::Unknown => "unknown", + } + } + + /// 是否错误级(退出分析筛行用)。 + pub fn is_error(self) -> bool { + self == Self::Error + } +} + +/// 猜测一行日志的级别。 +pub fn guess(is_stderr: bool, line: &str) -> Level { + let trimmed = line.trim_start(); + if is_stderr { + // authlib-injector 往 stderr 打的都是正常信息(`analyse/02` §6.4)。 + if trimmed.starts_with("[authlib-injector]") { + return Level::Info; + } + return Level::Error; + } + if let Some(level) = log4j_level(trimmed) { + return level; + } + jul_level(trimmed).unwrap_or(Level::Unknown) +} + +/// 是否“关键行”:命中这些特征的行不随环形淘汰(`Exception|Error|Caused by|OutOfMemory`)。 +pub fn is_key_line(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.contains("exception") + || lower.contains("error") + || lower.contains("caused by") + || lower.contains("outofmemory") + || lower.contains("fatal") +} + +/// 从 `[12:34:56] [Render thread/INFO]: ...` 里取级别。 +/// +/// 逐段扫描方括号内容,取第一段形如 `<线程名>/` 的级别,最多看 3 段。 +fn log4j_level(line: &str) -> Option { + let mut rest = line; + for _ in 0..3 { + let open = rest.find('[')?; + let tail = &rest[open + 1..]; + let close = tail.find(']')?; + let segment = &tail[..close]; + if let Some(slash) = segment.rfind('/') { + if let Some(level) = level_from_name(&segment[slash + 1..]) { + return Some(level); + } + } + rest = &tail[close + 1..]; + } + None +} + +fn level_from_name(name: &str) -> Option { + match name.trim() { + "ERROR" | "FATAL" => Some(Level::Error), + "WARN" | "WARNING" => Some(Level::Warn), + "INFO" | "NOTICE" => Some(Level::Info), + "DEBUG" | "TRACE" | "FINE" | "FINER" | "FINEST" => Some(Level::Debug), + _ => None, + } +} + +/// JUL/裸标记形态:`[INFO]`、`[SEVERE]`、`[WARNING]`。 +fn jul_level(line: &str) -> Option { + const MARKERS: &[(&str, Level)] = &[ + ("[SEVERE]", Level::Error), + ("[ERROR]", Level::Error), + ("[WARNING]", Level::Warn), + ("[WARN]", Level::Warn), + ("[INFO]", Level::Info), + ("[DEBUG]", Level::Debug), + ("[FINE]", Level::Debug), + ]; + MARKERS + .iter() + .find(|(marker, _)| line.starts_with(marker)) + .map(|(_, level)| *level) +} diff --git a/crates/core/src/launch/mod.rs b/crates/core/src/launch/mod.rs new file mode 100644 index 0000000..02db942 --- /dev/null +++ b/crates/core/src/launch/mod.rs @@ -0,0 +1,75 @@ +//! 启动管线:10 阶段命令行组装、Java 选择、子进程管理、结构化 JSONL 事件流。 +//! +//! 设计定稿:`DevDocs/PlanDoc/06-launch-and-diagnostics.md` §1(管线)与 §3(事件流), +//! Java 侧约束见 `DevDocs/PlanDoc/01d-language-choice.md` §3(JDK 17 基准 + 版本映射提示)。 +//! +//! 事件流是 core → app 的唯一启动反馈通道(另一条是快照发布), +//! 不做 200ms 批量刷 UI 式的隐式耦合。 +//! +//! # 公共面 +//! +//! - [`Launcher`]:`dry_run`(D18,只组装命令行)、`spawn`/`launch`(完整管线); +//! - [`LaunchRequest`] 是命令行组装的**纯数据输入**,由 [`adapter`] 从 game 快照映射而来; +//! - [`AssembledCommand`] 是组装结果(program + argv + classpath + 环境变量 + 告警); +//! - 事件流([`session`])与索引([`session_index`])是 M15 诊断采集器与 +//! M16 `read_game_log`/`list_game_sessions` 的数据源,schema 见 +//! `DevDocs/SubAgentsTalk/launch-接口约定.md`; +//! - [`DiagnosisHook`] 是接 M15 的唯一接缝(launch 报事实、M15 下结论)。 +//! +//! # 与 HMCL 的三处差异(`analyse/02` §8.5) +//! +//! 1. `exit 0` 静默失败单独立标([`exit_analysis`]); +//! 2. 日志泵 join 有预算([`watchdog`]),进程树三段式回收([`tree_kill`],P1 换 Job Object); +//! 3. 窗口出现检测带启发式名称/耗时/证据([`window_detect`])。 +//! +//! 零 UI 依赖:本模块只产出结构化事件与纯数据,不 import 任何 UI 类型。 + +pub mod adapter; +pub mod args_table; +pub mod argument; +pub mod command_builder; +pub mod crash_scan; +pub mod defaults; +pub mod diagnosis_hook; +pub mod error; +pub mod exit_analysis; +pub mod game_args; +pub mod game_version; +pub mod jvm_args; +pub mod launch_session; +pub mod launcher; +pub mod log_level; +pub mod natives; +pub mod placeholders; +pub mod platform; +pub mod process; +pub mod pump; +pub mod redact; +pub mod request; +pub mod session; +pub mod session_index; +pub mod session_meta; +pub mod session_payload; +pub mod session_sink; +pub mod sink; +pub mod time; +pub mod tree_kill; +pub mod watchdog; +pub mod window_detect; + +pub use adapter::{ + build_request, AdapterOptions, AdapterOutput, AdapterWarning, LibraryInput, SnapshotInput, +}; +pub use args_table::{assemble, AssembledCommand, AssemblyWarning}; +pub use crash_scan::{CrashArtifact, CrashSource}; +pub use diagnosis_hook::{DiagnosisHook, DiagnosisRequest, NoopDiagnosis}; +pub use error::{LaunchError, MissingComponent, Result}; +pub use exit_analysis::{ExitAnalysis, ExitType, SilentFailure}; +pub use launch_session::{LaunchSession, SessionHandle}; +pub use launcher::{DryRun, Launcher, LauncherConfig}; +pub use natives::{NativeJar, NativesEra, NativesPlan, NativesReport}; +pub use process::{ProcessConfig, RunningProcess}; +pub use request::LaunchRequest; +pub use session::{EndReason, SessionMeta, SessionSink, SessionWriter}; +pub use session_index::SessionIndexEntry; +pub use sink::{CompositeSink, CountingSink, FnSink}; diff --git a/crates/core/src/launch/natives/jar.rs b/crates/core/src/launch/natives/jar.rs new file mode 100644 index 0000000..5e8c559 --- /dev/null +++ b/crates/core/src/launch/natives/jar.rs @@ -0,0 +1,250 @@ +//! natives jar 的读取细节:zip 遍历、条目筛选、目录清理、戳记读写。 +//! +//! 与 [`super`] 的分工:`super` 管「计划与报告」,这里管「怎么把一个 jar 里的原生库落到盘上」。 + +use std::fs::{self, File}; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; + +use serde::{Deserialize, Serialize}; +use zip::ZipArchive; + +use super::{NativeJar, NativesError, NativesReport, STAMP_FILE}; + +/// 戳记 schema 版本(改字段语义才升)。 +const STAMP_VERSION: u8 = 1; + +/// 内置排除前缀:Mojang 清单惯例,manifest 没写 `extract` 规则时也这么办。 +const DEFAULT_EXCLUDES: &[&str] = &["META-INF/"]; + +/// 原生库扩展名白名单(Windows / Linux / macOS;`jnilib` 是 JDK6 时代的老 mac 后缀)。 +const NATIVE_EXTENSIONS: &[&str] = &["dll", "so", "dylib", "jnilib"]; + +/// 签名 / 校验附属文件扩展名:即使在 natives jar 根目录也绝不落地。 +const SIGNATURE_EXTENSIONS: &[&str] = &["sf", "rsa", "dsa", "ec", "asc", "sha1", "sha256", "git"]; + +/// 戳记的一条:源 jar 的「路径 + 字节数 + mtime」指纹。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct JarStamp { + /// 源 jar 绝对路径(同一目录换了别的版本时必须能区分开)。 + pub path: String, + /// 字节数。 + pub len: u64, + /// 修改时间(Unix 毫秒;读不到时 0,等价于「每次都当它变了」)。 + pub mtime_ms: u64, +} + +/// 目录戳记文件内容。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct Stamp { + v: u8, + files: usize, + jars: Vec, +} + +impl Stamp { + /// 戳记是否仍然对应当前的 jar 集(版本 + 指纹逐一相等)。 + pub(crate) fn matches(&self, stamps: &[JarStamp]) -> bool { + self.v == STAMP_VERSION && self.jars == stamps + } + + /// 上次解压的文件数。 + pub(crate) fn files(&self) -> usize { + self.files + } +} + +/// 构造 `NativesError::Io`。 +pub(crate) fn io_error( + op: &'static str, + path: impl Into, + source: io::Error, +) -> NativesError { + NativesError::Io { + op, + path: path.into(), + source, + } +} + +/// 解压单个 jar:排除前缀 → 原生库白名单 → zip-slip 防护 → 落盘。 +pub(crate) fn extract_jar( + native: &NativeJar, + dir: &Path, + report: &mut NativesReport, +) -> Result<(), NativesError> { + let file = File::open(&native.path) + .map_err(|source| io_error("open_native_jar", &native.path, source))?; + let mut archive = ZipArchive::new(file).map_err(|err| NativesError::Zip { + path: native.path.clone(), + reason: err.to_string(), + })?; + let excludes: Vec<&str> = DEFAULT_EXCLUDES + .iter() + .copied() + .chain(native.exclude.iter().map(String::as_str)) + .collect(); + + for index in 0..archive.len() { + let mut entry = archive.by_index(index).map_err(|err| NativesError::Zip { + path: native.path.clone(), + reason: err.to_string(), + })?; + if entry.is_dir() { + continue; + } + // `enclosed_name` 拒绝 `..` / 绝对路径条目(zip-slip)。 + let Some(relative) = entry.enclosed_name() else { + report.ignored += 1; + continue; + }; + let name = relative.to_string_lossy().replace('\\', "/"); + if !should_extract(&name, &excludes) { + report.ignored += 1; + continue; + } + let dest = dir.join(&relative); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent) + .map_err(|source| io_error("create_natives_subdir", parent, source))?; + } + let mut out = + File::create(&dest).map_err(|source| io_error("create_native_file", &dest, source))?; + io::copy(&mut entry, &mut out) + .map_err(|source| io_error("write_native_file", &dest, source))?; + report.files += 1; + } + Ok(()) +} + +/// 该条目要不要解压:排除前缀命中、签名文件、非原生库扩展名 → 跳过。 +pub(crate) fn should_extract(name: &str, excludes: &[&str]) -> bool { + if excludes.iter().any(|prefix| name.starts_with(prefix)) { + return false; + } + let lower = name.to_ascii_lowercase(); + let extension = lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or(""); + if SIGNATURE_EXTENSIONS.contains(&extension) { + return false; + } + NATIVE_EXTENSIONS.contains(&extension) +} + +/// 清掉目录里已有的原生库与戳记(尽力而为:删不掉的留着,后面覆盖写会报错)。 +pub(crate) fn clear_natives(dir: &Path) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + continue; + } + let name = path + .file_name() + .map(|name| name.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + if name == STAMP_FILE { + let _ = fs::remove_file(&path); + continue; + } + let is_native = name + .rsplit_once('.') + .map(|(_, ext)| NATIVE_EXTENSIONS.contains(&ext)) + .unwrap_or(false); + if is_native { + let _ = fs::remove_file(&path); + } + } +} + +/// 源 jar 指纹(路径 + 字节数 + mtime)。 +pub(crate) fn jar_stamps(jars: &[NativeJar]) -> Vec { + jars.iter() + .map(|native| { + let metadata = fs::metadata(&native.path); + let len = metadata.as_ref().map(|meta| meta.len()).unwrap_or(0); + let mtime_ms = metadata + .ok() + .and_then(|meta| meta.modified().ok()) + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0); + JarStamp { + path: native.path.display().to_string(), + len, + mtime_ms, + } + }) + .collect() +} + +/// 读戳记(不存在 / 损坏 / 版本不符都当作「没有戳记」)。 +pub(crate) fn read_stamp(dir: &Path) -> Option { + let text = fs::read_to_string(dir.join(STAMP_FILE)).ok()?; + let stamp: Stamp = serde_json::from_str(&text).ok()?; + if stamp.v == STAMP_VERSION { + Some(stamp) + } else { + None + } +} + +/// 写戳记(直接覆盖写:半截文件下次解析失败即视为「没有戳记」,重解一遍就行)。 +pub(crate) fn write_stamp(dir: &Path, files: usize, jars: &[JarStamp]) -> Result<(), NativesError> { + let path = dir.join(STAMP_FILE); + let stamp = Stamp { + v: STAMP_VERSION, + files, + jars: jars.to_vec(), + }; + let text = serde_json::to_string_pretty(&stamp).map_err(|err| NativesError::Zip { + path: path.clone(), + reason: err.to_string(), + })?; + fs::write(&path, text).map_err(|source| io_error("write_natives_stamp", &path, source)) +} + +#[cfg(test)] +mod tests { + use super::{should_extract, DEFAULT_EXCLUDES}; + + #[test] + fn native_library_extensions_pass() { + for name in [ + "lwjgl.dll", + "OpenAL.dll", + "liblwjgl.so", + "liblwjgl.dylib", + "liblwjgl.jnilib", + ] { + assert!(should_extract(name, DEFAULT_EXCLUDES), "{name} 应解压"); + } + } + + #[test] + fn metadata_and_signature_entries_are_skipped() { + for name in [ + "META-INF/MANIFEST.MF", + "META-INF/FOO.SF", + "META-INF/FOO.RSA", + "lwjgl.dll.sha1", + "lwjgl.dll.sha256", + "README.txt", + "LICENSE", + // 无扩展名的文件一律不进 java.library.path。 + "lwjgl", + ] { + assert!(!should_extract(name, DEFAULT_EXCLUDES), "{name} 不该解压"); + } + } + + #[test] + fn manifest_exclude_prefixes_apply_on_top_of_builtin() { + assert!(!should_extract("extra/x.dll", &["extra/"])); + assert!(should_extract("x.dll", &["extra/"])); + // 大小写不敏感只对扩展名生效(排除前缀按清单原样比较,与 HMCL 一致)。 + assert!(should_extract("X.DLL", DEFAULT_EXCLUDES)); + } +} diff --git a/crates/core/src/launch/natives/mod.rs b/crates/core/src/launch/natives/mod.rs new file mode 100644 index 0000000..a57e83c --- /dev/null +++ b/crates/core/src/launch/natives/mod.rs @@ -0,0 +1,355 @@ +//! natives 解压:把版本链里的 natives 分类器构件解到 `versions//natives/`。 +//! +//! 这是 10 阶段管线里**第 6 段「环境准备」**的落盘部分(`PlanDoc/06` §1),发生顺序在 +//! 命令行组装之前(HMCL 也在组装期做:`DefaultLauncher.decompressNatives`, +//! `analyse/03` §6.4-(7)),因此 `--dry-run` 也能验证「装完到底能不能起」。 +//! +//! # 三个时代(`SubAgentsTalk/launch-natives-解压与时代分界.md`,piston-meta 实测) +//! +//! | 版本区间 | natives 形态 | 谁负责 `-Djava.library.path` | +//! |---|---|---| +//! | ≤1.12.2(LWJGL 2.9) | `natives` 表 + `downloads.classifiers` | 启动器补(`defaults::legacy_jvm_arguments`) | +//! | 1.13–1.18.2(LWJGL 3.1/3.2) | 同上(另有同坐标的主构件条目) | 清单自己写 `arguments.jvm` | +//! | 1.19+(LWJGL 3.3) | **没有 `natives` 表**:natives jar 是普通 classpath 库 | 清单写 4 个目录属性,LWJGL 运行期自解压 | +//! +//! 所以三个时代走**同一条代码路径**:有 jar 就解压,没 jar 就只把目录建出来 +//! (1.19+ 的 `-Dorg.lwjgl.system.SharedLibraryExtractPath` 指向它;目录不存在时 +//! LWJGL 会静默走到「找不到 native」)。 +//! +//! # 幂等 +//! +//! 解压完在目录里写一份戳记 [`jar::STAMP_FILE`](每个源 jar 的路径 + 字节数 + mtime)。 +//! 下次启动:戳记与当前 jar 集完全一致 → 目录视为就绪,**不读 jar、不写盘**直接跳过; +//! 不一致(换版本 / 库升级 / 用户删了目录)→ 清掉旧原生库再全量解压,避免两代 dll +//! 混在同一个 `java.library.path` 里(HMCL 也是每次清目录,这里只把「没变就不动」补上)。 + +pub(crate) mod jar; + +use std::path::{Path, PathBuf}; + +use crate::game::arguments::Environment; +use crate::game::layout::GameLayout; +use crate::game::library::Library; +use crate::launch::natives::jar::{ + clear_natives, extract_jar, jar_stamps, read_stamp, write_stamp, +}; + +/// 戳记文件名:放在 natives 目录里,跟着目录一起删 / 建。 +/// +/// 公开出来是为了「一键修复」类功能能认出自己的痕迹(判断目录里哪些文件是我们解出来的)。 +pub const STAMP_FILE: &str = ".ull-natives.json"; + +/// 一个待解压的 natives 构件(版本链里 `natives` 分类器命中的 jar)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeJar { + /// 磁盘路径(`libraries//.../--natives-.jar`)。 + pub path: PathBuf, + /// 坐标 + 分类器(`org.lwjgl:lwjgl:3.2.2:natives-windows`,报错与事件里用)。 + pub name: String, + /// 清单 `extract.exclude` 的排除前缀(`["META-INF/"]`)。 + pub exclude: Vec, +} + +impl NativeJar { + /// 构造(无排除规则)。 + pub fn new(path: impl Into, name: impl Into) -> Self { + Self { + path: path.into(), + name: name.into(), + exclude: Vec::new(), + } + } + + /// 带清单排除规则构造。 + pub fn with_exclude( + path: impl Into, + name: impl Into, + exclude: Vec, + ) -> Self { + Self { + path: path.into(), + name: name.into(), + exclude, + } + } +} + +/// natives 处理的时代(决定「要不要解压」以及「`-Djava.library.path` 从哪来」)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum NativesEra { + /// ≤1.12.2:清单没有 `arguments.jvm`,启动器自己补 `-Djava.library.path`。 + #[default] + Legacy, + /// 1.13–1.18.2:清单有 `arguments.jvm`,natives 走分类器 jar(需要解压)。 + Lwjgl3, + /// 1.19+:natives jar 是普通 classpath 库,LWJGL 3.3 运行期自解压(只需目录存在)。 + ClasspathNative, +} + +impl NativesEra { + /// 按「清单有没有 `arguments.jvm`」+「有没有 natives 分类器 jar」判时代。 + pub fn detect(manifest_jvm_args: usize, natives_jars: usize) -> Self { + if manifest_jvm_args == 0 { + Self::Legacy + } else if natives_jars == 0 { + Self::ClasspathNative + } else { + Self::Lwjgl3 + } + } + + /// 事件/日志里的稳定字符串。 + pub fn as_str(self) -> &'static str { + match self { + Self::Legacy => "legacy", + Self::Lwjgl3 => "lwjgl3", + Self::ClasspathNative => "classpath_native", + } + } +} + +/// 解压计划(`LaunchRequest` 的一部分,纯数据)。 +/// +/// 目标目录**不在**这里:唯一真相源是 `LaunchDirs::natives_dir`(`${natives_directory}` 也用它), +/// 免得出现「参数指向 A、dll 解到 B」的静默错位。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativesPlan { + /// 待解压的 natives 分类器构件。 + pub jars: Vec, + /// 时代(只影响报告字段;解压路径三时代统一)。 + pub era: NativesEra, + /// 目录是否用户自定义(事件 payload 的 `custom` 字段,给「自备 dll 被覆盖」留证据)。 + pub custom: bool, +} + +impl Default for NativesPlan { + fn default() -> Self { + Self { + jars: Vec::new(), + era: NativesEra::ClasspathNative, + custom: false, + } + } +} + +impl NativesPlan { + /// 以 jar 清单构造:有 jar 按 [`NativesEra::Lwjgl3`],没有按 [`NativesEra::ClasspathNative`]。 + /// + /// 这一层分不出「≤1.12.2 的 LWJGL 2」与「1.13–1.18.2 的 LWJGL 3」(两者都有分类器 jar), + /// 所以适配层用 [`NativesPlan::with_era`] 明确给值;直接构造只适合测试与脚本。 + pub fn new(jars: Vec) -> Self { + let era = NativesEra::detect(usize::from(!jars.is_empty()), jars.len()); + Self { + jars, + era, + custom: false, + } + } + + /// 指定时代。 + pub fn with_era(mut self, era: NativesEra) -> Self { + self.era = era; + self + } + + /// 标记目录为用户自定义。 + pub fn with_custom(mut self, custom: bool) -> Self { + self.custom = custom; + self + } + + /// 没有任何 natives 构件(1.19+ 的常态)。 + pub fn is_empty(&self) -> bool { + self.jars.is_empty() + } +} + +/// 解压结果(进 `natives_extracted` 事件与 `--dry-run` 输出)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativesReport { + /// 目标目录。 + pub dir: PathBuf, + /// 时代。 + pub era: NativesEra, + /// 参与的 jar 数。 + pub jars: usize, + /// 本次写盘的原生库文件数。 + pub files: usize, + /// 按戳记跳过、未重新解压的文件数(= 上次解压的文件数)。 + pub skipped: usize, + /// 被排除 / 非原生库而忽略的条目数(`META-INF/`、签名文件)。 + pub ignored: usize, + /// 目录是否用户自定义。 + pub custom: bool, + /// 是否按戳记判定「目录已就绪」而整体跳过。 + pub up_to_date: bool, +} + +impl NativesReport { + /// 人读一行(CLI / 日志用)。 + pub fn summary(&self) -> String { + if self.jars == 0 { + return format!( + "natives 目录已就绪(该版本无需解压,LWJGL 运行期自取)→ {}", + self.dir.display() + ); + } + if self.up_to_date { + return format!( + "natives 已就绪({} 个原生库,{} 个 jar 未变)", + self.skipped, self.jars + ); + } + format!( + "natives 解压 {} 个原生库({} 个 jar,忽略 {} 个条目)→ {}", + self.files, + self.jars, + self.ignored, + self.dir.display() + ) + } +} + +/// 解压失败原因。 +#[derive(Debug)] +pub enum NativesError { + /// 目标目录为空(调用方漏填 `natives_dir`)。 + NoDir, + /// 文件系统操作失败。 + Io { + /// 操作名。 + op: &'static str, + /// 相关路径。 + path: PathBuf, + /// 底层错误。 + source: std::io::Error, + }, + /// natives jar 打不开 / 不是合法 zip(下载损坏的最常见形态)。 + Zip { + /// jar 路径。 + path: PathBuf, + /// 原因。 + reason: String, + }, +} + +impl NativesError { + /// 一行人读描述。 + pub fn message(&self) -> String { + match self { + Self::NoDir => "natives 目录未配置(natives_dir 为空)".to_string(), + Self::Io { op, path, source } => { + format!("{op} 失败({}):{source}", path.display()) + } + Self::Zip { path, reason } => { + format!( + "natives jar 无法作为 zip 打开({}):{reason}", + path.display() + ) + } + } + } +} + +impl std::fmt::Display for NativesError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message()) + } +} + +impl std::error::Error for NativesError {} + +/// 从版本链库表收集 natives 分类器构件(adapter / app / CLI 共用这一段,别各写一份)。 +/// +/// 口径与安装层 `game::install::libraries::plan` **除 `hint=local` 落点外**逐条对齐: +/// 1. 先按环境过滤(`Library::applies`); +/// 2. `natives` 表命中当前 OS 才取分类器(1.19+ 没有 `natives` 表 → 返回空表); +/// 3. 落点优先用清单 `downloads.classifiers..path`,缺了才按 Maven 坐标推导 +/// (`Library::native_relative_path`);`hint=local` 的库在实例私有目录里 +/// (`analyse/03` §6.8 的 HMCL 口径)。 +/// +/// ⚠️ 安装层当前的 natives 分类器分支硬编码共享 `libraries/`、不认 `hint=local`(主构件认), +/// 所以「`hint=local` + `natives` 同体」的 manifest(只有 HMCL 补丁导入才会有)会出现 +/// 「装到共享目录、解压去实例目录」→ 由 [`crate::launch::adapter::AdapterWarning::MissingNativeJar`] +/// 转成启动前 `missing_components` 显式失败。此处仍按 HMCL 权威口径实现,等 install 侧对齐。 +pub fn collect( + layout: &GameLayout, + version_id: &str, + libraries: &[Library], + environment: &Environment, +) -> Vec { + let mut natives = Vec::new(); + for library in libraries { + if !library.applies(environment) { + continue; + } + let Some(classifier) = library.native_classifier(environment) else { + continue; + }; + let Ok(relative) = library.native_relative_path(&classifier) else { + continue; + }; + // 安装层当前对 natives 不做这条分支(见上方警告);这里按 HMCL 口径落实例私有目录。 + let base = if library.is_local() { + layout.version_libraries_dir(version_id) + } else { + layout.libraries_dir() + }; + let exclude = library + .extract + .as_ref() + .map(|extract| extract.exclude.clone()) + .unwrap_or_default(); + natives.push(NativeJar::with_exclude( + base.join(relative), + format!("{}:{classifier}", library.name), + exclude, + )); + } + natives +} + +/// 解压一个计划(幂等:目录与源 jar 都没变时零写盘)。 +/// +/// # Errors +/// +/// 目录为空、建目录 / 写文件失败、jar 不是合法 zip 时返回 [`NativesError`]。 +pub fn extract(dir: &Path, plan: &NativesPlan) -> Result { + if dir.as_os_str().is_empty() { + return Err(NativesError::NoDir); + } + let mut report = NativesReport { + dir: dir.to_path_buf(), + era: plan.era, + jars: plan.jars.len(), + files: 0, + skipped: 0, + ignored: 0, + custom: plan.custom, + up_to_date: false, + }; + // 1.19+ 没有 jar 也要建目录:LWJGL 3.3 往 SharedLibraryExtractPath 里解压。 + std::fs::create_dir_all(dir) + .map_err(|source| jar::io_error("create_natives_dir", dir, source))?; + if plan.jars.is_empty() { + report.up_to_date = true; + return Ok(report); + } + + let stamps = jar_stamps(&plan.jars); + if let Some(stamp) = read_stamp(dir) { + if stamp.matches(&stamps) { + report.up_to_date = true; + report.skipped = stamp.files(); + return Ok(report); + } + } + + // 源变了:先清旧原生库(避免两代 dll 混在同一个 java.library.path 里),再全量解。 + clear_natives(dir); + for native in &plan.jars { + extract_jar(native, dir, &mut report)?; + } + write_stamp(dir, report.files, &stamps)?; + Ok(report) +} diff --git a/crates/core/src/launch/placeholders.rs b/crates/core/src/launch/placeholders.rs new file mode 100644 index 0000000..b99f42b --- /dev/null +++ b/crates/core/src/launch/placeholders.rs @@ -0,0 +1,173 @@ +//! 参数占位符字典与替换。 +//! +//! 占位符清单平移自 `analyse/02` §4.8(`DefaultLauncher.getConfigurations()`): +//! 版本清单里的 `arguments.*` 用 `${...}` 引用运行时值,启动器负责在组装时替换。 +//! 替换语义与 HMCL 一致:**单遍、不递归**;未定义的占位符原样保留(便于发现清单漏项)。 + +use std::collections::BTreeMap; +use std::path::Path; + +use crate::launch::platform::PlatformCtx; +use crate::launch::request::LaunchRequest; + +/// 我们支持的占位符键(不含 `${}` 包裹)。 +pub const PLACEHOLDER_KEYS: &[&str] = &[ + "auth_player_name", + "auth_session", + "auth_access_token", + "auth_uuid", + "user_type", + "user_properties", + "version_name", + "version_type", + "profile_name", + "game_directory", + "assets_index_name", + "game_assets", + "assets_root", + "natives_directory", + "classpath", + "classpath_separator", + "launcher_name", + "launcher_version", + "library_directory", + "libraries_directory", + "primary_jar", + "primary_jar_name", + "file_separator", + "resolution_width", + "resolution_height", + "language", +]; + +/// 占位符表。 +#[derive(Debug, Clone, Default)] +pub struct PlaceholderMap { + entries: BTreeMap, +} + +impl PlaceholderMap { + /// 空表。 + pub fn new() -> Self { + Self::default() + } + + /// 插入一个占位符(键不含 `${}`)。 + pub fn insert(&mut self, key: &str, value: impl Into) { + self.entries.insert(key.to_string(), value.into()); + } + + /// 查询。 + pub fn get(&self, key: &str) -> Option<&str> { + self.entries.get(key).map(String::as_str) + } + + /// 已登记的键数。 + pub fn len(&self) -> usize { + self.entries.len() + } + + /// 是否为空。 + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// 单遍替换 `${key}`;未定义键原样保留。 + pub fn render(&self, template: &str) -> String { + if !template.contains("${") { + return template.to_string(); + } + let mut out = String::with_capacity(template.len() + 16); + let mut rest = template; + while let Some(start) = rest.find("${") { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + match after.find('}') { + Some(end) => { + let key = &after[..end]; + match self.entries.get(key) { + Some(value) => out.push_str(value), + None => { + out.push_str("${"); + out.push_str(key); + out.push('}'); + } + } + rest = &after[end + 1..]; + } + None => { + out.push_str(&rest[start..]); + rest = ""; + } + } + } + out.push_str(rest); + out + } + + /// 由启动请求构造完整占位符表。 + pub fn from_request(req: &LaunchRequest, ctx: &PlatformCtx) -> Self { + let mut map = Self::new(); + let auth = &req.auth; + map.insert("auth_player_name", auth.player_name.clone()); + map.insert("auth_session", auth.access_token.clone()); + map.insert("auth_access_token", auth.access_token.clone()); + map.insert("auth_uuid", auth.uuid.clone()); + map.insert("user_type", auth.user_type.clone()); + map.insert("user_properties", auth.user_properties.clone()); + + map.insert("version_name", req.instance.name.clone()); + map.insert("version_type", req.instance.version_type.clone()); + map.insert("profile_name", req.instance.id.clone()); + + let dirs = &req.dirs; + map.insert("game_directory", path_str(&dirs.game_dir)); + map.insert("assets_index_name", dirs.assets_index.clone()); + map.insert("game_assets", path_str(&dirs.game_assets)); + map.insert("assets_root", path_str(&dirs.assets_root)); + map.insert("natives_directory", path_str(&dirs.natives_dir)); + map.insert( + "classpath", + join_classpath(&req.classpath, ctx.path_separator()), + ); + map.insert("classpath_separator", ctx.path_separator().to_string()); + map.insert("launcher_name", req.launcher.name.clone()); + map.insert("launcher_version", req.launcher.version.clone()); + map.insert("library_directory", path_str(&dirs.library_dir)); + map.insert("libraries_directory", path_str(&dirs.library_dir)); + map.insert("primary_jar", path_str(&dirs.primary_jar)); + map.insert( + "primary_jar_name", + dirs.primary_jar + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(), + ); + map.insert("file_separator", ctx.file_separator().to_string()); + match &req.resolution { + Some(res) => { + map.insert("resolution_width", res.width.to_string()); + map.insert("resolution_height", res.height.to_string()); + } + None => { + map.insert("resolution_width", String::new()); + map.insert("resolution_height", String::new()); + } + } + map.insert("language", req.language.clone()); + map + } +} + +/// 按平台分隔符拼接 classpath。 +pub fn join_classpath(paths: &[std::path::PathBuf], separator: char) -> String { + paths + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(&separator.to_string()) +} + +fn path_str(path: &Path) -> String { + path.display().to_string() +} diff --git a/crates/core/src/launch/platform.rs b/crates/core/src/launch/platform.rs new file mode 100644 index 0000000..435b4a0 --- /dev/null +++ b/crates/core/src/launch/platform.rs @@ -0,0 +1,95 @@ +//! 平台上下文:命令行组装与进程管理需要知道的操作系统事实。 +//! +//! 依据 `analyse/02` §4.1(Linux/macOS `nice`)、§4.4(原生编码)、§4.6(原生库路径)、 +//! §4.10(环境变量,`APPDATA` 重定向只在 Windows 有意义)。 +//! +//! `os_version` 不再是空占位:`current()` 从 [`crate::platform::os_info`] 的运行期探测取值, +//! 清单里带 `rules.os.version` 的参数项因此能真正命中(D19 · `PlanDoc/11-os-detection.md` §2)。 + +use std::path::PathBuf; + +pub use crate::platform::os_info::OsFamily; + +/// 平台上下文。 +#[derive(Debug, Clone)] +pub struct PlatformCtx { + /// 操作系统家族。 + pub os: OsFamily, + /// `rules.os.arch` 用的架构名。 + pub arch: String, + /// 系统原生编码(`-Dfile.encoding` 的默认值来源)。 + pub native_charset: String, + /// 主目录(脱敏与 `-Duser.home` 用)。 + pub home_dir: Option, + /// 系统版本串(`rules.os.version` 用,例如 `10.0.19045`)。 + pub os_version: String, +} + +impl PlatformCtx { + /// 按当前编译目标推断,版本串取运行期真实探测值。 + pub fn current() -> Self { + let info = crate::platform::os_info::current(); + Self { + os: info.family, + arch: info.arch.clone(), + native_charset: native_charset().to_string(), + home_dir: home_dir(), + os_version: info.os_version().to_string(), + } + } + + /// 测试用:显式指定系统与架构,版本串留空(规则求值走保守分支)。 + pub fn new(os: OsFamily, arch: &str) -> Self { + Self { + os, + arch: arch.to_string(), + native_charset: native_charset().to_string(), + home_dir: None, + os_version: String::new(), + } + } + + /// 显式指定版本串(测试与适配层注入用)。 + pub fn with_os_version(mut self, version: impl Into) -> Self { + self.os_version = version.into(); + self + } + + /// classpath 分隔符(Windows `;`,POSIX `:`)。 + pub fn path_separator(&self) -> char { + if self.os == OsFamily::Windows { + ';' + } else { + ':' + } + } + + /// 文件名分隔符的字符串形式(供 `${file_separator}` 占位符)。 + pub fn file_separator(&self) -> &'static str { + if self.os == OsFamily::Windows { + "\\" + } else { + "/" + } + } +} + +impl Default for PlatformCtx { + fn default() -> Self { + Self::current() + } +} + +/// 系统原生编码名。 +/// +/// 中文 Windows 上系统编码是 GBK,但 Java 18+ 默认 UTF-8;这里统一给 `UTF-8`, +/// 需要跟随系统时由用户在 `-Dfile.encoding=COMPAT` 语义下覆盖(`analyse/02` §4.4)。 +fn native_charset() -> &'static str { + "UTF-8" +} + +fn home_dir() -> Option { + std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) +} diff --git a/crates/core/src/launch/process.rs b/crates/core/src/launch/process.rs new file mode 100644 index 0000000..aad3409 --- /dev/null +++ b/crates/core/src/launch/process.rs @@ -0,0 +1,222 @@ +//! 进程管理:spawn + stdout/stderr 泵 + 进程树回收。 +//! +//! 对应 `analyse/02` §6 的四条线程模型(`stdout-pump` / `stderr-pump` / `exit-waiter`), +//! 但线程生命周期的收尾由 [`crate::launch::watchdog`] 兜底,不无条件 `join`。 +//! +//! 本模块只管“把命令行跑起来并收集输出”,不做组件检查、不做诊断—— +//! 那些是 `launch` 上层流水线(阶段B)与 M15 的职责。 + +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use crate::launch::args_table::AssembledCommand; +use crate::launch::error::{LaunchError, Result}; +use crate::launch::pump::{LineSink, LogLine, PumpGroup, RingBuffer, RingBufferConfig}; +use crate::launch::tree_kill; +use crate::launch::watchdog::{self, PumpVerdict, WatchdogConfig}; + +/// 强制结束后等进程消失的预算。 +const FORCE_KILL_BUDGET: Duration = Duration::from_secs(5); +/// 轮询进程状态的间隔。 +const POLL_INTERVAL: Duration = Duration::from_millis(20); + +/// 进程管理配置。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProcessConfig { + /// 日志泵等待预算。 + pub watchdog: WatchdogConfig, + /// 停止时温和关闭的宽限期。 + pub graceful_stop: Duration, + /// 环形缓冲配置。 + pub buffer: RingBufferConfig, +} + +impl Default for ProcessConfig { + fn default() -> Self { + Self { + watchdog: WatchdogConfig::default(), + graceful_stop: Duration::from_secs(3), + buffer: RingBufferConfig::default(), + } + } +} + +/// 已启动的游戏进程。 +#[derive(Debug)] +pub struct RunningProcess { + child: Child, + pump: PumpGroup, + buffer: Arc>, + started_at: Instant, + pid: u32, + config: ProcessConfig, +} + +impl RunningProcess { + /// 启动进程并接管两条输出流。 + pub fn spawn( + command: &AssembledCommand, + working_dir: Option<&Path>, + sink: Arc, + config: ProcessConfig, + ) -> Result { + let mut process = Command::new(&command.program); + process.args(&command.args); + for (key, value) in &command.env { + process.env(key, value); + } + if let Some(dir) = working_dir { + process.current_dir(dir); + } + process + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + /// 独立进程组:便于 `taskkill /T` 递归回收。 + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + process.creation_flags(CREATE_NEW_PROCESS_GROUP); + } + + let mut child = process.spawn().map_err(|source| LaunchError::Spawn { + program: command.program.clone(), + source, + })?; + let pid = child.id(); + let buffer = Arc::new(Mutex::new(RingBuffer::new(config.buffer))); + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let pump = PumpGroup::spawn(stdout, stderr, buffer.clone(), sink); + Ok(Self { + child, + pump, + buffer, + started_at: Instant::now(), + pid, + config, + }) + } + + /// 进程号。 + pub fn pid(&self) -> u32 { + self.pid + } + + /// 已运行毫秒数。 + pub fn uptime_ms(&self) -> u64 { + u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX) + } + + /// 启动时刻(窗口检测计时用)。 + pub fn started_at(&self) -> Instant { + self.started_at + } + + /// 共享的日志环形缓冲。 + pub fn buffer(&self) -> Arc> { + self.buffer.clone() + } + + /// 当前可读日志快照。 + pub fn lines(&self) -> Vec { + match self.buffer.lock() { + Ok(guard) => guard.lines().cloned().collect(), + Err(_) => Vec::new(), + } + } + + /// 等待进程退出(不等待日志泵)。 + pub fn wait(&mut self) -> Result { + self.child.wait().map_err(|source| LaunchError::Io { + op: "wait_child", + path: PathBuf::from(self.pid.to_string()), + source, + }) + } + + /// 进程退出后,按预算收尾日志泵。 + pub fn supervise(&mut self) -> PumpVerdict { + watchdog::wait_for_pumps(&mut self.pump, &self.config.watchdog) + } + + /// 进程是否仍在运行。 + pub fn is_running(&mut self) -> bool { + matches!(self.child.try_wait(), Ok(None)) + } + + /// 三段式停止整棵进程树(温和 → 宽限 → 强制)。 + /// + /// 判定标准是**进程是否真的没了**,不是 `taskkill`/`kill` 的退出码: + /// 外部命令对“已经退出的进程”会返回非 0(Windows 上 `taskkill` 报 128), + /// 把它当失败会误报——尤其是温和阶段已经把进程关掉的情况。 + pub fn stop(&mut self) -> Result<()> { + if !self.is_running() { + return Ok(()); + } + let _ = tree_kill::terminate(self.pid); + if self.wait_until_exit(self.config.graceful_stop) { + return Ok(()); + } + let _ = tree_kill::force_kill(self.pid); + if self.wait_until_exit(FORCE_KILL_BUDGET) { + return Ok(()); + } + Err(LaunchError::Kill { + pid: self.pid, + source: std::io::Error::other("强制结束后进程仍在运行"), + }) + } + + /// 直接强杀(看门狗/崩溃兜底路径)。 + pub fn force_kill(&mut self) -> Result<()> { + if !self.is_running() { + return Ok(()); + } + let _ = tree_kill::force_kill(self.pid); + if self.wait_until_exit(FORCE_KILL_BUDGET) { + Ok(()) + } else { + Err(LaunchError::Kill { + pid: self.pid, + source: std::io::Error::other("强制结束后进程仍在运行"), + }) + } + } + + fn wait_until_exit(&mut self, budget: Duration) -> bool { + let deadline = Instant::now() + budget; + loop { + match self.child.try_wait() { + Ok(Some(_)) => return true, + Ok(None) => {} + Err(_) => return false, + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(POLL_INTERVAL); + } + } +} + +/// 从退出状态取整数退出码。 +/// +/// POSIX 上被子进程信号杀死没有 `code()`,按 shell 惯例补 `128 + signal` +/// (`SIGKILL` → 137、`SIGTERM` → 143,与 `analyse/02` 附录 C 一致)。 +pub fn exit_code(status: &ExitStatus) -> i32 { + if let Some(code) = status.code() { + return code; + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return 128 + signal; + } + } + -1 +} diff --git a/crates/core/src/launch/pump.rs b/crates/core/src/launch/pump.rs new file mode 100644 index 0000000..f7fd684 --- /dev/null +++ b/crates/core/src/launch/pump.rs @@ -0,0 +1,379 @@ +//! stdout/stderr 泵与日志环形缓冲。 +//! +//! 平移自 `analyse/02` §6.1/§6.2(`StreamPump` + `ManagedProcess.lines`),并补两处改进: +//! 缓冲按“行数 + 字节数”双上限(§8.4-9),且命中 `Exception|Error|Caused by|OutOfMemory` +//! 的关键行**不随环形淘汰**——2000 行截断会把根因行挤掉(§7.10-3)。 +//! +//! 解码:我们强制给子进程下发 `-Dstdout.encoding=UTF-8`(`-Dsun.stdout.encoding` +//! for Java <19),因此泵按 UTF-8 宽松解码;用户把编码改成 GBK 时会出现替换字符, +//! 阶段B 再视需要引入 `encoding_rs`(记录在 `launch-接口约定.md` §7)。 + +use std::collections::VecDeque; +use std::io::{BufRead, BufReader, Read}; +use std::process::{ChildStderr, ChildStdout}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use crate::launch::log_level; + +/// 默认环形缓冲行数(与 HMCL 一致)。 +pub const DEFAULT_MAX_LINES: usize = 2000; +/// 默认环形缓冲字节上限。 +pub const DEFAULT_MAX_BYTES: usize = 4 * 1024 * 1024; +/// 单行最大字符数(超出截断并标注,避免单行撑爆事件流)。 +pub const MAX_LINE_CHARS: usize = 8192; + +/// 输出流。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Stream { + /// 标准输出。 + Stdout, + /// 标准错误。 + Stderr, +} + +impl Stream { + /// 事件流里的字符串形式(`"stdout"` / `"stderr"`)。 + pub fn as_str(self) -> &'static str { + match self { + Self::Stdout => "stdout", + Self::Stderr => "stderr", + } + } + + /// 是否标准错误。 + pub fn is_stderr(self) -> bool { + self == Self::Stderr + } +} + +/// 一行日志。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogLine { + /// 来源流。 + pub stream: Stream, + /// 文本(已去行尾、超长已截断)。 + pub text: String, +} + +impl LogLine { + /// 构造。 + pub fn new(stream: Stream, text: impl Into) -> Self { + Self { + stream, + text: text.into(), + } + } + + /// 猜测的级别。 + pub fn level(&self) -> log_level::Level { + log_level::guess(self.stream.is_stderr(), &self.text) + } + + /// 是否错误级。 + pub fn is_error(&self) -> bool { + self.level().is_error() + } +} + +/// 日志观察者(会话事件流、M15 采集器适配层都实现它)。 +pub trait LineSink: Send + Sync { + /// 收到一行日志。 + fn on_line(&self, line: &LogLine); +} + +/// 丢弃所有行的 sink(测试/无监听场景)。 +#[derive(Debug, Default, Clone, Copy)] +pub struct NullSink; + +impl LineSink for NullSink { + fn on_line(&self, _line: &LogLine) {} +} + +/// 环形缓冲配置。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RingBufferConfig { + /// 行数上限。 + pub max_lines: usize, + /// 字节上限。 + pub max_bytes: usize, +} + +impl Default for RingBufferConfig { + fn default() -> Self { + Self { + max_lines: DEFAULT_MAX_LINES, + max_bytes: DEFAULT_MAX_BYTES, + } + } +} + +/// 环形日志缓冲:近期行 + 永久保留的关键行。 +#[derive(Debug)] +pub struct RingBuffer { + config: RingBufferConfig, + recent: VecDeque, + retained: Vec, + bytes: usize, + pushed: u64, +} + +impl RingBuffer { + /// 构造。 + pub fn new(config: RingBufferConfig) -> Self { + Self { + config, + recent: VecDeque::new(), + retained: Vec::new(), + bytes: 0, + pushed: 0, + } + } + + /// 压入一行,必要时淘汰最早的行(关键行转入 `retained`)。 + pub fn push(&mut self, line: LogLine) { + self.pushed += 1; + let size = line.text.len(); + self.bytes += size; + self.recent.push_back(line); + while self.recent.len() > self.config.max_lines || self.bytes > self.config.max_bytes { + let Some(evicted) = self.recent.pop_front() else { + break; + }; + self.bytes = self.bytes.saturating_sub(evicted.text.len()); + if log_level::is_key_line(&evicted.text) { + self.retained.push(evicted); + } + } + } + + /// 按时间顺序取全部可读行(保留的关键行在前)。 + pub fn lines(&self) -> impl Iterator { + self.retained.iter().chain(self.recent.iter()) + } + + /// 拼接成文本(喂给规则引擎/日志切片)。 + pub fn text(&self) -> String { + let mut out = String::with_capacity(self.bytes.min(64 * 1024)); + for line in self.lines() { + out.push_str(&line.text); + out.push('\n'); + } + out + } + + /// 只要错误级行(退出分析的输入)。 + pub fn error_lines(&self) -> Vec<&LogLine> { + self.lines().filter(|line| line.is_error()).collect() + } + + /// 当前可读行数。 + pub fn len(&self) -> usize { + self.retained.len() + self.recent.len() + } + + /// 是否为空。 + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// 累计压入行数(含已被淘汰的)。 + pub fn total_pushed(&self) -> u64 { + self.pushed + } +} + +impl Default for RingBuffer { + fn default() -> Self { + Self::new(RingBufferConfig::default()) + } +} + +/// 单条流结束的回执。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StreamFinished { + /// 哪条流。 + pub stream: Stream, + /// 累计行数。 + pub line_count: u64, + /// 读取错误(有的话)。 + pub error: Option, +} + +/// 一次等待 PumpGroup 的结果。 +#[derive(Debug, Clone, Default)] +pub struct PumpCompletion { + /// 已结束的流。 + pub finished: Vec, + /// 超时仍未结束的流。 + pub stalled: Vec, +} + +impl PumpCompletion { + /// 是否全部结束。 + pub fn is_complete(&self) -> bool { + self.stalled.is_empty() + } + + /// 累计行数。 + pub fn total_lines(&self) -> u64 { + self.finished.iter().map(|item| item.line_count).sum() + } +} + +/// 一组泵线程(stdout + stderr)。 +#[derive(Debug)] +pub struct PumpGroup { + handles: Vec>, + receiver: Receiver, + streams: Vec, + completed: Vec, +} + +impl PumpGroup { + /// 为子进程的输出流派生泵线程。 + /// + /// `buffer` 与 `sink` 同时被两个泵线程共享:缓冲区用于“最后 N 行”, + /// sink 用于事件流落盘。 + pub fn spawn( + stdout: Option, + stderr: Option, + buffer: Arc>, + sink: Arc, + ) -> Self { + let (sender, receiver) = mpsc::channel(); + let mut handles = Vec::new(); + let mut streams = Vec::new(); + if let Some(stdout) = stdout { + streams.push(Stream::Stdout); + handles.push(spawn_one( + Box::new(stdout), + Stream::Stdout, + buffer.clone(), + sink.clone(), + sender.clone(), + )); + } + if let Some(stderr) = stderr { + streams.push(Stream::Stderr); + handles.push(spawn_one( + Box::new(stderr), + Stream::Stderr, + buffer, + sink, + sender.clone(), + )); + } + drop(sender); + Self { + handles, + receiver, + streams, + completed: Vec::new(), + } + } + + /// 期望的流数。 + pub fn expected(&self) -> usize { + self.streams.len() + } + + /// 等待所有泵结束(带超时,避免 HMCL `pump.join()` 永久挂死的风险)。 + pub fn wait(&mut self, timeout: Duration) -> PumpCompletion { + let deadline = Instant::now() + timeout; + let mut finished = Vec::new(); + while self.completed.len() < self.streams.len() { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + match self.receiver.recv_timeout(remaining) { + Ok(item) => { + if !self.completed.contains(&item.stream) { + self.completed.push(item.stream); + } + finished.push(item); + } + Err(_) => break, + } + } + let stalled = self + .streams + .iter() + .copied() + .filter(|stream| !self.completed.contains(stream)) + .collect(); + PumpCompletion { finished, stalled } + } + + /// 强制 join(可能永久阻塞——只在上层已经确认流已关闭时使用)。 + pub fn join_all(&mut self) { + for handle in self.handles.drain(..) { + let _ = handle.join(); + } + } +} + +type BoxedReader = Box; + +fn spawn_one( + reader: BoxedReader, + stream: Stream, + buffer: Arc>, + sink: Arc, + sender: Sender, +) -> JoinHandle<()> { + thread::Builder::new() + .name(format!("launch-pump-{}", stream.as_str())) + .spawn(move || { + let mut reader = BufReader::new(reader); + let mut raw = Vec::with_capacity(4096); + let mut line_count = 0u64; + let mut error = None; + loop { + raw.clear(); + match reader.read_until(b'\n', &mut raw) { + Ok(0) => break, + Ok(_) => { + let text = decode_line(&raw); + if text.is_empty() { + continue; + } + line_count += 1; + let line = LogLine::new(stream, text); + if let Ok(mut guard) = buffer.lock() { + guard.push(line.clone()); + } + sink.on_line(&line); + } + Err(err) => { + error = Some(err.to_string()); + break; + } + } + } + let _ = sender.send(StreamFinished { + stream, + line_count, + error, + }); + }) + .expect("spawn log pump thread") +} + +fn decode_line(raw: &[u8]) -> String { + let text = String::from_utf8_lossy(raw); + let trimmed = text.trim_end_matches(['\r', '\n']); + truncate_line(trimmed) +} + +fn truncate_line(text: &str) -> String { + if text.chars().count() <= MAX_LINE_CHARS { + return text.to_string(); + } + let head: String = text.chars().take(MAX_LINE_CHARS).collect(); + let extra = text.chars().count() - MAX_LINE_CHARS; + format!("{head}…[truncated {extra} chars]") +} diff --git a/crates/core/src/launch/redact.rs b/crates/core/src/launch/redact.rs new file mode 100644 index 0000000..78b7425 --- /dev/null +++ b/crates/core/src/launch/redact.rs @@ -0,0 +1,299 @@ +//! 日志与事件流的脱敏(写入前执行,落盘即脱敏)。 +//! +//! 规则见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §4: +//! token 整串替换、用户主目录替换、长度 ≥64 的不透明串(base64/hex token)替换。 +//! 依据 `analyse/02` §6.4 / §8.4-5(HMCL 只过滤 access token,我们要求导出前全量脱敏)。 +//! +//! 第四条规则(M40 从 `auth::body` 上提):**带点 token** 整串替换。JWT 形状是 +//! `header.payload.signature`(XSTS/XBL 票据正是这个),点号把连续段切开,单看 +//! 「连续不透明串 ≥64」这条会整串原样放过。规则只在这里实现一份,所有调用方 +//! (launch 会话 / 诊断 / auth 错误摘要 …)共用,避免各自补一套后漂移。 + +use std::borrow::Cow; +use std::path::Path; + +use serde_json::Value; + +/// access token / session token 的替换标记。 +pub const TOKEN_PLACEHOLDER: &str = ""; +/// 用户主目录的替换标记。 +pub const HOME_PLACEHOLDER: &str = ""; +/// 形似 token 的长不透明串的替换标记。 +pub const OPAQUE_PLACEHOLDER: &str = ""; + +/// 判定“像 token”的最短连续长度。 +const MAX_OPAQUE_RUN: usize = 64; +/// 「带点 token」(JWT 三段式)判定的最短总长。 +const DOTTED_TOKEN_MIN_TOTAL: usize = 40; +/// 「带点 token」每一段的最短长度(挡住 `Service.Unavailable.today` 这类正常文案)。 +const DOTTED_TOKEN_MIN_SEGMENT: usize = 8; + +/// 脱敏器:持有需要抹掉的敏感值(token、主目录)。 +#[derive(Debug, Clone, Default)] +pub struct Redactor { + secrets: Vec, + homes: Vec, +} + +impl Redactor { + /// 空脱敏器(不做任何替换)。 + pub fn new() -> Self { + Self::default() + } + + /// 登记一个敏感值(access token、userhash、xuid 等)。 + /// + /// 太短的值(< 6 字节)会被忽略:替换一个 3 字符串会把正常日志搅乱。 + pub fn with_secret(mut self, secret: impl Into) -> Self { + let secret = secret.into(); + if secret.trim().len() >= 6 && !self.secrets.contains(&secret) { + self.secrets.push(secret); + } + self + } + + /// 登记一个需要抹掉的主目录(替换成 ``)。 + pub fn with_home(mut self, home: impl AsRef) -> Self { + let home = home.as_ref().to_string_lossy().into_owned(); + if home.len() > 3 && !self.homes.contains(&home) { + self.homes.push(home); + } + self + } + + /// 登记一个主目录字符串。 + pub fn with_home_str(mut self, home: impl Into) -> Self { + let home = home.into(); + if home.len() > 3 && !self.homes.contains(&home) { + self.homes.push(home); + } + self + } + + /// 是否登记了任何规则。 + pub fn is_empty(&self) -> bool { + self.secrets.is_empty() && self.homes.is_empty() + } + + /// 对文本脱敏;无需修改时返回借用,避免无谓拷贝。 + pub fn scrub<'a>(&self, text: &'a str) -> Cow<'a, str> { + let mut current: Cow<'a, str> = Cow::Borrowed(text); + for secret in &self.secrets { + if current.contains(secret.as_str()) { + current = Cow::Owned(current.replace(secret.as_str(), TOKEN_PLACEHOLDER)); + } + } + for home in &self.homes { + if current.contains(home.as_str()) { + current = Cow::Owned(current.replace(home.as_str(), HOME_PLACEHOLDER)); + } + } + if has_dotted_token(¤t) { + current = Cow::Owned(mask_dotted_tokens(¤t)); + } + if has_opaque_run(¤t) { + current = Cow::Owned(mask_opaque(¤t)); + } + current + } + + /// 对整条命令行脱敏(`cmd_built.argv` 必然含 accessToken,必须走这里)。 + pub fn scrub_all(&self, values: &[String]) -> Vec { + values + .iter() + .map(|value| self.scrub(value).into_owned()) + .collect() + } + + /// 递归脱敏一个 JSON 值。 + /// + /// **只改字符串**:数值/布尔/null 与字段名原样保留——满足「launch 只做脱敏、不改写字段」 + /// 的契约(`DevDocs/SubAgentsTalk/launch-接口约定.md` §9.3)。诊断结论里的证据行常带 + /// 崩溃报告/`hs_err` 的绝对路径,正是要过 `` 规则的地方。 + pub fn scrub_json(&self, value: &Value) -> Value { + match value { + Value::String(text) => Value::String(self.scrub(text).into_owned()), + Value::Array(items) => { + Value::Array(items.iter().map(|item| self.scrub_json(item)).collect()) + } + Value::Object(map) => Value::Object( + map.iter() + .map(|(key, value)| (key.clone(), self.scrub_json(value))) + .collect(), + ), + other => other.clone(), + } + } +} + +/// 文本里是否含长度 ≥64 的“不透明串”(base64/hex token 的特征)。 +pub fn has_opaque_run(text: &str) -> bool { + let mut run = 0usize; + for ch in text.chars() { + if is_opaque_char(ch) { + run += 1; + if run >= MAX_OPAQUE_RUN { + return true; + } + } else { + run = 0; + } + } + false +} + +/// 把长度 ≥64 的不透明串替换成 [`OPAQUE_PLACEHOLDER`]。 +pub fn mask_opaque(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut run_start: Option = None; + for (idx, ch) in text.char_indices() { + if is_opaque_char(ch) { + if run_start.is_none() { + run_start = Some(idx); + } + } else { + if let Some(start) = run_start.take() { + push_masked(&mut out, &text[start..idx]); + } + out.push(ch); + } + } + if let Some(start) = run_start { + push_masked(&mut out, &text[start..]); + } + out +} + +fn push_masked(out: &mut String, run: &str) { + if run.len() >= MAX_OPAQUE_RUN { + out.push_str(OPAQUE_PLACEHOLDER); + } else { + out.push_str(run); + } +} + +fn is_opaque_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=' | '_' | '-') +} + +/// 文本里是否含「带点 token」(≥3 段、每段 ≥8、总长 ≥40)。 +pub fn has_dotted_token(text: &str) -> bool { + let mut start: Option = None; + for (idx, ch) in text.char_indices() { + if is_dotted_token_char(ch) { + start.get_or_insert(idx); + continue; + } + if let Some(from) = start.take() { + if is_dotted_token(&text[from..idx]) { + return true; + } + } + } + start.is_some_and(|from| is_dotted_token(&text[from..])) +} + +/// 把「带点 token」整串替换成 [`TOKEN_PLACEHOLDER`](段外文本原样保留)。 +pub fn mask_dotted_tokens(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut start: Option = None; + for (idx, ch) in text.char_indices() { + if is_dotted_token_char(ch) { + start.get_or_insert(idx); + continue; + } + if let Some(from) = start.take() { + push_dotted(&mut out, &text[from..idx]); + } + out.push(ch); + } + if let Some(from) = start { + push_dotted(&mut out, &text[from..]); + } + out +} + +fn push_dotted(out: &mut String, run: &str) { + if is_dotted_token(run) { + out.push_str(TOKEN_PLACEHOLDER); + } else { + out.push_str(run); + } +} + +/// 「带点 token」的字符集:比不透明串多一个 `.`(JWT 的分段符)。 +fn is_dotted_token_char(ch: char) -> bool { + is_opaque_char(ch) || ch == '.' +} + +/// 单段连写(无空白)的串是否像 JWT:段数 ≥3、每段 ≥8 字节、总长 ≥40。 +fn is_dotted_token(run: &str) -> bool { + run.len() >= DOTTED_TOKEN_MIN_TOTAL + && run.split('.').count() >= 3 + && run + .split('.') + .all(|segment| segment.len() >= DOTTED_TOKEN_MIN_SEGMENT) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 真实形状的 JWT(header.payload.signature,三段各 ≥8)。 + const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g"; + + #[test] + fn jwt_token_is_masked_without_any_registered_secret() { + let scrubbed = Redactor::new().scrub(JWT).into_owned(); + assert!(scrubbed.contains(TOKEN_PLACEHOLDER), "{scrubbed}"); + assert!(!scrubbed.contains("eyJhbGci"), "{scrubbed}"); + + // 嵌在别的内容里也只换 token 本体。 + let masked = Redactor::new() + .scrub(&format!("authorization: Bearer {JWT}, retry=1")) + .into_owned(); + assert_eq!( + masked, + format!("authorization: Bearer {TOKEN_PLACEHOLDER}, retry=1") + ); + } + + #[test] + fn dotted_token_rule_does_not_eat_normal_text() { + // 分段太短、总长不够、段数不够:三种都不算 token。 + for text in [ + "Service.Unavailable.today", + "1.2.0", + "xbl.xsts.entitlements.profile", + "eyJhbGciOiJIUzI1NiJ9.short.abc", + ] { + assert!(!has_dotted_token(text), "{text}"); + assert_eq!(Redactor::new().scrub(text), text, "{text}"); + } + } + + #[test] + fn existing_rules_still_apply() { + let redactor = Redactor::new() + .with_secret("SECRET-TOKEN-123456") + .with_home_str("/home/alice"); + let scrubbed = redactor + .scrub("/home/alice/game.log: token=SECRET-TOKEN-123456") + .into_owned(); + assert!(scrubbed.contains(HOME_PLACEHOLDER), "{scrubbed}"); + assert!(scrubbed.contains(TOKEN_PLACEHOLDER), "{scrubbed}"); + assert!( + !scrubbed.contains("alice") && !scrubbed.contains("SECRET"), + "{scrubbed}" + ); + + // 没有点号的长不透明串仍走 ≥64 规则,替换标记也不同。 + let long = "A".repeat(MAX_OPAQUE_RUN); + assert!(has_opaque_run(&long)); + assert!(!has_dotted_token(&long)); + assert_eq!( + Redactor::new().scrub(&long).into_owned(), + OPAQUE_PLACEHOLDER.to_string() + ); + } +} diff --git a/crates/core/src/launch/request.rs b/crates/core/src/launch/request.rs new file mode 100644 index 0000000..6a4f54d --- /dev/null +++ b/crates/core/src/launch/request.rs @@ -0,0 +1,358 @@ +//! 启动请求:命令行组装的输入模型(纯数据,无 IO,便于快照测试)。 +//! +//! 与 `crate::game` / `crate::download` 的映射关系见 +//! `DevDocs/SubAgentsTalk/launch-接口约定.md` §6:launch 阶段只消费本结构, +//! 由薄适配层把 game 的快照搬进来,避免 launch 对 manifest 类型产生编译期依赖。 + +use std::path::PathBuf; + +use crate::launch::argument::ArgumentEntry; +use crate::launch::error::MissingComponent; +use crate::launch::game_version::GameVersion; +use crate::launch::natives::NativesPlan; + +/// 实例信息。 +#[derive(Debug, Clone, Default)] +pub struct InstanceInfo { + /// 实例/版本 id(稳定 UUID 或版本目录名)。 + pub id: String, + /// 展示名(`${version_name}`)。 + pub name: String, + /// 游戏版本号(分支门限用)。 + pub version: GameVersion, + /// 版本类型(`release` / `snapshot`;`${version_type}`)。 + pub version_type: String, +} + +/// 启动器自身信息(`${launcher_name}` / `${launcher_version}`)。 +#[derive(Debug, Clone, Default)] +pub struct LauncherInfo { + /// 名称。 + pub name: String, + /// 版本。 + pub version: String, +} + +/// 选定的 Java 运行时。 +#[derive(Debug, Clone, Default)] +pub struct JavaSelection { + /// `java` 可执行文件路径。 + pub binary: PathBuf, + /// 主版本号(8 / 17 / 21 / 25 …)。 + pub major: u32, + /// 位数(32 / 64)。 + pub bits: u32, +} + +impl JavaSelection { + /// 构造。 + pub fn new(binary: impl Into, major: u32, bits: u32) -> Self { + Self { + binary: binary.into(), + major, + bits, + } + } + + /// 是否 64 位 JVM。 + pub fn is_64bit(&self) -> bool { + self.bits == 64 + } +} + +/// 内存设置(MiB)。 +#[derive(Debug, Clone, Copy, Default)] +pub struct MemoryOptions { + /// 初始堆 `-Xms`。 + pub min_mb: Option, + /// 最大堆 `-Xmx`。 + pub max_mb: Option, +} + +/// 版本清单里的参数(`arguments.jvm` / `arguments.game` / 旧版 `minecraftArguments`)。 +#[derive(Debug, Clone, Default)] +pub struct ManifestArgs { + /// 主类。 + pub main_class: Option, + /// 现代清单的 JVM 参数(带 rules)。 + pub jvm: Vec, + /// 现代清单的游戏参数(带 rules)。 + pub game: Vec, + /// 旧版清单的整串游戏参数(1.12 及更早)。 + pub legacy_arguments: Option, + /// 版本库表里带 LWJGL 的 Vulkan 绑定(`org.lwjgl:lwjgl-vulkan`)。 + /// + /// 只作「无法排序的版本」(周快照)的 `--graphicsBackend` 兜底判据与 UI 提示; + /// 可解析版本的下发门限仍是 `26.2-snapshot-2`(见 + /// [`graphics_backend_allowed`](crate::launch::game_args::graphics_backend_allowed))。 + pub has_vulkan_backend: bool, +} + +impl ManifestArgs { + /// 是否走旧版参数模板(只有 `minecraftArguments`,没有 `arguments`)。 + pub fn is_legacy(&self) -> bool { + self.jvm.is_empty() && self.legacy_arguments.is_some() + } +} + +/// 目录布局。 +#[derive(Debug, Clone, Default)] +pub struct LaunchDirs { + /// 运行目录(`${game_directory}`,同时是子进程的 CWD)。 + /// + /// D20:这个值是**隔离覆盖链的派生结果**(`game::isolation::derive_game_dir`: + /// 临时参数 > 实例 `isolation` > 全局 `instances.mode`)。launch 侧只消费、不派生, + /// 也不读配置——路径语义的真相在 `core::game`。 + pub game_dir: PathBuf, + /// 资产根。 + pub assets_root: PathBuf, + /// 重建后的虚拟资产目录(`${game_assets}`)。 + pub game_assets: PathBuf, + /// 资产索引 id(`${assets_index_name}`)。 + pub assets_index: String, + /// 原生库目录(按 Java 平台选择,`analyse/02` §4.6)。 + pub natives_dir: PathBuf, + /// 库根目录。 + pub library_dir: PathBuf, + /// 主 jar。 + pub primary_jar: PathBuf, +} + +/// 账户占位符值。 +#[derive(Debug, Clone, Default)] +pub struct AuthPlaceholders { + /// 玩家名。 + pub player_name: String, + /// 无连字符 UUID。 + pub uuid: String, + /// 访问令牌(落盘前必须脱敏)。 + pub access_token: String, + /// `msa` / `legacy`。 + pub user_type: String, + /// `user_properties` JSON 串。 + pub user_properties: String, +} + +/// 用户自定义参数。 +#[derive(Debug, Clone, Default)] +pub struct UserArgs { + /// 最高优先级 JVM 参数(不解析转义,会读 `@argfile`)。 + pub override_jvm: Vec, + /// 普通 JVM 参数(整体按“默认值”处理)。 + pub jvm: Vec, + /// 追加在最后、可覆盖前面一切的 game 参数。 + pub game: Vec, + /// 额外环境变量(用户自定义,最后覆盖)。 + pub environment: Vec<(String, String)>, +} + +/// 清单参数 rules 求值所需的特性开关。 +#[derive(Debug, Clone, Copy, Default)] +pub struct FeatureSet { + /// 用户指定了分辨率(`--width/--height` 的开关)。 + pub has_custom_resolution: bool, + /// 演示模式。 + pub is_demo_user: bool, + /// 支持 QuickPlay(1.20+)。 + pub has_quick_plays_support: bool, +} + +impl FeatureSet { + /// 按特性名查询(未知特性为 `None`)。 + pub fn get(&self, name: &str) -> Option { + match name { + "has_custom_resolution" => Some(self.has_custom_resolution), + "is_demo_user" => Some(self.is_demo_user), + "has_quick_plays_support" => Some(self.has_quick_plays_support), + _ => None, + } + } +} + +/// 窗口分辨率。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Resolution { + /// 宽。 + pub width: u32, + /// 高。 + pub height: u32, +} + +/// 一键进入目标。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QuickPlay { + /// 直入存档。 + Singleplayer { + /// 存档目录名。 + world: String, + }, + /// 直入服务器。 + Multiplayer { + /// `host:port` 形式地址。 + address: String, + }, + /// 直入 Realms。 + Realms { + /// Realm id。 + realm_id: String, + }, +} + +/// SOCKS 代理。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SocksProxy { + /// 主机。 + pub host: String, + /// 端口。 + pub port: u16, + /// 用户名。 + pub username: Option, + /// 密码。 + pub password: Option, +} + +/// 模组加载器种类(决定 Forge 专属参数与环境变量)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ModLoaderKind { + /// 原版。 + #[default] + Vanilla, + /// Forge。 + Forge, + /// NeoForge。 + NeoForge, + /// Fabric。 + Fabric, + /// Quilt。 + Quilt, + /// LiteLoader。 + LiteLoader, + /// OptiFine(独立安装器)。 + OptiFine, +} + +impl ModLoaderKind { + /// 是否使用 FML/LaunchWrapper(决定 `-Dfml.ignore*` 参数)。 + pub fn is_fml_like(self) -> bool { + matches!(self, Self::Forge | Self::NeoForge | Self::LiteLoader) + } +} + +/// Log4j 注入选项。 +#[derive(Debug, Clone)] +pub struct Log4jOptions { + /// 是否注入日志配置(HMCL:版本 ≥1.7 即注入,探测失败按 1.7 兜底)。 + pub enabled: bool, + /// 配置文件路径(`-Dlog4j.configurationFile=`)。 + pub config_file: Option, + /// 调试日志变体。 + pub debug: bool, +} + +impl Default for Log4jOptions { + fn default() -> Self { + Self { + enabled: true, + config_file: None, + debug: false, + } + } +} + +/// 渲染器相关开关(`analyse/02` §4.1 #17、§4.10)。 +#[derive(Debug, Clone, Default)] +pub struct RendererOptions { + /// 软件渲染(Mesa/llvmpipe)。 + pub software: bool, + /// Windows 上 mesa 的 native 目录。 + pub mesa_native_dir: Option, + /// macOS 上的 Vulkan ICD JSON。 + pub vulkan_icd: Option, +} + +/// JVM 自动优化选项。 +#[derive(Debug, Clone)] +pub struct OptimizationOptions { + /// 是否注入自动优化参数(用户可关)。 + pub enabled: bool, + /// 物理内存 MiB(决定 CodeCache 一组参数的开关)。 + pub physical_memory_mb: Option, +} + +impl Default for OptimizationOptions { + fn default() -> Self { + Self { + enabled: true, + physical_memory_mb: None, + } + } +} + +/// 一次启动的完整输入。 +#[derive(Debug, Clone, Default)] +pub struct LaunchRequest { + /// 实例信息。 + pub instance: InstanceInfo, + /// 启动器信息。 + pub launcher: LauncherInfo, + /// Java 运行时。 + pub java: JavaSelection, + /// 内存。 + pub memory: MemoryOptions, + /// 清单参数。 + pub manifest: ManifestArgs, + /// 目录布局。 + pub dirs: LaunchDirs, + /// natives 解压计划(`dirs.natives_dir` 为目标目录,`jars` 为分类器构件清单)。 + /// + /// 解压发生在**组装期**(`Launcher::dry_run`/`spawn` 的「环境准备」段), + /// 见 [`crate::launch::natives`] 与 `SubAgentsTalk/launch-natives-解压与时代分界.md`。 + pub natives: NativesPlan, + /// 账户。 + pub auth: AuthPlaceholders, + /// 用户参数。 + pub user: UserArgs, + /// 特性开关。 + pub features: FeatureSet, + /// 分辨率(`None` 表示不指定)。 + pub resolution: Option, + /// 一键进入。 + pub quick_play: Option, + /// SOCKS 代理。 + pub proxy: Option, + /// 图形后端(`default` / `opengl` / `vulkan`)。 + /// + /// 下发门限见 [`graphics_backend_allowed`](crate::launch::game_args::graphics_backend_allowed): + /// 可解析版本 ≥ `26.2-snapshot-2`,周快照另按版本库表的能力位判。 + pub graphics_backend: Option, + /// 全屏。 + pub fullscreen: bool, + /// 进程优先级(POSIX `nice -n`,Windows 忽略)。 + pub nice: Option, + /// 命令包装器(`optirun`、`prime-run` 等)。 + pub wrapper: Vec, + /// `-javaagent:` 列表。 + pub java_agents: Vec, + /// 登录源提供的 JVM 参数(authlib-injector)。 + pub auth_jvm_args: Vec, + /// 登录源提供的游戏参数。 + pub auth_game_args: Vec, + /// 模组加载器。 + pub mod_loader: ModLoaderKind, + /// Log4j 注入。 + pub log4j: Log4jOptions, + /// 渲染器开关。 + pub renderer: RendererOptions, + /// JVM 自动优化。 + pub optimization: OptimizationOptions, + /// `-Dfile.encoding` 指定值(`COMPAT` 表示跟随系统)。 + pub encoding: Option, + /// 界面语言(`${language}`)。 + pub language: String, + /// 已过滤为“确实存在”的 classpath(主 jar 由组装器追加到末位)。 + pub classpath: Vec, + /// 组件就绪检查发现的缺失项(非空则组装直接失败)。 + pub missing_components: Vec, + /// 额外注入的 JVM 参数(品牌/特性钩子,`appendJvmArgs` 的替代者)。 + pub extra_jvm: Vec, +} diff --git a/crates/core/src/launch/session.rs b/crates/core/src/launch/session.rs new file mode 100644 index 0000000..54c4989 --- /dev/null +++ b/crates/core/src/launch/session.rs @@ -0,0 +1,375 @@ +//! JSONL 会话事件流:`/game/.jsonl`。 +//! +//! schema 见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§3(v1)——M15 诊断采集器与 +//! M16 `read_game_log`/`list_game_sessions` 消费同一份文件,是**单一真相源**。 +//! +//! 两条纪律写死在实现里: +//! 1. 脱敏在写入前做([`Redactor`]),`cmd_built.argv` 也不例外(命令行必含 accessToken); +//! 2. 「launch 报事实、M15 下结论」:`diagnosis` 事件由 M15 产出、由本模块落盘。 + +use std::fs::{self, File, OpenOptions}; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use serde_json::Value; + +use crate::launch::args_table::AssembledCommand; +use crate::launch::error::{AssemblyError, LaunchError, Result}; +use crate::launch::exit_analysis::ExitAnalysis; +use crate::launch::natives::NativesReport; +use crate::launch::pump::{LogLine, Stream}; +use crate::launch::redact::Redactor; +use crate::launch::session_index::{self, SessionIndexEntry}; +use crate::launch::session_payload as payload; +use crate::launch::time::{self, SessionClock}; +use crate::launch::window_detect::Detection; + +/// 事件 schema 版本。 +pub const SCHEMA_VERSION: u8 = 1; + +/// 事件类型。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EventKind { + /// 会话开始。 + SessionStart, + /// Java 选择结果。 + JavaSelected, + /// 命令行组装完成。 + CmdBuilt, + /// natives 解压结果。 + NativesExtracted, + /// 启动前失败(没有进程)。 + AssemblyFailed, + /// 进程已启动。 + ProcStart, + /// stdout 行。 + Stdout, + /// stderr 行。 + Stderr, + /// 检测到游戏窗口。 + WindowDetected, + /// 进程退出。 + Exit, + /// 发现崩溃报告 / hs_err。 + CrashFound, + /// 诊断结论(M15 产出)。 + Diagnosis, + /// 被主动结束。 + Killed, + /// 会话结束。 + SessionEnd, +} + +impl EventKind { + /// JSONL 里的 `kind` 字符串。 + pub fn as_str(self) -> &'static str { + match self { + Self::SessionStart => "session_start", + Self::JavaSelected => "java_selected", + Self::CmdBuilt => "cmd_built", + Self::NativesExtracted => "natives_extracted", + Self::AssemblyFailed => "assembly_failed", + Self::ProcStart => "proc_start", + Self::Stdout => "stdout", + Self::Stderr => "stderr", + Self::WindowDetected => "window_detected", + Self::Exit => "exit", + Self::CrashFound => "crash_found", + Self::Diagnosis => "diagnosis", + Self::Killed => "killed", + Self::SessionEnd => "session_end", + } + } +} + +/// 会话结束原因。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EndReason { + /// 进程自然退出。 + Exit, + /// 被主动结束。 + Killed, + /// 启动前失败(组装/组件检查)。 + AssemblyFailed, + /// 进程派生失败。 + SpawnFailed, +} + +impl EndReason { + /// JSONL 里的 `reason` 字符串。 + pub fn as_str(self) -> &'static str { + match self { + Self::Exit => "exit", + Self::Killed => "killed", + Self::AssemblyFailed => "assembly_failed", + Self::SpawnFailed => "spawn_failed", + } + } +} + +pub use crate::launch::session_meta::SessionMeta; +pub use crate::launch::session_sink::SessionSink; + +#[derive(Serialize)] +struct EventLine<'a> { + v: u8, + seq: u64, + ts: String, + t_ms: u64, + kind: &'a str, + session_id: &'a str, + payload: &'a Value, +} + +/// 会话事件流写入器。 +#[derive(Debug)] +pub struct SessionWriter { + session_id: String, + clock: SessionClock, + file: BufWriter, + path: PathBuf, + index_path: PathBuf, + seq: u64, + event_count: u64, + redactor: Redactor, + meta: SessionMeta, + started_at: String, + window_detected: bool, + crash_found: bool, + exit_code: Option, + exit_type: Option, + silent_failure: bool, + diagnosis_top: Option, + finished: bool, +} + +impl SessionWriter { + /// 新建会话(id 由时间 + pid 生成)。 + pub fn create(sessions_root: &Path, meta: SessionMeta, redactor: Redactor) -> Result { + let session_id = time::make_session_id(time::now(), std::process::id()); + Self::create_with_id(sessions_root, &session_id, meta, redactor) + } + + /// 用指定 id 新建会话(测试与“续写”场景)。 + pub fn create_with_id( + sessions_root: &Path, + session_id: &str, + meta: SessionMeta, + redactor: Redactor, + ) -> Result { + let dir = sessions_root.join("game"); + fs::create_dir_all(&dir).map_err(|source| LaunchError::Io { + op: "create_session_dir", + path: dir.clone(), + source, + })?; + let path = dir.join(format!("{session_id}.jsonl")); + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|source| LaunchError::Io { + op: "create_session_file", + path: path.clone(), + source, + })?; + let clock = SessionClock::start_new(); + let started_at = clock.started_at_iso(); + Ok(Self { + session_id: session_id.to_string(), + clock, + file: BufWriter::new(file), + path, + index_path: session_index::index_path(sessions_root), + seq: 0, + event_count: 0, + redactor, + meta, + started_at, + window_detected: false, + crash_found: false, + exit_code: None, + exit_type: None, + silent_failure: false, + diagnosis_top: None, + finished: false, + }) + } + + /// 会话 id。 + pub fn session_id(&self) -> &str { + &self.session_id + } + + /// 事件文件路径。 + pub fn path(&self) -> &Path { + &self.path + } + + /// 已写入事件数。 + pub fn event_count(&self) -> u64 { + self.event_count + } + + /// 是否已结束。 + pub fn is_finished(&self) -> bool { + self.finished + } + + /// 写一条事件。 + pub fn record(&mut self, kind: EventKind, payload: Value) -> Result<()> { + let seq = self.seq; + let line = EventLine { + v: SCHEMA_VERSION, + seq, + ts: self.clock.now_iso(), + t_ms: self.clock.elapsed_ms(), + kind: kind.as_str(), + session_id: &self.session_id, + payload: &payload, + }; + let serialized = serde_json::to_string(&line).map_err(|err| LaunchError::Session { + path: self.path.clone(), + reason: err.to_string(), + })?; + self.file + .write_all(serialized.as_bytes()) + .and_then(|()| self.file.write_all(b"\n")) + .and_then(|()| self.file.flush()) + .map_err(|source| LaunchError::Io { + op: "write_session_event", + path: self.path.clone(), + source, + })?; + self.seq += 1; + self.event_count += 1; + Ok(()) + } + + /// `session_start`。 + pub fn session_start(&mut self) -> Result<()> { + let payload = payload::session_start(&self.meta, SCHEMA_VERSION); + self.record(EventKind::SessionStart, payload) + } + + /// `cmd_built`:完整 argv(已脱敏)+ classpath 摘要。 + pub fn cmd_built(&mut self, command: &AssembledCommand) -> Result<()> { + let payload = payload::cmd_built(command, &self.redactor); + self.record(EventKind::CmdBuilt, payload) + } + + /// `assembly_failed`:启动前失败,附结构化缺失清单(M15 据此下结论)。 + pub fn assembly_failed(&mut self, error: &AssemblyError) -> Result<()> { + let payload = payload::assembly_failed(error); + self.record(EventKind::AssemblyFailed, payload) + } + + /// `proc_start`。 + pub fn proc_start(&mut self, pid: u32) -> Result<()> { + let payload = payload::proc_start(pid, &self.clock.now_iso()); + self.record(EventKind::ProcStart, payload) + } + + /// `natives_extracted`:natives 解压结果(启动期 `UnsatisfiedLinkError` 的第一嫌疑)。 + pub fn natives_extracted(&mut self, report: &NativesReport) -> Result<()> { + let payload = payload::natives_extracted(report, &self.redactor); + self.record(EventKind::NativesExtracted, payload) + } + + /// `stdout` / `stderr` 行事件。 + pub fn line(&mut self, line: &LogLine) -> Result<()> { + let kind = match line.stream { + Stream::Stdout => EventKind::Stdout, + Stream::Stderr => EventKind::Stderr, + }; + let text = self.redactor.scrub(&line.text).into_owned(); + let payload = payload::log_line(&text, line.level()); + self.record(kind, payload) + } + + /// `window_detected`。 + pub fn window_detected(&mut self, detection: &Detection) -> Result<()> { + self.window_detected = true; + let payload = payload::window_detected(detection, &self.redactor); + self.record(EventKind::WindowDetected, payload) + } + + /// 是否已检测到游戏窗口(索引摘要用)。 + pub fn is_window_detected(&self) -> bool { + self.window_detected + } + + /// `killed`。 + pub fn killed(&mut self, by: &str, signal: Option<&str>) -> Result<()> { + let payload = payload::killed(by, signal); + self.record(EventKind::Killed, payload) + } + + /// `exit`。 + pub fn exit(&mut self, analysis: &ExitAnalysis) -> Result<()> { + self.exit_code = Some(analysis.code); + self.exit_type = Some(analysis.exit_type.as_str().to_string()); + self.silent_failure = analysis.silent_failure.is_some(); + let payload = payload::exit(analysis); + self.record(EventKind::Exit, payload) + } + + /// `crash_found`:以 `path` 为权威字段(M15 自己读盘)。 + pub fn crash_found(&mut self, source: &str, path: &Path, excerpt: Option<&str>) -> Result<()> { + self.crash_found = true; + let payload = payload::crash_found(source, path, excerpt, &self.redactor); + self.record(EventKind::CrashFound, payload) + } + + /// `diagnosis`:M15 产出的结构化结论,由 launch 脱敏后落盘(单一真相源)。 + /// + /// M15 会按 `crash_found.path` 自己读盘再组 payload,崩溃报告/`latest.log` 里的 + /// 用户主目录路径与 token 会由此带进结论,所以这里必须再过一遍 [`Redactor`] + /// (只改字符串,字段名/数值/布尔不动——`launch-接口约定.md` §9.3)。 + pub fn diagnosis(&mut self, record: Value) -> Result<()> { + let record = self.redactor.scrub_json(&record); + if self.diagnosis_top.is_none() { + self.diagnosis_top = record + .get("candidates") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(|item| item.get("id")) + .and_then(Value::as_str) + .map(str::to_string); + } + self.record(EventKind::Diagnosis, record) + } + + /// `session_end` + 追加索引,返回索引条目。 + pub fn finish(&mut self, reason: EndReason) -> Result { + let duration_ms = self.clock.elapsed_ms(); + let payload = payload::session_end(reason.as_str(), duration_ms); + self.record(EventKind::SessionEnd, payload)?; + self.finished = true; + let entry = SessionIndexEntry { + v: SCHEMA_VERSION, + session_id: self.session_id.clone(), + instance_id: self.meta.instance_id.clone(), + instance_name: self.meta.instance_name.clone(), + game_version: self.meta.game_version.clone(), + started_at: self.started_at.clone(), + ended_at: self.clock.now_iso(), + duration_ms, + exit_code: self.exit_code, + exit_type: self.exit_type.clone(), + silent_failure: self.silent_failure, + window_detected: self.window_detected, + event_count: self.event_count, + log_path: format!("game/{}.jsonl", self.session_id), + crash_found: self.crash_found, + diagnosis_top: self.diagnosis_top.clone(), + }; + session_index::append(&self.index_path, &entry).map_err(|source| LaunchError::Io { + op: "append_session_index", + path: self.index_path.clone(), + source, + })?; + Ok(entry) + } +} diff --git a/crates/core/src/launch/session_index.rs b/crates/core/src/launch/session_index.rs new file mode 100644 index 0000000..4dfc47d --- /dev/null +++ b/crates/core/src/launch/session_index.rs @@ -0,0 +1,120 @@ +//! 会话记录索引:`list_game_sessions` 的数据源。 +//! +//! 文件:`/game/index.jsonl`,每会话一行摘要,会话结束时追加。 +//! 读取约定(`DevDocs/SubAgentsTalk/launch-接口约定.md` §2):同一 `session_id` +//! 可能出现多行(崩溃后重写索引),读取方**按 id 去重取最后一行**,再按 `started_at` 倒序。 + +use std::fs::{File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// 索引条目的 schema 版本。 +pub const INDEX_VERSION: u8 = 1; + +/// 一次会话的摘要。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionIndexEntry { + /// schema 版本。 + #[serde(default)] + pub v: u8, + /// 会话 id。 + pub session_id: String, + /// 实例 id。 + pub instance_id: String, + /// 实例名。 + pub instance_name: String, + /// 游戏版本。 + pub game_version: String, + /// 开始时间(RFC3339 UTC)。 + pub started_at: String, + /// 结束时间(RFC3339 UTC)。 + pub ended_at: String, + /// 时长毫秒。 + pub duration_ms: u64, + /// 退出码(未跑到进程时为 `None`)。 + pub exit_code: Option, + /// 退出判定字符串。 + pub exit_type: Option, + /// 是否静默失败。 + pub silent_failure: bool, + /// 是否检测到游戏窗口。 + pub window_detected: bool, + /// 事件条数。 + pub event_count: u64, + /// 相对 `game/` 的日志文件名。 + pub log_path: String, + /// 是否抓到崩溃报告/hs_err。 + pub crash_found: bool, + /// 诊断首因(M15 写入的第一个候选 id)。 + pub diagnosis_top: Option, +} + +/// 索引文件路径(`/game/index.jsonl`)。 +pub fn index_path(sessions_root: &Path) -> PathBuf { + sessions_root.join("game").join("index.jsonl") +} + +/// 追加一行摘要。 +pub fn append(index: &Path, entry: &SessionIndexEntry) -> std::io::Result<()> { + if let Some(parent) = index.parent() { + std::fs::create_dir_all(parent)?; + } + let mut file = OpenOptions::new().create(true).append(true).open(index)?; + let line = serde_json::to_string(entry) + .map_err(|err| std::io::Error::other(format!("序列化索引失败:{err}")))?; + file.write_all(line.as_bytes())?; + file.write_all(b"\n")?; + file.flush() +} + +/// 读取全部条目(同一 id 取最后一行,按开始时间倒序)。 +pub fn read_all(index: &Path) -> std::io::Result> { + let file = match File::open(index) { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(err), + }; + let mut entries: Vec = Vec::new(); + for line in BufReader::new(file).lines() { + let line = match line { + Ok(line) => line, + Err(_) => break, + }; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + // 容忍坏行(半截 JSON):索引是“尽力而为”的数据源,不能因为一行坏数据丢掉整份列表。 + let Ok(entry) = serde_json::from_str::(trimmed) else { + continue; + }; + match entries + .iter_mut() + .find(|item| item.session_id == entry.session_id) + { + Some(slot) => *slot = entry, + None => entries.push(entry), + } + } + entries.sort_by(|left, right| right.started_at.cmp(&left.started_at)); + Ok(entries) +} + +/// `list_game_sessions` 语义:可选实例过滤 + 条数上限,按开始时间倒序。 +pub fn list( + index: &Path, + limit: usize, + instance_id: Option<&str>, +) -> std::io::Result> { + let entries = read_all(index)?; + Ok(entries + .into_iter() + .filter(|entry| match instance_id { + Some(id) => entry.instance_id == id, + None => true, + }) + .take(limit) + .collect()) +} diff --git a/crates/core/src/launch/session_meta.rs b/crates/core/src/launch/session_meta.rs new file mode 100644 index 0000000..d56118e --- /dev/null +++ b/crates/core/src/launch/session_meta.rs @@ -0,0 +1,62 @@ +//! 会话元信息(`session_start` 的 payload 来源 + 索引摘要字段)。 +//! +//! 从 `session.rs` 拆出以守住“单文件 ≤400 行”的硬约束;公开路径仍是 +//! `launch::session::SessionMeta`(在 `session.rs` 里 re-export)。 + +use crate::launch::platform::PlatformCtx; + +/// 会话元信息(`session_start` 的 payload 来源)。 +#[derive(Debug, Clone, Default)] +pub struct SessionMeta { + /// 实例 id。 + pub instance_id: String, + /// 实例名。 + pub instance_name: String, + /// 游戏版本。 + pub game_version: String, + /// 启动器名。 + pub launcher_name: String, + /// 启动器版本。 + pub launcher_version: String, + /// 系统名。 + pub os_name: String, + /// 架构。 + pub os_arch: String, + /// 位数。 + pub os_bits: u32, +} + +impl SessionMeta { + /// 基本构造。 + pub fn new( + instance_id: impl Into, + instance_name: impl Into, + game_version: impl Into, + ) -> Self { + Self { + instance_id: instance_id.into(), + instance_name: instance_name.into(), + game_version: game_version.into(), + ..Self::default() + } + } + + /// 补启动器信息。 + pub fn with_launcher(mut self, name: impl Into, version: impl Into) -> Self { + self.launcher_name = name.into(); + self.launcher_version = version.into(); + self + } + + /// 补平台信息。 + pub fn with_platform(mut self, ctx: &PlatformCtx) -> Self { + self.os_name = ctx.os.manifest_name().to_string(); + self.os_arch = ctx.arch.clone(); + self.os_bits = if cfg!(target_pointer_width = "64") { + 64 + } else { + 32 + }; + self + } +} diff --git a/crates/core/src/launch/session_payload.rs b/crates/core/src/launch/session_payload.rs new file mode 100644 index 0000000..f647a87 --- /dev/null +++ b/crates/core/src/launch/session_payload.rs @@ -0,0 +1,159 @@ +//! 会话事件 payload 构造(schema 见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §3)。 +//! +//! 拆出来是为了让 [`crate::launch::session::SessionWriter`] 只关心“写文件 + seq + 索引”, +//! 字段定义集中在一处,评审时一眼能对完整张表。 + +use std::path::Path; + +use serde_json::{json, Value}; + +use crate::launch::args_table::AssembledCommand; +use crate::launch::error::AssemblyError; +use crate::launch::exit_analysis::ExitAnalysis; +use crate::launch::log_level::Level; +use crate::launch::natives::NativesReport; +use crate::launch::redact::Redactor; +use crate::launch::session::SessionMeta; +use crate::launch::window_detect::Detection; + +/// `session_start`。 +pub(crate) fn session_start(meta: &SessionMeta, schema: u8) -> Value { + json!({ + "instance_id": meta.instance_id, + "instance_name": meta.instance_name, + "game_version": meta.game_version, + "launcher": { + "name": meta.launcher_name, + "version": meta.launcher_version, + }, + "os": { + "name": meta.os_name, + "arch": meta.os_arch, + "bits": meta.os_bits, + }, + "schema": schema, + }) +} + +/// `cmd_built`:完整 argv(脱敏)+ classpath 摘要 + 组装告警。 +pub(crate) fn cmd_built(command: &AssembledCommand, redactor: &Redactor) -> Value { + let argv = redactor.scrub_all(&command.argv()); + let program = redactor + .scrub(&command.program.display().to_string()) + .into_owned(); + json!({ + "program": program, + "argv": argv, + "main_class": command.main_class, + "classpath_entries": command.classpath.len(), + "classpath_missing": Vec::::new(), + "memory": { + "xmx_mb": command.memory.max_mb, + "xms_mb": command.memory.min_mb, + }, + "encoding": command.encoding, + "warnings": command.warnings.iter().map(ToString::to_string).collect::>(), + }) +} + +/// `assembly_failed`:启动前失败的结构化事实(结论由 M15 下)。 +pub(crate) fn assembly_failed(error: &AssemblyError) -> Value { + let (code, components) = match error { + AssemblyError::MissingMainClass => ("missing_main_class", Vec::new()), + AssemblyError::EmptyClasspath => ("empty_classpath", Vec::new()), + AssemblyError::EmptyJavaPath => ("empty_java_path", Vec::new()), + AssemblyError::MalformedArgument { .. } => ("malformed_argument", Vec::new()), + AssemblyError::NativesExtraction { .. } => ("natives_extraction", Vec::new()), + AssemblyError::MissingComponents(items) => ( + "missing_components", + items + .iter() + .map(|item| { + json!({ + "name": item.name, + "path": item.path.as_ref().map(|path| path.display().to_string()), + }) + }) + .collect::>(), + ), + }; + json!({ + "error_code": code, + "message": error.to_string(), + "missing_components": components, + }) +} + +/// `proc_start`。 +pub(crate) fn proc_start(pid: u32, started_at: &str) -> Value { + json!({ "pid": pid, "started_at": started_at }) +} + +/// `natives_extracted`:解压事实(`files`/`skipped`/`custom` 是既有约定字段, +/// 其余为 v1 追加字段,读取方按「未知字段忽略」处理)。 +pub(crate) fn natives_extracted(report: &NativesReport, redactor: &Redactor) -> Value { + json!({ + "dir": redactor.scrub(&report.dir.display().to_string()).into_owned(), + "files": report.files, + "skipped": report.skipped, + "custom": report.custom, + "jars": report.jars, + "ignored": report.ignored, + "up_to_date": report.up_to_date, + "era": report.era.as_str(), + }) +} + +/// `killed`。 +pub(crate) fn killed(by: &str, signal: Option<&str>) -> Value { + json!({ "by": by, "signal": signal }) +} + +/// `stdout` / `stderr` 行事件。 +pub(crate) fn log_line(text: &str, level: Level) -> Value { + json!({ "line": text, "level": level.as_str() }) +} + +/// `window_detected`。 +pub(crate) fn window_detected(detection: &Detection, redactor: &Redactor) -> Value { + json!({ + "heuristic": detection.heuristic, + "took_ms": detection.took_ms, + "evidence": redactor.scrub(&detection.evidence), + }) +} + +/// `exit`。 +pub(crate) fn exit(analysis: &ExitAnalysis) -> Value { + json!({ + "code": analysis.code, + "exit_type": analysis.exit_type.as_str(), + "uptime_ms": analysis.uptime_ms, + "error_lines": analysis.error_lines, + "crash_keywords": analysis.crash_keywords, + "jvm_keywords": analysis.jvm_keywords, + "silent_failure": analysis.silent_failure.is_some(), + "windows_status": analysis.windows_status, + "needs_diagnosis": analysis.needs_diagnosis, + "summary": analysis.summary(), + }) +} + +/// `crash_found`:`path` 是权威字段,`excerpt` 仅供人读。 +pub(crate) fn crash_found( + source: &str, + path: &Path, + excerpt: Option<&str>, + redactor: &Redactor, +) -> Value { + json!({ + "source": source, + "path": path.display().to_string(), + "excerpt": excerpt.map(|text| redactor.scrub(text).into_owned()), + }) +} + +/// `session_end`。 +pub(crate) fn session_end(reason: &str, duration_ms: u64) -> Value { + json!({ "reason": reason, "duration_ms": duration_ms }) +} diff --git a/crates/core/src/launch/session_sink.rs b/crates/core/src/launch/session_sink.rs new file mode 100644 index 0000000..712b28e --- /dev/null +++ b/crates/core/src/launch/session_sink.rs @@ -0,0 +1,64 @@ +//! 把会话事件流接到日志泵上:逐行落盘 + 自动窗口出现检测。 +//! +//! 放在单独文件是为了让 [`crate::launch::session`] 保持“只写事件、只管索引”的单一职责, +//! 也避免单文件超过 400 行的硬约束。 + +use std::sync::Mutex; + +use crate::launch::pump::{LineSink, LogLine}; +use crate::launch::session::SessionWriter; +use crate::launch::window_detect::WindowDetector; + +/// 会话日志 sink。 +#[derive(Debug)] +pub struct SessionSink { + writer: Mutex, + detector: Mutex, +} + +impl SessionSink { + /// 以进程启动时刻为窗口检测起点。 + pub fn new(writer: SessionWriter) -> Self { + Self { + writer: Mutex::new(writer), + detector: Mutex::new(WindowDetector::new()), + } + } + + /// 指定窗口检测计时起点(进程 spawn 时刻)。 + pub fn with_start(writer: SessionWriter, started_at: std::time::Instant) -> Self { + Self { + writer: Mutex::new(writer), + detector: Mutex::new(WindowDetector::with_start(started_at)), + } + } + + /// 借用写入器(退出分析、结束会话等需要可变访问的场合)。 + pub fn with_writer(&self, action: impl FnOnce(&mut SessionWriter) -> R) -> Option { + self.writer.lock().ok().map(|mut guard| action(&mut guard)) + } + + /// 是否已检测到游戏窗口。 + pub fn window_detected(&self) -> bool { + match self.writer.lock() { + Ok(writer) => writer.is_window_detected(), + Err(_) => false, + } + } +} + +impl LineSink for SessionSink { + fn on_line(&self, line: &LogLine) { + let detection = match self.detector.lock() { + Ok(mut detector) => detector.observe(&line.text), + Err(_) => None, + }; + let Ok(mut writer) = self.writer.lock() else { + return; + }; + if let Some(detection) = detection { + let _ = writer.window_detected(&detection); + } + let _ = writer.line(line); + } +} diff --git a/crates/core/src/launch/sink.rs b/crates/core/src/launch/sink.rs new file mode 100644 index 0000000..8b5809a --- /dev/null +++ b/crates/core/src/launch/sink.rs @@ -0,0 +1,138 @@ +//! 日志 sink 组合:把同一个进程的输出同时喂给多个消费者。 +//! +//! 设计依据 `DevDocs/SubAgentsTalk/launch-接口约定.md` §3(单一真相源)与 tower 批复的 +//! “复合 sink”方案:会话事件流(落盘)是必需的一个 sink,M15 采集器 / UI 日志页 / +//! 测试断言各自挂自己的 sink,彼此不感知。 +//! +//! 接 M15 的姿态:diagnose 不解析 jsonl、也不 import launch 类型,所以它只需要一个 +//! `FnSink` 包一层 `LogBuffer::push(stream, line)`;这里只提供通用的组合原语。 + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use crate::launch::pump::{LineSink, LogLine, Stream}; + +/// 把一条日志广播给多个 sink(顺序 = 注册顺序)。 +#[derive(Default)] +pub struct CompositeSink { + sinks: Vec>, +} + +impl CompositeSink { + /// 空组合。 + pub fn new() -> Self { + Self::default() + } + + /// 追加一个 sink(builder 风格)。 + pub fn with(mut self, sink: Arc) -> Self { + self.sinks.push(sink); + self + } + + /// 追加一个 sink。 + pub fn push(&mut self, sink: Arc) { + self.sinks.push(sink); + } + + /// 已注册的 sink 数。 + pub fn len(&self) -> usize { + self.sinks.len() + } + + /// 是否为空。 + pub fn is_empty(&self) -> bool { + self.sinks.is_empty() + } +} + +impl std::fmt::Debug for CompositeSink { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CompositeSink") + .field("sinks", &self.sinks.len()) + .finish() + } +} + +impl LineSink for CompositeSink { + fn on_line(&self, line: &LogLine) { + for sink in &self.sinks { + sink.on_line(line); + } + } +} + +/// 闭包 sink:把 M15 采集器、UI 通道、测试断言接进来的最轻方式。 +/// +/// ```ignore +/// let sink = FnSink::new(move |line: &LogLine| { +/// bundle.log.push(stream_kind(line.stream), &line.text); +/// }); +/// ``` +pub struct FnSink { + action: F, +} + +impl FnSink { + /// 用闭包构造。 + pub fn new(action: F) -> Self { + Self { action } + } +} + +impl FnSink { + /// 转成 `Arc`(可直接塞进 [`CompositeSink`] / `RunningProcess`)。 + pub fn shared(self) -> Arc { + Arc::new(self) + } +} + +impl std::fmt::Debug for FnSink { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("FnSink") + } +} + +impl LineSink for FnSink { + fn on_line(&self, line: &LogLine) { + (self.action)(line); + } +} + +/// 计数 sink:给进度条/测试一个“已收多少行”的廉价探针。 +#[derive(Debug, Default)] +pub struct CountingSink { + stdout: AtomicU64, + stderr: AtomicU64, +} + +impl CountingSink { + /// 新计数器。 + pub fn new() -> Self { + Self::default() + } + + /// 标准输出行数。 + pub fn stdout_lines(&self) -> u64 { + self.stdout.load(Ordering::Relaxed) + } + + /// 标准错误行数。 + pub fn stderr_lines(&self) -> u64 { + self.stderr.load(Ordering::Relaxed) + } + + /// 总行数。 + pub fn total_lines(&self) -> u64 { + self.stdout_lines() + self.stderr_lines() + } +} + +impl LineSink for CountingSink { + fn on_line(&self, line: &LogLine) { + match line.stream { + Stream::Stdout => self.stdout.fetch_add(1, Ordering::Relaxed), + Stream::Stderr => self.stderr.fetch_add(1, Ordering::Relaxed), + }; + } +} diff --git a/crates/core/src/launch/time.rs b/crates/core/src/launch/time.rs new file mode 100644 index 0000000..39f1ead --- /dev/null +++ b/crates/core/src/launch/time.rs @@ -0,0 +1,118 @@ +//! 时间工具:RFC3339(毫秒,UTC)格式化与会话时钟。 +//! +//! 会话事件流的 `ts` / `t_ms` 字段由这里产出(schema 见 +//! `DevDocs/SubAgentsTalk/launch-接口约定.md` §2)。 +//! 只用 `std::time`,不引入 chrono/time 依赖。 + +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +/// 把 `SystemTime` 格式化成 RFC3339 UTC 毫秒精度串,例 `2026-10-29T12:45:01.123Z`。 +/// +/// 早于 Unix 纪元的时间按纪元处理(不会 panic:事件流宁可有 1970 的时间戳也不能丢事件)。 +pub fn format_rfc3339_millis(time: SystemTime) -> String { + let since_epoch = time.duration_since(UNIX_EPOCH).unwrap_or_default(); + let secs = since_epoch.as_secs(); + let millis = since_epoch.subsec_millis(); + let days = i64::try_from(secs / 86_400).unwrap_or(i64::MAX); + let secs_of_day = secs % 86_400; + let (year, month, day) = civil_from_days(days); + let hour = secs_of_day / 3_600; + let minute = (secs_of_day % 3_600) / 60; + let second = secs_of_day % 60; + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z") +} + +/// 当前墙上时间(便于测试替换的单一入口)。 +pub fn now() -> SystemTime { + SystemTime::now() +} + +/// 由“自 1970-01-01 起的天数”还原公历年月日(Howard Hinnant 的 civil_from_days)。 +pub 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 year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if month <= 2 { year + 1 } else { year }; + ( + year, + u32::try_from(month).unwrap_or(1), + u32::try_from(day).unwrap_or(1), + ) +} + +/// 会话时钟:同时记录单调时刻(`t_ms`)与墙上时刻(`ts`)。 +#[derive(Debug, Clone)] +pub struct SessionClock { + start_instant: Instant, + started_at: SystemTime, +} + +impl SessionClock { + /// 以“现在”为起点创建时钟。 + pub fn start_new() -> Self { + Self { + start_instant: Instant::now(), + started_at: now(), + } + } + + /// 用给定的墙上时间与单调时刻创建(测试可注入)。 + pub fn started_at(start_instant: Instant, started_at: SystemTime) -> Self { + Self { + start_instant, + started_at, + } + } + + /// 会话开始的墙上时间。 + pub fn started_at_wall(&self) -> SystemTime { + self.started_at + } + + /// 会话开始时刻的 RFC3339 串。 + pub fn started_at_iso(&self) -> String { + format_rfc3339_millis(self.started_at) + } + + /// 距会话开始的毫秒数(`t_ms`)。 + pub fn elapsed_ms(&self) -> u64 { + u64::try_from(self.start_instant.elapsed().as_millis()).unwrap_or(u64::MAX) + } + + /// 当前墙上时间的 RFC3339 串(`ts`)。 + pub fn now_iso(&self) -> String { + format_rfc3339_millis(now()) + } +} + +impl Default for SessionClock { + fn default() -> Self { + Self::start_new() + } +} + +/// 生成会话 id:`YYYYMMDDTHHMMSS-<6 位十六进制>`。 +/// +/// 十六进制段由墙上时间与 pid 混合而来——不引入 rand 依赖,同一秒同一进程内也能区分 +/// (调用方可在冲突时追加序号)。 +pub fn make_session_id(wall: SystemTime, pid: u32) -> String { + let since_epoch = wall.duration_since(UNIX_EPOCH).unwrap_or_default(); + let secs = since_epoch.as_secs(); + let days = i64::try_from(secs / 86_400).unwrap_or(i64::MAX); + let (year, month, day) = civil_from_days(days); + let secs_of_day = secs % 86_400; + let hour = secs_of_day / 3_600; + let minute = (secs_of_day % 3_600) / 60; + let second = secs_of_day % 60; + let mut hash: u64 = u64::from(since_epoch.subsec_nanos()).wrapping_add(u64::from(pid)); + hash ^= secs.rotate_left(17); + hash = hash.wrapping_mul(0x9E37_79B9_7F4A_7C15); + let suffix = (hash >> 40) & 0xFF_FFFF; + format!("{year:04}{month:02}{day:02}T{hour:02}{minute:02}{second:02}-{suffix:06x}") +} diff --git a/crates/core/src/launch/tree_kill.rs b/crates/core/src/launch/tree_kill.rs new file mode 100644 index 0000000..7988cdc --- /dev/null +++ b/crates/core/src/launch/tree_kill.rs @@ -0,0 +1,116 @@ +//! 进程树回收:`SIGTERM → 宽限 → SIGKILL` 三段式关闭。 +//! +//! HMCL 只 `process.destroy()` 单进程(`analyse/02` §6.8),子进程会残留、日志尾部丢失。 +//! 这里做进程**树**回收: +//! +//! - 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 -`(命不中是常态,因为我们没有 `setsid`——`unsafe` pre_exec 被 +//! workspace `unsafe_code = deny` 挡住)→ 宽限 → `kill -KILL -`,再退到单 pid。 +//! +//! 两个平台都先尝试“温和”,宽限期后再“强制”。 +//! +//! ⚠️ 外部命令的退出码**不能**用来判断“进程是否已被结束”:温和阶段成功关掉进程后, +//! 再发 `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<()> { + imp::signal(pid, false) +} + +/// 强制关闭(Windows `/F`;POSIX `SIGKILL`),并尽量覆盖整棵进程树。 +pub fn force_kill(pid: u32) -> io::Result<()> { + imp::signal(pid, true) +} + +/// 三段式:温和 → 宽限 → 强制。返回**强制阶段**的结果(温和阶段失败不致命)。 +/// +/// 注意:返回值不代表“进程已经死了”(见模块注释)。需要准确判定请用 +/// [`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")); + } + let _ = terminate(pid); + if !grace.is_zero() { + thread::sleep(grace); + } + force_kill(pid) +} + +#[cfg(windows)] +mod imp { + use super::*; + use std::os::windows::process::CommandExt; + + /// 不弹控制台窗口(否则 kill 子进程会闪一个黑框)。 + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + pub(super) fn signal(pid: u32, force: bool) -> io::Result<()> { + let mut command = Command::new("taskkill"); + command.arg("/PID").arg(pid.to_string()).arg("/T"); + if force { + command.arg("/F"); + } + command.creation_flags(CREATE_NO_WINDOW); + let status = command + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status()?; + if status.success() { + Ok(()) + } else { + // taskkill 对已退出的进程返回非 0,这不值得中断关闭流程。 + Err(io::Error::other(format!("taskkill 退出码 {status}"))) + } + } +} + +#[cfg(unix)] +mod imp { + use super::*; + + pub(super) fn signal(pid: u32, force: bool) -> io::Result<()> { + let flag = if force { "-KILL" } else { "-TERM" }; + // 先按进程组(`-`);没有 setsid 时通常失败,再退到单 pid。 + if run_kill(flag, &format!("-{pid}"))?.is_some() { + return Ok(()); + } + 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 命令")), + } + } + + fn run_kill(flag: &str, target: &str) -> io::Result> { + match Command::new("kill") + .arg(flag) + .arg(target) + .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), + Err(err) => Err(err), + } + } +} + +#[cfg(not(any(windows, unix)))] +mod imp { + use super::*; + + pub(super) fn signal(_pid: u32, _force: bool) -> io::Result<()> { + Err(io::Error::other("当前平台不支持进程树 kill")) + } +} diff --git a/crates/core/src/launch/watchdog.rs b/crates/core/src/launch/watchdog.rs new file mode 100644 index 0000000..745ef53 --- /dev/null +++ b/crates/core/src/launch/watchdog.rs @@ -0,0 +1,71 @@ +//! 看门狗:给 pump 的 join 加超时。 +//! +//! HMCL 的 `ExitWaiter` 在 `waitFor()` 之后无条件 `join` 两条泵线程 +//! (`analyse/02` §6.1、§8.5):若子进程自己不关闭 stdout(例如它又拉起了不退出的 +//! 子进程),`onExit` 永远不回调,UI 卡在“正在启动”。这里把“等日志落盘”改成 +//! 有预算的等待——超时就带着已收集到的日志继续做退出分析,绝不无限等待。 + +use std::time::Duration; + +use crate::launch::pump::{PumpCompletion, PumpGroup, Stream}; + +/// 看门狗配置。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WatchdogConfig { + /// 进程退出后等待日志泵结束的预算。 + pub pump_timeout: Duration, +} + +impl Default for WatchdogConfig { + fn default() -> Self { + Self { + pump_timeout: Duration::from_secs(10), + } + } +} + +/// 看门狗判定。 +#[derive(Debug, Clone)] +pub enum PumpVerdict { + /// 两条流都正常结束。 + Complete(PumpCompletion), + /// 超时:仍有流未结束(附带是哪几条)。 + Stalled { + /// 等待结果(含已完成流与行数)。 + completion: PumpCompletion, + /// 卡住的流。 + streams: Vec, + /// 等待了多久。 + waited: Duration, + }, +} + +impl PumpVerdict { + /// 是否一切正常。 + pub fn is_complete(&self) -> bool { + matches!(self, Self::Complete(_)) + } + + /// 等待结果(两种情形都有)。 + pub fn completion(&self) -> &PumpCompletion { + match self { + Self::Complete(completion) => completion, + Self::Stalled { completion, .. } => completion, + } + } +} + +/// 用配置里的预算等待日志泵结束。 +pub fn wait_for_pumps(group: &mut PumpGroup, config: &WatchdogConfig) -> PumpVerdict { + let completion = group.wait(config.pump_timeout); + if completion.is_complete() { + PumpVerdict::Complete(completion) + } else { + let streams = completion.stalled.clone(); + PumpVerdict::Stalled { + completion, + streams, + waited: config.pump_timeout, + } + } +} diff --git a/crates/core/src/launch/window_detect.rs b/crates/core/src/launch/window_detect.rs new file mode 100644 index 0000000..1a1a667 --- /dev/null +++ b/crates/core/src/launch/window_detect.rs @@ -0,0 +1,86 @@ +//! “游戏窗口是否出现”的检测(日志启发式)。 +//! +//! HMCL 只看两条小写子串(`analyse/02` §6.5:`lwjgl version` / `lwjgl openal`), +//! 换日志格式就失效。v1 保持同样的启发式但做成**可扩展模式表 + 命中证据 + 耗时**, +//! 这样 `window_detected` 事件能告诉 AI“靠哪一行判定的、花了多久”; +//! 进程/窗口级信号(Windows `EnumWindows` 按 PID)留 P1(`PlanDoc/06` §1 升级点 3)。 + +use std::time::Instant; + +/// 一次成功检测。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Detection { + /// 启发式名称(事件流 `window_detected.heuristic`)。 + pub heuristic: String, + /// 从进程启动到命中的毫秒数。 + pub took_ms: u64, + /// 命中的日志行(事件流 `evidence`)。 + pub evidence: String, +} + +/// 启发式模式表:(小写子串,启发式名称)。 +pub const HEURISTICS: &[(&str, &str)] = &[ + ("lwjgl version", "lwjgl_version"), + ("lwjgl openal", "lwjgl_openal"), + ("sound engine started", "sound_engine"), +]; + +/// 窗口检测器。 +#[derive(Debug, Clone)] +pub struct WindowDetector { + started_at: Instant, + detection: Option, +} + +impl WindowDetector { + /// 以“现在”为计时起点。 + pub fn new() -> Self { + Self { + started_at: Instant::now(), + detection: None, + } + } + + /// 指定计时起点(进程 spawn 时刻)。 + pub fn with_start(started_at: Instant) -> Self { + Self { + started_at, + detection: None, + } + } + + /// 观察一行日志;首次命中返回检测结果。 + pub fn observe(&mut self, line: &str) -> Option { + if self.detection.is_some() { + return None; + } + let lower = line.to_ascii_lowercase(); + let (needle, name) = HEURISTICS + .iter() + .find(|(needle, _)| lower.contains(needle))?; + let _ = needle; + let detection = Detection { + heuristic: (*name).to_string(), + took_ms: u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX), + evidence: line.trim().to_string(), + }; + self.detection = Some(detection.clone()); + Some(detection) + } + + /// 是否已经检测到窗口。 + pub fn is_detected(&self) -> bool { + self.detection.is_some() + } + + /// 检测结果。 + pub fn detection(&self) -> Option<&Detection> { + self.detection.as_ref() + } +} + +impl Default for WindowDetector { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..16966bf --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,24 @@ +//! Unlucky-Launcher 领域库(`unlucky_core`)。 +//! +//! 本 crate **零 UI 依赖**:UI 框架(Slint)只允许出现在 `crates/app`。 +//! 这是对 HMCL「HMCLCore 34 处 import javafx」架构债的正面修正, +//! 由 `.github/workflows/guards.yml` 的 `core-no-ui` job 在 CI 守门。 +//! +//! core 对 UI 的回调只有三条通道(见 `DevDocs/PlanDoc/01-tech-stack-and-architecture.md` §3): +//! 1. 不可变快照发布;2. 事件流(启动/下载/诊断 JSONL 事件);3. 注入的显示服务接口(进度/确认)。 +//! +//! 设计定稿索引:`DevDocs/PlanDoc/00-master-architecture.md`。 +//! 各模块的设计文档在对应 `mod.rs` 的模块注释里点名。 +//! +//! 当前状态:P0 脚手架,只有模块骨架与设计注释,实现按 M12–M15/M18 分批落地。 + +#![forbid(unsafe_code)] + +pub mod addon; +pub mod auth; +pub mod diagnose; +pub mod download; +pub mod game; +pub mod launch; +pub mod platform; +pub mod setting; diff --git a/crates/core/src/platform/atomic.rs b/crates/core/src/platform/atomic.rs new file mode 100644 index 0000000..9448a84 --- /dev/null +++ b/crates/core/src/platform/atomic.rs @@ -0,0 +1,79 @@ +//! 原子写:临时文件 → fsync → rename。 +//! +//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.6(HMCL `FileUtils.saveSafely`: +//! 写 tmp + `Files.move(REPLACE_EXISTING)`)。配置与下载落盘都该走这里,禁止直接 `fs::write`。 +//! +//! `std::fs::rename` 在 Unix 与 Windows 上都是「替换式」语义 +//! (Windows 侧 std 用 `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`),因此不需要先删目标文件。 + +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use super::error::{io_at, PlatformError, PlatformResult}; + +/// 同进程内的 tmp 名去重计数器。 +static TMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// 原子地把 `bytes` 写到 `path`(父目录不存在会创建)。 +/// +/// # Errors +/// 建目录、写临时文件、fsync、rename 任一步失败都返回 [`PlatformError::Io`], +/// 且失败时会尽力清理临时文件,不留垃圾。 +pub fn write_atomic(path: &Path, bytes: &[u8]) -> PlatformResult<()> { + let parent = path.parent().unwrap_or_else(|| Path::new("")); + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(io_at(parent))?; + } + + let tmp = tmp_path(path); + let write_result = (|| -> io::Result<()> { + let mut file = std::fs::File::create(&tmp)?; + file.write_all(bytes)?; + file.sync_all()?; + Ok(()) + })(); + if let Err(source) = write_result { + std::fs::remove_file(&tmp).ok(); + return Err(PlatformError::Io { path: tmp, source }); + } + + if let Err(source) = std::fs::rename(&tmp, path) { + std::fs::remove_file(&tmp).ok(); + return Err(PlatformError::Io { + path: path.to_path_buf(), + source, + }); + } + Ok(()) +} + +/// 读文件;不存在返回 `None`,其它错误照原样上报(用于区分「没配过」和「读不了」)。 +/// +/// # Errors +/// 除 `NotFound` 以外的读失败返回 [`PlatformError::Io`]。 +pub fn read_optional(path: &Path) -> PlatformResult>> { + match std::fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None), + Err(source) => Err(PlatformError::Io { + path: path.to_path_buf(), + source, + }), + } +} + +/// 同目录下的临时文件名(`launcher.json` → `launcher.json.tmp-`)。 +#[must_use] +pub fn tmp_path(path: &Path) -> PathBuf { + let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let name = path.file_name().map_or_else( + || String::from("ull"), + |name| name.to_string_lossy().into_owned(), + ); + let tmp_name = format!(".{name}.tmp{}-{counter}", std::process::id()); + match path.parent() { + Some(parent) => parent.join(tmp_name), + None => PathBuf::from(tmp_name), + } +} diff --git a/crates/core/src/platform/data_dir.rs b/crates/core/src/platform/data_dir.rs new file mode 100644 index 0000000..f1ca7b0 --- /dev/null +++ b/crates/core/src/platform/data_dir.rs @@ -0,0 +1,235 @@ +//! 三模式数据目录解析(Portable 默认 / Standard / Custom)。 +//! +//! 设计出处:`DevDocs/PlanDoc/04-data-and-config.md` §1—— +//! 用户原话定案:「1.默认在运行目录下生成 .ULL .minecraft 目录 2.设置内可以更改到标准的用户应用数据目录 +//! 3.用户可手动指定数据目录」。 +//! +//! 附加约束:`.ULL` 与 `.minecraft` 同根但**可分离**(`launcher_home` / `game_home` 覆盖项), +//! macOS `.app` 只读所以默认走 Standard。 + +use std::fmt; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use super::error::{PlatformError, PlatformResult}; +use super::layout::{GAME_HOME_DIR, LAUNCHER_HOME_DIR}; +use super::paths::{self, EnvSource, Platform, ProcessEnv}; +use super::redirect; + +/// 数据目录三模式。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DataMode { + /// exe 运行目录下生成 `.ULL/` 与 `.minecraft/`(首次运行的默认)。 + #[default] + Portable, + /// 各 OS 标准应用数据目录。 + Standard, + /// 用户手动指定目录。 + Custom, +} + +impl DataMode { + /// 稳定名(写进 launcher.json 与日志)。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Portable => "portable", + Self::Standard => "standard", + Self::Custom => "custom", + } + } + + /// 从配置字符串解析。 + #[must_use] + pub fn parse(text: &str) -> Option { + match text { + "portable" => Some(Self::Portable), + "standard" => Some(Self::Standard), + "custom" => Some(Self::Custom), + _ => None, + } + } + + /// 该平台的默认模式:macOS 的 `.app` 内是只读的 → 默认 Standard(§1 细节)。 + #[must_use] + pub const fn default_for(platform: Platform) -> Self { + match platform { + Platform::MacOs => Self::Standard, + _ => Self::Portable, + } + } +} + +impl fmt::Display for DataMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// 设置里关于数据目录的原始输入。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DataDirRequest { + /// 三模式。 + pub mode: DataMode, + /// Custom 模式的根目录(必须绝对路径)。 + pub custom_root: Option, + /// 高级:显式覆盖启动器数据目录(`.ULL` 的等价物)。 + pub launcher_home: Option, + /// 高级:游戏目录与数据目录分离指定(整合包用户:游戏在 D 盘,配置在用户目录)。 + pub game_home: Option, +} + +/// 解析结果:实际生效的两个 home。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedData { + /// 生效模式。 + pub mode: DataMode, + /// 启动器数据目录(四分区之家)。 + pub launcher_home: PathBuf, + /// 游戏目录(`.minecraft`)。 + pub game_home: PathBuf, + /// 经 `redirect.json` 跳转时的原始目录(未跳转为 `None`)。 + pub redirected_from: Option, +} + +impl ResolvedData { + /// 生效的启动器布局视图。 + #[must_use] + pub fn layout(&self) -> super::layout::LauncherLayout { + super::layout::LauncherLayout::new(self.launcher_home.clone()) + } + + /// 是否和另一份解析结果指向同一组目录(迁移判定用)。 + #[must_use] + pub fn same_target(&self, other: &Self) -> bool { + self.launcher_home == other.launcher_home && self.game_home == other.game_home + } +} + +/// 三模式解析器。环境与 exe 目录都可注入,单测里能确定性地覆盖各 OS 落点。 +pub struct Resolver<'env> { + platform: Platform, + exe_dir: Option, + env: &'env dyn EnvSource, +} + +impl Resolver<'static> { + /// 用真实进程环境构造。 + #[must_use] + pub fn new(platform: Platform, exe_dir: Option) -> Self { + Self { + platform, + exe_dir, + env: &ProcessEnv, + } + } +} + +impl<'env> Resolver<'env> { + /// 注入环境来源(测试 / 便携化封装)。 + #[must_use] + pub fn with_env( + platform: Platform, + exe_dir: Option, + env: &'env dyn EnvSource, + ) -> Self { + Self { + platform, + exe_dir, + env, + } + } + + /// 平台。 + #[must_use] + pub const fn platform(&self) -> Platform { + self.platform + } + + /// 该平台的默认模式。 + #[must_use] + pub const fn default_mode(&self) -> DataMode { + DataMode::default_for(self.platform) + } + + /// 按三模式解析出实际目录(不跟随 `redirect.json`)。 + /// + /// # Errors + /// Portable 拿不到 exe 目录、Standard 拿不到标准目录、Custom 没给绝对路径时返回错误。 + pub fn resolve(&self, request: &DataDirRequest) -> PlatformResult { + let (launcher_home, game_home) = match request.mode { + DataMode::Portable => { + let exe_dir = self.exe_dir.clone().ok_or(PlatformError::NoExecutableDir)?; + (exe_dir.join(LAUNCHER_HOME_DIR), exe_dir.join(GAME_HOME_DIR)) + } + DataMode::Standard => { + let launcher = paths::standard_launcher_home(self.platform, self.env).ok_or( + PlatformError::NoStandardDataDir { + platform: self.platform.as_str(), + }, + )?; + let game = paths::standard_game_home(self.platform, self.env).ok_or( + PlatformError::NoStandardDataDir { + platform: self.platform.as_str(), + }, + )?; + (launcher, game) + } + DataMode::Custom => { + let root = request.custom_root.clone().ok_or_else(|| { + PlatformError::InvalidCustomRoot { + root: PathBuf::new(), + } + })?; + if !root.is_absolute() { + return Err(PlatformError::InvalidCustomRoot { root }); + } + (root.join(LAUNCHER_HOME_DIR), root.join(GAME_HOME_DIR)) + } + }; + + Ok(ResolvedData { + mode: request.mode, + launcher_home: request.launcher_home.clone().unwrap_or(launcher_home), + game_home: request.game_home.clone().unwrap_or(game_home), + redirected_from: None, + }) + } + + /// 解析并跟随 `/redirect.json`(便携盘被搬走后旧目录不再自己长出新实例)。 + /// + /// # Errors + /// 解析失败、指针损坏、跳转成环/超限时返回错误。 + pub fn resolve_effective( + &self, + request: &DataDirRequest, + max_hops: usize, + ) -> PlatformResult { + let resolved = self.resolve(request)?; + let followed = redirect::follow(&resolved.launcher_home, max_hops)?; + match followed { + Some(followed) => Ok(ResolvedData { + mode: resolved.mode, + launcher_home: followed.pointer.launcher_home.clone(), + game_home: followed.pointer.game_home.clone(), + redirected_from: Some(resolved.launcher_home), + }), + None => Ok(resolved), + } + } +} + +/// 默认跟随跳数上限(防成环)。 +pub const DEFAULT_REDIRECT_MAX_HOPS: usize = 8; + +impl<'env> Resolver<'env> { + /// [`Self::resolve_effective`] 的默认跳数版本。 + /// + /// # Errors + /// 同 [`Self::resolve_effective`]。 + pub fn resolve_default(&self, request: &DataDirRequest) -> PlatformResult { + self.resolve_effective(request, DEFAULT_REDIRECT_MAX_HOPS) + } +} diff --git a/crates/core/src/platform/dir_migration.rs b/crates/core/src/platform/dir_migration.rs new file mode 100644 index 0000000..7a1a14e --- /dev/null +++ b/crates/core/src/platform/dir_migration.rs @@ -0,0 +1,252 @@ +//! 数据目录迁移:计划 → 执行 → 失败回滚 → 收据 + `redirect.json` 指针。 +//! +//! 设计出处:`DevDocs/PlanDoc/04-data-and-config.md` §1 细节—— +//! 「迁移 = 移动 + 原地留 redirect.json 指针;迁移失败自动回滚(HMCL `MigrationReceipt` 指纹防重放思想)」。 +//! +//! 安全取舍(都是显式拒绝,不猜用户意图): +//! 1. 目标目录已有数据 → 拒绝(不合并,避免半合并状态); +//! 2. 任一步失败 → 逆序回滚已搬的分区;回滚也失败 → [`PlatformError::RollbackFailed`] 要求人工介入; +//! 3. `cache/` 是纯缓存,默认一起搬但可以关掉。 + +use std::io; +use std::path::{Path, PathBuf}; + +use super::data_dir::ResolvedData; +use super::error::{io_at, PlatformError, PlatformResult}; +use super::layout::{LauncherLayout, Partition}; +use super::receipt::{self, MigrationReceipt}; +use super::redirect::{self, RedirectPointer, REDIRECT_SCHEMA_VERSION}; + +/// 迁移选项。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DirMigrationOptions { + /// 是否连 `cache/` 一起搬(关掉则目标侧重建缓存)。 + pub migrate_cache: bool, +} + +impl Default for DirMigrationOptions { + fn default() -> Self { + Self { + migrate_cache: true, + } + } +} + +/// 一步分区搬运。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MoveStep { + /// 分区。 + pub partition: Partition, + /// 源目录。 + pub source: PathBuf, + /// 目标目录。 + pub target: PathBuf, +} + +/// 已校验过的迁移计划。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirMigrationPlan { + /// 源布局。 + pub from: ResolvedData, + /// 目标布局。 + pub to: ResolvedData, + /// 要搬的分区(按搬动顺序:config → data → logs → cache)。 + pub steps: Vec, +} + +/// 迁移结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirMigrationOutcome { + /// 已搬的分区(按搬动顺序)。 + pub moved: Vec, + /// 写入目标的收据。 + pub receipt: MigrationReceipt, + /// 写入旧目录的指针。 + pub pointer: RedirectPointer, + /// 收据落盘路径。 + pub receipt_path: PathBuf, + /// 指针落盘路径。 + pub pointer_path: PathBuf, +} + +/// 搬动顺序:配置文件最重要、缓存最后(且可跳过)。 +const ORDER: [Partition; 4] = [ + Partition::Config, + Partition::Data, + Partition::Logs, + Partition::Cache, +]; + +/// 生成计划(不改动磁盘,只校验)。目标目录已存在且非空 → 拒绝。 +/// +/// # Errors +/// 源目标相同、源目录不存在、目标非空、没有可搬分区时返回 [`PlatformError::MigrationRefused`]。 +pub fn plan( + from: &ResolvedData, + to: &ResolvedData, + options: &DirMigrationOptions, +) -> PlatformResult { + if from.same_target(to) { + return Err(PlatformError::MigrationRefused { + reason: format!("源与目标相同: {}", from.launcher_home.display()), + }); + } + if !from.launcher_home.is_dir() { + return Err(PlatformError::MigrationRefused { + reason: format!("源数据目录不存在: {}", from.launcher_home.display()), + }); + } + if is_non_empty_dir(&to.launcher_home) { + return Err(PlatformError::MigrationRefused { + reason: format!("目标数据目录已有数据: {}", to.launcher_home.display()), + }); + } + + let from_layout = LauncherLayout::new(from.launcher_home.clone()); + let to_layout = LauncherLayout::new(to.launcher_home.clone()); + let mut steps = Vec::new(); + for partition in ORDER { + if partition.is_disposable() && !options.migrate_cache { + continue; + } + let source = from_layout.partition(partition); + if !source.is_dir() { + continue; + } + steps.push(MoveStep { + partition, + source, + target: to_layout.partition(partition), + }); + } + if steps.is_empty() { + return Err(PlatformError::MigrationRefused { + reason: format!( + "源数据目录里没有可搬的分区: {}", + from.launcher_home.display() + ), + }); + } + Ok(DirMigrationPlan { + from: from.clone(), + to: to.clone(), + steps, + }) +} + +/// 执行计划:逐分区搬 → 失败逆序回滚 → 成功后写收据 + 旧目录指针。 +/// +/// # Errors +/// 写/搬失败且回滚成功 → 返回原始错误(磁盘已还原);回滚也失败 → [`PlatformError::RollbackFailed`]。 +pub fn execute(plan: &DirMigrationPlan, now_ms: u64) -> PlatformResult { + let mut applied: Vec<&MoveStep> = Vec::new(); + for step in &plan.steps { + match move_tree(&step.source, &step.target) { + Ok(()) => applied.push(step), + Err(err) => return Err(rollback(&applied, err)), + } + } + + let from_layout = LauncherLayout::new(plan.from.launcher_home.clone()); + let to_layout = LauncherLayout::new(plan.to.launcher_home.clone()); + std::fs::create_dir_all(from_layout.home()).map_err(io_at(from_layout.home()))?; + std::fs::create_dir_all(to_layout.config()).map_err(io_at(to_layout.config()))?; + + let receipt = MigrationReceipt::data_dir_move( + plan.from.launcher_home.clone(), + plan.to.launcher_home.clone(), + now_ms, + ); + let receipt_path = to_layout.migration_receipt(); + receipt::write(&receipt_path, &receipt)?; + + let pointer = RedirectPointer { + schema_version: REDIRECT_SCHEMA_VERSION, + mode: plan.to.mode, + launcher_home: plan.to.launcher_home.clone(), + game_home: plan.to.game_home.clone(), + migrated_at_ms: now_ms, + }; + let pointer_path = redirect::write(from_layout.home(), &pointer)?; + + Ok(DirMigrationOutcome { + moved: plan.steps.iter().map(|step| step.partition).collect(), + receipt, + pointer, + receipt_path, + pointer_path, + }) +} + +/// 逆序回滚已搬分区;回滚本身失败时升级为 [`PlatformError::RollbackFailed`]。 +fn rollback(applied: &[&MoveStep], cause: PlatformError) -> PlatformError { + for step in applied.iter().rev() { + if let Err(rollback_err) = move_tree(&step.target, &step.source) { + return PlatformError::RollbackFailed { + reason: format!( + "分区 {} 搬运失败({cause}),且回滚 {} 失败({rollback_err})", + step.partition.as_str(), + step.partition.as_str() + ), + }; + } + } + cause +} + +/// 搬一棵目录树:优先 `rename`(同盘瞬时);跨盘失败回落「复制 + 校验 + 删源」。 +/// +/// 目标已存在则拒绝(绝不合并)。源不存在视为无事可做。 +fn move_tree(source: &Path, target: &Path) -> PlatformResult<()> { + if !source.exists() { + return Ok(()); + } + if target.exists() { + return Err(PlatformError::MigrationRefused { + reason: format!("目标已存在,拒绝覆盖: {}", target.display()), + }); + } + let parent = target.parent().unwrap_or_else(|| Path::new("")); + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(io_at(parent))?; + } + + match std::fs::rename(source, target) { + Ok(()) => Ok(()), + Err(_) => { + // 跨盘(便携盘 → 内置盘)时 rename 会失败,改用复制 + 删源。 + copy_tree(source, target).map_err(|err| { + std::fs::remove_dir_all(target).ok(); + PlatformError::Io { + path: target.to_path_buf(), + source: err, + } + })?; + std::fs::remove_dir_all(source).map_err(io_at(source)) + } + } +} + +/// 递归复制(不跟随符号链接,避免把外部大目录拖进来)。 +fn copy_tree(from: &Path, to: &Path) -> io::Result<()> { + std::fs::create_dir_all(to)?; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + let file_type = entry.file_type()?; + let destination = to.join(entry.file_name()); + if file_type.is_dir() { + copy_tree(&entry.path(), &destination)?; + } else if file_type.is_file() { + std::fs::copy(entry.path(), &destination)?; + } + } + Ok(()) +} + +/// 目录存在且非空。 +fn is_non_empty_dir(path: &Path) -> bool { + if !path.is_dir() { + return false; + } + std::fs::read_dir(path).is_ok_and(|mut entries| entries.next().is_some()) +} diff --git a/crates/core/src/platform/error.rs b/crates/core/src/platform/error.rs new file mode 100644 index 0000000..95e392e --- /dev/null +++ b/crates/core/src/platform/error.rs @@ -0,0 +1,124 @@ +//! platform 层错误类型与结果别名。 +//! +//! 设计出处:`DevDocs/PlanDoc/04-data-and-config.md` §1(三模式 + 迁移收据)、 +//! `DevDocs/analyse/07-settings-packaging-update.md` §1.6(保存通道)、§1.8(遗留迁移收据)。 + +use std::fmt; +use std::io; +use std::path::PathBuf; + +/// platform 层的结果别名。 +pub type PlatformResult = Result; + +/// 数据目录解析、文件系统原语、目录迁移可能出的问题。 +#[derive(Debug)] +pub enum PlatformError { + /// 文件系统操作失败(带出错路径)。 + Io { + /// 出错的文件或目录。 + path: PathBuf, + /// 底层 IO 错误。 + source: io::Error, + }, + /// Portable 模式拿不到可执行文件所在目录。 + NoExecutableDir, + /// Standard 模式拿不到宿主标准数据目录(环境变量缺失)。 + NoStandardDataDir { + /// 平台名(`windows` / `macos` / `linux` / `other`)。 + platform: &'static str, + }, + /// Custom 模式没给目录,或给的不是绝对路径。 + InvalidCustomRoot { + /// 用户给的目录。 + root: PathBuf, + }, + /// 实例 id 非法(只允许 `[A-Za-z0-9_-]`,挡掉 `../` 路径穿越)。 + InvalidInstanceId { + /// 用户/档案里给的 id。 + id: String, + }, + /// `redirect.json` 链超过跳数上限或成环。 + RedirectCycle { + /// 起始目录。 + dir: PathBuf, + /// 实际跳数。 + hops: usize, + }, + /// `redirect.json` 内容损坏。 + RedirectCorrupt { + /// 指针文件路径。 + path: PathBuf, + /// 原因。 + reason: String, + }, + /// 拒绝迁移(目标已有数据、源不存在等),原因写在消息里。 + MigrationRefused { + /// 拒绝原因。 + reason: String, + }, + /// 迁移失败且回滚也失败——需要人工介入,不能当成普通失败吞掉。 + RollbackFailed { + /// 原始失败 + 回滚失败串起来的原因。 + reason: String, + }, + /// 迁移收据损坏。 + ReceiptCorrupt { + /// 收据文件路径。 + path: PathBuf, + /// 原因。 + reason: String, + }, +} + +impl fmt::Display for PlatformError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { path, source } => write!(f, "IO 失败 @ {}: {source}", path.display()), + Self::NoExecutableDir => write!(f, "Portable 模式无法定位可执行文件所在目录"), + Self::NoStandardDataDir { platform } => { + write!( + f, + "Standard 模式在 {platform} 上拿不到标准数据目录(环境变量缺失)" + ) + } + Self::InvalidCustomRoot { root } => { + write!( + f, + "Custom 数据目录不合法(必须是绝对路径): {}", + root.display() + ) + } + Self::InvalidInstanceId { id } => { + write!(f, "实例 id 非法(只允许 [A-Za-z0-9_-]): {id:?}") + } + Self::RedirectCycle { dir, hops } => write!( + f, + "redirect.json 跳转成环或超限({hops} 跳)@ {}", + dir.display() + ), + Self::RedirectCorrupt { path, reason } => { + write!(f, "redirect.json 损坏 @ {}: {reason}", path.display()) + } + Self::MigrationRefused { reason } => write!(f, "拒绝迁移: {reason}"), + Self::RollbackFailed { reason } => write!(f, "迁移回滚失败,需人工介入: {reason}"), + Self::ReceiptCorrupt { path, reason } => { + write!(f, "迁移收据损坏 @ {}: {reason}", path.display()) + } + } + } +} + +impl std::error::Error for PlatformError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io { source, .. } => Some(source), + _ => None, + } + } +} + +/// 把 `io::Error` 绑上路径,给 `map_err` 用。 +pub(crate) fn io_at>(path: P) -> impl FnOnce(io::Error) -> PlatformError { + let path = path.into(); + move |source| PlatformError::Io { path, source } +} diff --git a/crates/core/src/platform/hash.rs b/crates/core/src/platform/hash.rs new file mode 100644 index 0000000..9b31944 --- /dev/null +++ b/crates/core/src/platform/hash.rs @@ -0,0 +1,95 @@ +//! 迁移收据的源文件指纹(SHA-256),委托给 `sha2`。 +//! +//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.8——收据里存源文件 `sourceSHA256`, +//! 这样「删掉新文件但旧文件没变」时不会重复迁移,旧文件变了才重放。 +//! +//! 阶段A 在「不许动 Cargo.toml」的约束下自带过一版 FIPS 180-4 实现;阶段B 按 tower 批复 +//! 改用 `sha2`(安全哈希不自维护),**公开签名不变**,单测从「自带实现的标准向量」 +//! 变为「wrapper 与 `sha2` 一致 + 标准向量兜底」。 + +use std::io; +use std::path::Path; + +use sha2::{Digest, Sha256}; + +/// 计算 32 字节摘要。 +#[must_use] +pub fn sha256(data: &[u8]) -> [u8; 32] { + let digest = Sha256::digest(data); + let mut out = [0u8; 32]; + out.copy_from_slice(digest.as_slice()); + out +} + +/// 计算小写十六进制摘要。 +#[must_use] +pub fn sha256_hex(data: &[u8]) -> String { + to_hex(&sha256(data)) +} + +/// 读文件算十六进制摘要(配置文件很小,整读即可)。 +/// +/// # Errors +/// 文件读失败时返回原始 `io::Error`。 +pub fn sha256_hex_file(path: &Path) -> io::Result { + let bytes = std::fs::read(path)?; + Ok(sha256_hex(&bytes)) +} + +fn to_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(char::from(HEX[usize::from(byte >> 4)])); + out.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + out +} + +#[cfg(test)] +mod tests { + use sha2::Digest; + + use super::{sha256, sha256_hex, sha256_hex_file}; + + #[test] + fn matches_fips_vectors() { + assert_eq!( + sha256_hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + sha256_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"), + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" + ); + } + + #[test] + fn agrees_with_sha2_at_block_boundaries() { + // 55 / 56 / 63 / 64 / 65 是补齐逻辑最容易出错的长度,wrapper 必须与 sha2 完全一致。 + for len in [55usize, 56, 63, 64, 65, 128, 1000] { + let data = vec![b'a'; len]; + let expected = sha2::Sha256::digest(&data); + assert_eq!(&sha256(&data)[..], expected.as_slice(), "length {len}"); + assert_eq!(sha256_hex(&data).len(), 64); + } + } + + #[test] + fn hashes_file_contents() { + let dir = std::env::temp_dir().join(format!("ull-hash-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let path = dir.join("a.txt"); + std::fs::write(&path, b"abc").expect("write"); + let hex = sha256_hex_file(&path).expect("hash file"); + std::fs::remove_dir_all(&dir).ok(); + assert_eq!( + hex, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } +} diff --git a/crates/core/src/platform/layout.rs b/crates/core/src/platform/layout.rs new file mode 100644 index 0000000..146a011 --- /dev/null +++ b/crates/core/src/platform/layout.rs @@ -0,0 +1,223 @@ +//! `.ULL/` 四分区布局——所有模块取路径的唯一入口,禁止各自手拼路径。 +//! +//! 布局依据 `DevDocs/PlanDoc/04-data-and-config.md` §2(承 HMCL 四分区): +//! `config/`(schema 化 JSON)、`data/`(可重建但优先保留)、`cache/`(随时可删)、`logs/`。 + +use std::path::{Path, PathBuf}; + +use super::error::{io_at, PlatformError, PlatformResult}; + +/// Portable 模式下启动器数据目录名(exe 同目录)。 +pub const LAUNCHER_HOME_DIR: &str = ".ULL"; +/// 游戏数据目录名。 +pub const GAME_HOME_DIR: &str = ".minecraft"; +/// 分区:配置。 +pub const CONFIG_DIR: &str = "config"; +/// 分区:状态数据。 +pub const DATA_DIR: &str = "data"; +/// 分区:纯缓存。 +pub const CACHE_DIR: &str = "cache"; +/// 分区:启动器日志。 +pub const LOGS_DIR: &str = "logs"; + +/// 全局设置文件名。 +pub const LAUNCHER_CONFIG_FILE: &str = "launcher.json"; +/// 实例档案列表文件名。 +pub const PROFILES_CONFIG_FILE: &str = "profiles.json"; +/// 每实例设置所在子目录。 +pub const INSTANCES_DIR: &str = "instances"; +/// 迁移后留在旧目录的指针文件名。 +pub const REDIRECT_FILE: &str = "redirect.json"; +/// 目录迁移收据文件名(写在目标 `config/`)。 +pub const MIGRATION_RECEIPT_FILE: &str = "migration-receipt.json"; + +/// `.ULL/` 下的四个分区。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Partition { + /// `config/`:配置(迁移时优先搬)。 + Config, + /// `data/`:状态数据(不可丢)。 + Data, + /// `cache/`:纯缓存(可丢,迁移可选)。 + Cache, + /// `logs/`:启动器日志。 + Logs, +} + +impl Partition { + /// 全部四个分区。 + pub const ALL: [Self; 4] = [Self::Config, Self::Data, Self::Cache, Self::Logs]; + + /// 分区目录名。 + #[must_use] + pub const fn dir_name(&self) -> &'static str { + match self { + Self::Config => CONFIG_DIR, + Self::Data => DATA_DIR, + Self::Cache => CACHE_DIR, + Self::Logs => LOGS_DIR, + } + } + + /// 稳定名(日志 / 收据用)。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + self.dir_name() + } + + /// 是否为可丢弃分区(迁移时可跳过)。 + #[must_use] + pub const fn is_disposable(&self) -> bool { + matches!(self, Self::Cache) + } +} + +/// 一个 launcher home(`.ULL` 的等价物)的路径视图。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LauncherLayout { + home: PathBuf, +} + +impl LauncherLayout { + /// 绑定到某个 home 目录(不创建任何东西,创建用 [`Self::ensure`])。 + #[must_use] + pub fn new(home: impl Into) -> Self { + Self { home: home.into() } + } + + /// home 根目录。 + #[must_use] + pub fn home(&self) -> &Path { + &self.home + } + + /// 任一分区目录。 + #[must_use] + pub fn partition(&self, partition: Partition) -> PathBuf { + self.home.join(partition.dir_name()) + } + + /// `config/`。 + #[must_use] + pub fn config(&self) -> PathBuf { + self.partition(Partition::Config) + } + + /// `data/`。 + #[must_use] + pub fn data(&self) -> PathBuf { + self.partition(Partition::Data) + } + + /// `cache/`。 + #[must_use] + pub fn cache(&self) -> PathBuf { + self.partition(Partition::Cache) + } + + /// `logs/`。 + #[must_use] + pub fn logs(&self) -> PathBuf { + self.partition(Partition::Logs) + } + + /// `config/`。 + #[must_use] + pub fn config_file(&self, name: &str) -> PathBuf { + self.config().join(name) + } + + /// `config/launcher.json`。 + #[must_use] + pub fn launcher_config(&self) -> PathBuf { + self.config_file(LAUNCHER_CONFIG_FILE) + } + + /// `config/profiles.json`。 + #[must_use] + pub fn profiles_config(&self) -> PathBuf { + self.config_file(PROFILES_CONFIG_FILE) + } + + /// `config/instances/.json`。 + /// + /// # Errors + /// 实例 id 只允许 `[A-Za-z0-9_-]`(正常是稳定 UUID),否则返回 + /// [`super::error::PlatformError::InvalidInstanceId`],挡掉 `../` 穿越。 + pub fn instance_config(&self, id: &str) -> PlatformResult { + if !is_valid_instance_id(id) { + return Err(PlatformError::InvalidInstanceId { id: id.to_owned() }); + } + Ok(self.config().join(INSTANCES_DIR).join(format!("{id}.json"))) + } + + /// `cache/sha1/

`——内容寻址缓存的两级分片(避开 NTFS 单目录塌陷)。 + #[must_use] + pub fn sha1_cache(&self, shard: &str) -> PathBuf { + self.cache().join("sha1").join(shard) + } + + /// `cache/etag.json`——共享 ETag 表。 + #[must_use] + pub fn etag_cache(&self) -> PathBuf { + self.cache().join("etag.json") + } + + /// `cache/meta/`——列表 API 响应缓存(带 TTL)。 + #[must_use] + pub fn meta_cache(&self) -> PathBuf { + self.cache().join("meta") + } + + /// `data/java-managed/`——自动下载的 JDK。 + #[must_use] + pub fn java_managed(&self) -> PathBuf { + self.data().join("java-managed") + } + + /// `data/auth/`——账户元数据与凭据。 + #[must_use] + pub fn auth_data(&self) -> PathBuf { + self.data().join("auth") + } + + /// `data/agent/sessions/`——AI 会话 jsonl 与审计日志。 + #[must_use] + pub fn agent_sessions(&self) -> PathBuf { + self.data().join("agent").join("sessions") + } + + /// `/redirect.json`——「我已迁走」指针。 + #[must_use] + pub fn redirect_path(&self) -> PathBuf { + self.home.join(REDIRECT_FILE) + } + + /// `config/migration-receipt.json`——迁移收据。 + #[must_use] + pub fn migration_receipt(&self) -> PathBuf { + self.config_file(MIGRATION_RECEIPT_FILE) + } + + /// 一次性建齐四分区。 + /// + /// # Errors + /// 目录创建失败时返回带路径的 [`super::error::PlatformError::Io`]。 + pub fn ensure(&self) -> PlatformResult<()> { + for partition in Partition::ALL { + let dir = self.partition(partition); + std::fs::create_dir_all(&dir).map_err(io_at(&dir))?; + } + Ok(()) + } +} + +/// 实例 id 白名单:字母数字与 `_`/`-`。 +#[must_use] +pub fn is_valid_instance_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 64 + && id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') +} diff --git a/crates/core/src/platform/linux_caps.rs b/crates/core/src/platform/linux_caps.rs new file mode 100644 index 0000000..3397e9d --- /dev/null +++ b/crates/core/src/platform/linux_caps.rs @@ -0,0 +1,225 @@ +//! Linux 探测:**能力**而非版本号(D19 · `PlanDoc/11-os-detection.md` §2)。 +//! +//! 发行版版本对能不能跑没有意义,所以这里分三类事实: +//! - `/etc/os-release` 的 `PRETTY_NAME` —— **仅供显示/日志**; +//! - 内核 release(`/proc/sys/kernel/osrelease`,退化 `uname -r`)—— 喂 MC 的 +//! `rules.os.version`,同时给出最低内核判定用的 `major.minor`; +//! - 能力探测:图形会话类型(Wayland / X11 / 无显示)与 GL 能力钩子(见 [`probe_gl`])。 +//! +//! glibc 版本用 `ldd --version` 探测(musl / Termux 上取不到,此时不阻塞)。 +//! fontconfig 可用性与 `xdg-desktop-portal` 同理留到 app 层(字体回退、主题跟随、文件选择器), +//! core 只报环境事实,不做渲染后端决策。 +//! 解析函数是纯函数且跨平台编译,单测用注入样本覆盖(见 `tests/platform/`)。 + +/// 图形会话类型(`Unknown` 是派生默认值:未探测)。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum SessionKind { + /// Wayland 会话(`WAYLAND_DISPLAY` 或 `XDG_SESSION_TYPE=wayland`)。 + Wayland, + /// X11 会话(`DISPLAY` 或 `XDG_SESSION_TYPE=x11`)。 + X11, + /// 没有图形会话(服务器 / 容器 / SSH)。GUI 用不了,CLI 照常。 + Headless, + /// 未探测(非 Linux 平台,或探测未执行)。 + #[default] + Unknown, +} + +impl SessionKind { + /// 简短名(日志用)。 + pub fn as_str(self) -> &'static str { + match self { + Self::Wayland => "wayland", + Self::X11 => "x11", + Self::Headless => "headless", + Self::Unknown => "unknown", + } + } + + /// 是否存在可用的显示服务器。 + pub fn has_display(self) -> bool { + matches!(self, Self::Wayland | Self::X11) + } +} + +/// GL/EGL 能力探测结果。 +/// +/// P1 只留钩子:真探测要 `dlopen("libEGL.so")` + `eglQueryString`,那是 FFI, +/// 而 core 是 `#![forbid(unsafe_code)]`;因此真实探测放在 app 层「渲染后端选择」里做, +/// core 只负责把 `Unknown` 如实报出来,不阻塞任何流程。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum GlProbe { + /// 未探测(P1 常态)。 + #[default] + Unknown, + /// 有可用的 GL/EGL。 + Available, + /// 没有可用 GL(调用方应降级到软渲染)。 + Missing, +} + +impl GlProbe { + /// 简短名(日志用)。 + pub fn as_str(self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::Available => "available", + Self::Missing => "missing", + } + } +} + +/// Linux 探测结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinuxCaps { + /// 发行版展示名(`PRETTY_NAME`,仅显示)。 + pub distro: Option, + /// 内核 release 原始串(`6.8.0-45-generic`),喂 `rules.os.version`。 + pub kernel: Option, + /// 内核 `major.minor`(最低内核判定用)。 + pub kernel_version: Option<(u32, u32)>, + /// glibc `major.minor`;musl / 取不到时为 `None`。 + pub glibc: Option<(u32, u32)>, + /// 图形会话类型。 + pub session: SessionKind, + /// GL 能力(P1 恒为 [`GlProbe::Unknown`])。 + pub gl: GlProbe, +} + +/// 探测当前 Linux 环境;非 Linux 平台返回全空的默认值。 +#[cfg(target_os = "linux")] +pub fn probe() -> LinuxCaps { + let distro = std::fs::read_to_string("/etc/os-release") + .ok() + .and_then(|text| parse_os_release(&text)); + let kernel = std::fs::read_to_string("/proc/sys/kernel/osrelease") + .ok() + .map(|text| text.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(|| run("uname", &["-r"])); + let glibc = run("ldd", &["--version"]).and_then(|text| parse_glibc_version(&text)); + LinuxCaps { + distro, + kernel_version: kernel.as_deref().and_then(parse_kernel_version), + kernel, + glibc, + session: current_session(), + gl: probe_gl(), + } +} + +/// 探测当前 Linux 环境;非 Linux 平台返回全空的默认值。 +#[cfg(not(target_os = "linux"))] +pub fn probe() -> LinuxCaps { + LinuxCaps { + distro: None, + kernel: None, + kernel_version: None, + glibc: None, + session: SessionKind::Unknown, + gl: GlProbe::Unknown, + } +} + +/// GL/EGL 探测钩子(P1 返回 [`GlProbe::Unknown`],见类型文档)。 +pub fn probe_gl() -> GlProbe { + GlProbe::Unknown +} + +/// 按当前环境变量判定会话类型。 +pub fn current_session() -> SessionKind { + session_from_env(|key| std::env::var(key).ok()) +} + +/// 按注入的环境变量读取函数判定会话类型(便于单测)。 +/// +/// 判定顺序:`WAYLAND_DISPLAY` → `XDG_SESSION_TYPE` → `DISPLAY` → 无显示。 +pub fn session_from_env(get: impl Fn(&str) -> Option) -> SessionKind { + let has = |key: &str| get(key).map(|v| !v.trim().is_empty()).unwrap_or(false); + if has("WAYLAND_DISPLAY") { + return SessionKind::Wayland; + } + match get("XDG_SESSION_TYPE").as_deref().map(str::trim) { + Some(value) if value.eq_ignore_ascii_case("wayland") => return SessionKind::Wayland, + Some(value) if value.eq_ignore_ascii_case("x11") => return SessionKind::X11, + _ => {} + } + if has("DISPLAY") { + return SessionKind::X11; + } + SessionKind::Headless +} + +/// 从 `/etc/os-release` 取 `PRETTY_NAME`(去引号)。 +pub fn parse_os_release(text: &str) -> Option { + let mut fallback = None; + for line in text.lines() { + let line = line.trim(); + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = value.trim().trim_matches('"').trim_matches('\'').trim(); + if value.is_empty() { + continue; + } + match key.trim() { + "PRETTY_NAME" => return Some(value.to_string()), + // 没有 PRETTY_NAME 时用 NAME + VERSION_ID 拼一个(某些裁剪发行版只给这两个)。 + "NAME" => { + let name = value.to_string(); + fallback = Some(match fallback.take() { + Some(prev) => format!("{prev} {name}"), + None => name, + }); + } + "VERSION_ID" => { + let version = value.to_string(); + fallback = Some(match fallback.take() { + Some(prev) => format!("{prev} {version}"), + None => version, + }); + } + _ => {} + } + } + fallback +} + +/// 从内核 release 串取 `major.minor`(`6.8.0-45-generic` → `(6, 8)`)。 +pub fn parse_kernel_version(release: &str) -> Option<(u32, u32)> { + let trimmed = release.trim(); + let mut parts = trimmed.split(|c: char| !c.is_ascii_digit()); + let major: u32 = parts.next()?.parse().ok()?; + let minor: u32 = parts.next().unwrap_or("0").parse().unwrap_or(0); + Some((major, minor)) +} + +/// 从 `ldd --version` 输出取 glibc `major.minor` +/// (`ldd (Ubuntu GLIBC 2.39-0ubuntu8.3) 2.39` → `(2, 39)`)。 +pub fn parse_glibc_version(text: &str) -> Option<(u32, u32)> { + // 只看第一行:后面是版权声明,可能含有别的数字。 + let first = text.lines().next().unwrap_or_default(); + first.split_whitespace().rev().find_map(|token| { + let (major, minor) = token.split_once('.')?; + let minor = minor + .split(|c: char| !c.is_ascii_digit()) + .next() + .unwrap_or_default(); + Some((major.parse().ok()?, minor.parse().ok()?)) + }) +} + +/// `(2, 31)` → `"2.31"`(展示用)。 +pub fn format_version(version: (u32, u32)) -> String { + format!("{}.{}", version.0, version.1) +} + +#[cfg(target_os = "linux")] +fn run(program: &str, args: &[&str]) -> Option { + use std::process::Command; + let output = Command::new(program).args(args).output().ok()?; + if output.stdout.is_empty() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).into_owned()) +} diff --git a/crates/core/src/platform/macos_version.rs b/crates/core/src/platform/macos_version.rs new file mode 100644 index 0000000..bc18447 --- /dev/null +++ b/crates/core/src/platform/macos_version.rs @@ -0,0 +1,100 @@ +//! macOS 探测:产品版本 + 运行架构(D19 · `PlanDoc/11-os-detection.md` §2)。 +//! +//! - 产品版本:`sw_vers -productVersion` → `12.3.1`(最低部署目标判定 + MC `os.version` 规则); +//! - 运行架构:`sysctl -n hw.optional.arm64` 输出 `1` 表示**当前运行在 Apple Silicon 上** +//! (关键:Rosetta 2 下 `cfg!(target_arch)` 仍是 `x86_64`,但原生渲染路径与 MC 原生库 +//! 要的是真实架构,所以必须运行期探); +//! - 内核版本:`uname -r`(`22.4.0`)只进日志,不参与判定。 +//! +//! 解析函数是纯函数且跨平台编译,单测用注入样本覆盖(见 `tests/platform/`)。 + +/// macOS 版本 + 运行架构。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MacOsVersion { + /// 主版本号(macOS 12 Monterey = 12)。 + pub major: u32, + /// 次版本号。 + pub minor: u32, + /// 补丁号。 + pub patch: u32, + /// 是否运行在 Apple Silicon(含 Rosetta 2 场景)。 + pub arm64: bool, +} + +impl MacOsVersion { + /// 构造。 + pub fn new(major: u32, minor: u32, patch: u32, arm64: bool) -> Self { + Self { + major, + minor, + patch, + arm64, + } + } + + /// 产品版本串(`rules.os.version` 用,例如 `12.3.1`)。 + pub fn version_string(&self) -> String { + format!("{}.{}.{}", self.major, self.minor, self.patch) + } + + /// 展示名(`macOS 12.3.1 (arm64)`)。 + pub fn display_name(&self) -> String { + let arch = if self.arm64 { "arm64" } else { "x86_64" }; + format!("macOS {} ({arch})", self.version_string()) + } + + /// `rules.os.arch` 取值:Apple Silicon 上必须是 `arm64`。 + pub fn runtime_arch(&self) -> &'static str { + if self.arm64 { + "arm64" + } else { + "x86_64" + } + } +} + +/// 真实探测;非 macOS 平台恒为 `None`。 +#[cfg(target_os = "macos")] +pub fn probe() -> Option { + let (major, minor, patch) = + run("sw_vers", &["-productVersion"]).and_then(|text| parse_product_version(&text))?; + // sysctl 在 Intel 机器上以非零码退出(键不存在)→ stdout 为空 → false。 + let arm64 = run("sysctl", &["-n", "hw.optional.arm64"]) + .map(|text| parse_sysctl_bool(&text)) + .unwrap_or(false); + Some(MacOsVersion::new(major, minor, patch, arm64)) +} + +/// 真实探测;非 macOS 平台恒为 `None`。 +#[cfg(not(target_os = "macos"))] +pub fn probe() -> Option { + None +} + +/// 解析 `12.3.1` / `12.3` / `12`(`sw_vers -productVersion` 的输出)。 +pub fn parse_product_version(text: &str) -> Option<(u32, u32, u32)> { + let trimmed = text.trim(); + let token = trimmed + .split_whitespace() + .find(|token| token.starts_with(|c: char| c.is_ascii_digit()))?; + let mut parts = token.split('.').map(str::parse::); + let major = parts.next()?.ok()?; + let minor = parts.next().map(|value| value.unwrap_or(0)).unwrap_or(0); + let patch = parts.next().map(|value| value.unwrap_or(0)).unwrap_or(0); + Some((major, minor, patch)) +} + +/// 解析 `sysctl -n hw.optional.arm64` 的输出(`1` 为真)。 +pub fn parse_sysctl_bool(text: &str) -> bool { + text.trim() == "1" +} + +#[cfg(target_os = "macos")] +fn run(program: &str, args: &[&str]) -> Option { + use std::process::Command; + let output = Command::new(program).args(args).output().ok()?; + if !output.status.success() || output.stdout.is_empty() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).into_owned()) +} diff --git a/crates/core/src/platform/mod.rs b/crates/core/src/platform/mod.rs new file mode 100644 index 0000000..d8ee16f --- /dev/null +++ b/crates/core/src/platform/mod.rs @@ -0,0 +1,42 @@ +//! OS 抽象层:路径规则、平台目录差异、进程与文件锁、软渲染/显示能力探测辅助。 +//! +//! 设计定稿:`DevDocs/PlanDoc/04-data-and-config.md` §1–§2(三模式路径与 `.ULL` 四分区)。 +//! Win7 单独适配(决策 D11)在此收敛平台差异判断,避免散落各模块打补丁。 +//! +//! 公共面(M12 已落地): +//! - [`paths`]:宿主平台族与标准目录探测(环境变量可注入),可搬运路径字符串; +//! - [`layout`]:`.ULL/` 四分区(config/data/cache/logs)与各已知路径的唯一入口; +//! - [`data_dir`]:三模式数据目录解析(Portable 默认 / Standard / Custom + 高级覆盖); +//! - [`redirect`]:迁移后留在旧目录的 `redirect.json` 指针(防便携盘旧目录被再次初始化); +//! - [`receipt`]:迁移收据(源指纹防重放); +//! - [`dir_migration`]:数据目录迁移(分区逐个搬 + 失败逆序回滚); +//! - [`atomic`]:原子写(tmp → fsync → rename);[`hash`]:迁移收据用的 SHA-256。 +//! +//! 运行期系统探测(M20 / D19 · `11-os-detection.md`): +//! - [`os_info`]:`OsInfo` 快照 + 最低支持判定(One / Unsupported,基线 **Windows 10+ / +//! macOS 12+ / Linux 达标**)+ 启动自检; +//! - [`windows_version`]:真实 `major.minor.build`(注册表 `CurrentBuildNumber` 快路径 → +//! 整键 `CurrentVersion` 家族 → `cmd /c ver` 兜底); +//! - [`macos_version`]:`sw_vers` 产品版本 + `sysctl hw.optional.arm64` 运行架构; +//! - [`linux_caps`]:发行版(仅显示)+ 内核 release(喂 `os.version`)+ 会话/GL 能力探测; +//! - [`vk_probe`]:Vulkan 能力的安全层探测(loader 文件 / ICD 注册 / 环境变量,无 `unsafe`); +//! - [`theme_probe`]:系统主题(浅色/深色)探测,供主题三态的 `auto` 初始化对齐与兜底跟随。 +//! +//! 尚未落地(后续 mission):文件锁、进程工具、真实 GL/Vulkan 能力探测(钩子见 +//! [`linux_caps::probe_gl`] 与 [`vk_probe::probe_vk`],FFI 受限,实际探测归 app 层渲染后端选择)。 + +pub mod atomic; +pub mod data_dir; +pub mod dir_migration; +pub mod error; +pub mod hash; +pub mod layout; +pub mod linux_caps; +pub mod macos_version; +pub mod os_info; +pub mod paths; +pub mod receipt; +pub mod redirect; +pub mod theme_probe; +pub mod vk_probe; +pub mod windows_version; diff --git a/crates/core/src/platform/os_info.rs b/crates/core/src/platform/os_info.rs new file mode 100644 index 0000000..70a9eb9 --- /dev/null +++ b/crates/core/src/platform/os_info.rs @@ -0,0 +1,388 @@ +//! 运行期系统探测与最低支持判定(D19 · `DevDocs/PlanDoc/11-os-detection.md`)。 +//! +//! 事实通道按平台分流(各文件里的 `probe()`):[`windows_version`] 注册表 `CurrentBuildNumber` +//! 快路径 → 整键 `CurrentVersion` 家族 → `cmd /c ver` 兜底;[`macos_version`] `sw_vers` + +//! `sysctl`;[`linux_caps`] `/etc/os-release`(仅显示)+ 内核 release + 会话/GL 能力探测。 +//! 不用 `RtlGetVersion` FFI 是因为 core 是 `#![forbid(unsafe_code)]`,而这些路径读的是同一份 +//! 真实事实,代价只是一次性几十毫秒。 +//! +//! 支持基线(用户拍板):**Windows 10 起、macOS 12 起、Linux(内核/glibc 达标)**。 +//! 因此等级只有两档:[`SupportLevel::One`] 与 [`SupportLevel::Unsupported`]——Win8.1/8/7 +//! 不承诺 GUI,也不承诺 CLI(识别出旧版本正是为了报「不支持」)。 +//! +//! 入口:[`detect`](真探)· [`current`](进程内缓存)· [`check`](CLI 启动自检); +//! 等级映射是纯函数([`support_level_for`] 及三平台分解),单测用注入式假数据覆盖。 + +use std::sync::OnceLock; + +use crate::platform::linux_caps::{self, GlProbe, SessionKind}; +use crate::platform::macos_version; +use crate::platform::vk_probe::{self, VkProbe}; +use crate::platform::windows_version; + +/// 最低支持的 Windows 版本(10.0 = Windows 10,build 10240 起)。 +pub const MIN_WINDOWS: (u32, u32) = (10, 0); +/// 最低支持的 macOS 主版本(Slint / winit 的现实部署下限,见 D19 §4)。 +pub const MIN_MACOS_MAJOR: u32 = 12; +/// 最低 Linux 内核(Rust std 的 Linux 下限)。 +pub const MIN_LINUX_KERNEL: (u32, u32) = (3, 2); +/// 最低 glibc(`x86_64-unknown-linux-gnu` 构建产物能跑的下限)。 +pub const MIN_GLIBC: (u32, u32) = (2, 17); + +/// 操作系统家族。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OsFamily { + /// Windows。 + Windows, + /// macOS。 + MacOs, + /// Linux 及其它 POSIX 系统。 + Linux, +} + +impl OsFamily { + /// 当前编译目标的系统家族。 + pub fn current() -> Self { + if cfg!(target_os = "windows") { + Self::Windows + } else if cfg!(target_os = "macos") { + Self::MacOs + } else { + Self::Linux + } + } + + /// Mojang 清单里 `rules.os.name` 的取值。 + pub fn manifest_name(self) -> &'static str { + match self { + Self::Windows => "windows", + Self::MacOs => "osx", + Self::Linux => "linux", + } + } + + /// `rules.os.arch` 的取值(按编译目标;Apple Silicon 的真实架构另见 + /// [`MacOsVersion::runtime_arch`](crate::platform::macos_version::MacOsVersion::runtime_arch))。 + pub fn manifest_arch(self) -> &'static str { + if cfg!(target_arch = "x86_64") { + "x86_64" + } else if cfg!(target_arch = "x86") { + "x86" + } else if cfg!(target_arch = "aarch64") { + "arm64" + } else { + "unknown" + } + } + + /// 是否为 POSIX 系统(决定 `nice`、进程组 kill 等分支)。 + pub fn is_posix(self) -> bool { + !matches!(self, Self::Windows) + } +} + +/// 支持等级(两档:满足基线 / 不满足)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SupportLevel { + /// 一等:基线以上(Windows 10+ / macOS 12+ / Linux 达标),GUI + CLI。 + One, + /// 不支持:低于基线,启动时拒绝并给升级指引。 + Unsupported, +} + +impl SupportLevel { + /// 简短名(日志/事件用)。 + pub fn as_str(self) -> &'static str { + match self { + Self::One => "one", + Self::Unsupported => "unsupported", + } + } + + /// 是否允许运行。 + pub fn is_usable(self) -> bool { + matches!(self, Self::One) + } +} + +/// 运行期系统信息(不可变快照,可跨线程共享;字段全是探测出来的事实)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OsInfo { + /// 系统家族。 + pub family: OsFamily, + /// `rules.os.arch` 用的架构名。 + pub arch: String, + /// 版本 `major.minor`(探测失败为 `None`)。 + pub major_minor: Option<(u32, u32)>, + /// 构建号(Windows build,其它平台 `None`)。 + pub build: Option, + /// 展示名(`Windows 11` / `macOS 12.3.1 (arm64)` / `Ubuntu 24.04.1 LTS`)。 + pub product: String, + /// 版本串(`rules.os.version` 用;Linux 上是内核 release)。 + pub version_string: String, + /// 图形会话类型(Linux 有意义,其它平台 `Unknown`)。 + pub session: SessionKind, + /// GL 能力(P1 恒为 `Unknown`,见 [`GlProbe`])。 + pub gl: GlProbe, + /// Vulkan 能力(文件/注册表层,无 `unsafe`;真探测归 app 层,见 [`VkProbe`])。 + pub vk: VkProbe, + /// Linux 内核 `major.minor`。 + pub kernel: Option<(u32, u32)>, + /// glibc `major.minor`(musl / 取不到为 `None`)。 + pub glibc: Option<(u32, u32)>, + /// 支持等级(由 [`support_level_for`] 从上面的事实算出)。 + pub support_level: SupportLevel, +} + +impl OsInfo { + /// `rules.os.version` 的取值(探测失败时为空串 —— 规则求值会走保守分支)。 + pub fn os_version(&self) -> &str { + &self.version_string + } + + /// 一行日志摘要(`§5.3` 每次启动写日志 / 诊断包环境快照)。 + pub fn summary_line(&self) -> String { + let version = if self.version_string.is_empty() { + "unknown" + } else { + self.version_string.as_str() + }; + format!( + "os={} version={version} arch={} level={} product=\"{}\" session={} gl={} vk={}", + self.family.manifest_name(), + self.arch, + self.support_level.as_str(), + self.product, + self.session.as_str(), + self.gl.as_str(), + self.vk.as_str(), + ) + } + + /// 用户可读文案(中英双语)。 + pub fn describe(&self) -> SupportText { + match self.support_level { + SupportLevel::One => self.text_supported(), + SupportLevel::Unsupported => self.text_unsupported(), + } + } + + fn text_supported(&self) -> SupportText { + let mut zh = format!("系统满足最低要求:{}({})。", self.product, self.arch); + let mut en = format!("Meets minimum requirements: {}.", self.product); + if self.family == OsFamily::Linux && !self.session.has_display() { + zh.push_str("\n注意:未检测到 Wayland/X11 图形会话,GUI 需要图形会话;CLI 不受影响。"); + en.push_str( + "\nNote: no Wayland/X11 graphical session; the GUI needs one. CLI is fine.", + ); + } + SupportText { zh, en } + } + + fn text_unsupported(&self) -> SupportText { + let product = if self.product.is_empty() { + "未知系统" + } else { + self.product.as_str() + }; + let (need_zh, need_en) = match self.family { + OsFamily::Windows => ( + " Windows 10 或更高版本".to_string(), + "Windows 10 or newer".to_string(), + ), + OsFamily::MacOs => ( + format!(" macOS {MIN_MACOS_MAJOR} 或更高版本"), + format!("macOS {MIN_MACOS_MAJOR} or newer"), + ), + OsFamily::Linux => { + let kernel = format!("{}.{}", MIN_LINUX_KERNEL.0, MIN_LINUX_KERNEL.1); + let glibc = MIN_GLIBC.0; + ( + format!(" Linux 内核 ≥ {kernel}(glibc ≥ {glibc})"), + format!("Linux kernel >= {kernel} (glibc >= {glibc})"), + ) + } + }; + // Windows 上版本探测失败时要说清楚是「探不到」而不是「探到旧版本」。 + let hint = if self.family == OsFamily::Windows && self.major_minor.is_none() { + "(未能探测到系统版本)" + } else { + "" + }; + SupportText { + zh: format!("不支持的系统:{product}{hint}。本启动器要求{need_zh}。"), + en: format!("Unsupported system: {product}. This launcher requires {need_en}."), + } + } +} + +/// 双语用户文案。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SupportText { + /// 中文文案。 + pub zh: String, + /// 英文文案。 + pub en: String, +} + +impl SupportText { + /// 中文 + 换行 + 英文(CLI 直接打印的形态)。 + pub fn combined(&self) -> String { + format!("{}\n{}", self.zh, self.en) + } +} + +/// 启动自检结果(CLI 打印 + 写日志用)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SupportCheck { + /// 支持等级。 + pub level: SupportLevel, + /// 用户可读文案(中英双语,多行)。 + pub message: String, + /// 探测到的系统信息(写日志 / 诊断包用)。 + pub os: OsInfo, +} + +impl SupportCheck { + /// 是否允许继续启动。 + pub fn is_usable(&self) -> bool { + self.level.is_usable() + } +} + +/// 按家族选对应的等级判定(探测与单测走同一条路径)。 +pub fn support_level_for( + family: OsFamily, + major_minor: Option<(u32, u32)>, + kernel: Option<(u32, u32)>, + glibc: Option<(u32, u32)>, +) -> SupportLevel { + match family { + OsFamily::Windows => windows_level(major_minor), + OsFamily::MacOs => macos_level(major_minor.map(|(major, _)| major)), + OsFamily::Linux => linux_level(kernel, glibc), + } +} + +/// Windows 等级:≥ Windows 10 → 一等;Win8.1/8/7 及更旧 → 不支持;探测不到 → 不支持(保守)。 +pub fn windows_level(major_minor: Option<(u32, u32)>) -> SupportLevel { + match major_minor { + Some(version) if version >= MIN_WINDOWS => SupportLevel::One, + _ => SupportLevel::Unsupported, + } +} + +/// macOS 等级:≥ 12 → 一等;更低或不认识 → 不支持。 +pub fn macos_level(major: Option) -> SupportLevel { + match major { + Some(major) if major >= MIN_MACOS_MAJOR => SupportLevel::One, + _ => SupportLevel::Unsupported, + } +} + +/// Linux 等级:内核/glibc 探得到但过低 → 不支持;取不到(容器、musl、Termux)不阻塞 → 一等。 +pub fn linux_level(kernel: Option<(u32, u32)>, glibc: Option<(u32, u32)>) -> SupportLevel { + if kernel.is_some_and(|version| version < MIN_LINUX_KERNEL) { + return SupportLevel::Unsupported; + } + if glibc.is_some_and(|version| version < MIN_GLIBC) { + return SupportLevel::Unsupported; + } + SupportLevel::One +} + +/// 真探一次当前系统(会起子进程,别在热路径反复调用;常规路径用 [`current`])。 +pub fn detect() -> OsInfo { + let family = OsFamily::current(); + let facts = probe(family); + let arch = if facts.arm64 { + "arm64".to_string() + } else { + family.manifest_arch().to_string() + }; + let product = facts + .product + .unwrap_or_else(|| family.manifest_name().to_string()); + OsInfo { + family, + arch, + major_minor: facts.major_minor, + build: facts.build, + product, + version_string: facts.version_string.unwrap_or_default(), + session: facts.session, + gl: facts.gl, + vk: vk_probe::probe_vk(), + kernel: facts.kernel, + glibc: facts.glibc, + support_level: support_level_for(family, facts.major_minor, facts.kernel, facts.glibc), + } +} + +/// 三平台探测结果统一成同一形状(内部用;`None` 表示该平台没有这项事实)。 +#[derive(Default)] +struct Facts { + major_minor: Option<(u32, u32)>, + build: Option, + product: Option, + version_string: Option, + kernel: Option<(u32, u32)>, + glibc: Option<(u32, u32)>, + session: SessionKind, + gl: GlProbe, + arm64: bool, +} + +fn probe(family: OsFamily) -> Facts { + match family { + OsFamily::Windows => { + let version = windows_version::probe(); + Facts { + major_minor: version.map(|v| v.major_minor()), + build: version.map(|v| v.build), + product: version.map(|v| v.display_name().to_string()), + version_string: version.map(|v| v.version_string()), + ..Facts::default() + } + } + OsFamily::MacOs => { + let version = macos_version::probe(); + Facts { + major_minor: version.map(|v| (v.major, v.minor)), + product: version.map(|v| v.display_name()), + version_string: version.map(|v| v.version_string()), + arm64: version.is_some_and(|v| v.arm64), + ..Facts::default() + } + } + OsFamily::Linux => { + let caps = linux_caps::probe(); + Facts { + major_minor: caps.kernel_version, + product: caps.distro, + version_string: caps.kernel, + kernel: caps.kernel_version, + glibc: caps.glibc, + session: caps.session, + gl: caps.gl, + ..Facts::default() + } + } + } +} + +/// 进程内缓存的一次探测结果(启动时多处以它为准,避免重复起子进程)。 +pub fn current() -> &'static OsInfo { + static CACHED: OnceLock = OnceLock::new(); + CACHED.get_or_init(detect) +} + +/// CLI 启动自检:等级 + 双语文案 + 系统信息。 +pub fn check() -> SupportCheck { + let os = current().clone(); + SupportCheck { + level: os.support_level, + message: os.describe().combined(), + os, + } +} diff --git a/crates/core/src/platform/paths.rs b/crates/core/src/platform/paths.rs new file mode 100644 index 0000000..afb2a8e --- /dev/null +++ b/crates/core/src/platform/paths.rs @@ -0,0 +1,186 @@ +//! 宿主平台探测与标准目录解析。 +//! +//! 环境变量读取走 [`EnvSource`] 注入,因此三模式落点可以在单测里确定性地覆盖各 OS。 +//! 设计出处:`DevDocs/PlanDoc/04-data-and-config.md` §1(三模式落点)。 + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// 需要分支处理的宿主平台族。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + /// Windows。 + Windows, + /// macOS。 + MacOs, + /// Linux 及其它 Unix。 + Linux, + /// 其它(按 Linux 的 XDG 习惯兜底)。 + Other, +} + +impl Platform { + /// 编译目标决定的当前平台。 + #[must_use] + pub const fn current() -> Self { + if cfg!(windows) { + Self::Windows + } else if cfg!(target_os = "macos") { + Self::MacOs + } else if cfg!(unix) { + Self::Linux + } else { + Self::Other + } + } + + /// 稳定名(写日志 / 序列化用)。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Windows => "windows", + Self::MacOs => "macos", + Self::Linux => "linux", + Self::Other => "other", + } + } +} + +/// 环境变量来源(生产用 [`ProcessEnv`],测试用 [`MapEnv`])。 +pub trait EnvSource { + /// 读一个环境变量;空串按不存在处理。 + fn var(&self, name: &str) -> Option; +} + +/// 真实进程环境。 +#[derive(Debug, Clone, Copy, Default)] +pub struct ProcessEnv; + +impl ProcessEnv { + /// 构造。 + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl EnvSource for ProcessEnv { + fn var(&self, name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + } +} + +/// 固定环境(测试 / 便携化封装用)。 +#[derive(Debug, Clone, Default)] +pub struct MapEnv { + vars: BTreeMap, +} + +impl MapEnv { + /// 空环境。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 链式塞一条变量:`MapEnv::new().with("HOME", "/home/u")`。 + #[must_use] + pub fn with(mut self, name: &str, value: &str) -> Self { + self.vars.insert(name.to_owned(), value.to_owned()); + self + } +} + +impl EnvSource for MapEnv { + fn var(&self, name: &str) -> Option { + self.vars.get(name).cloned() + } +} + +/// 用户主目录。 +#[must_use] +pub fn home_dir(env: &dyn EnvSource) -> Option { + if cfg!(windows) { + env.var("USERPROFILE") + .or_else(|| env.var("HOME")) + .map(PathBuf::from) + } else { + env.var("HOME").map(PathBuf::from) + } +} + +/// 可执行文件所在目录(Portable 模式的根)。 +/// +/// Linux 上 `/proc/self/exe` 由 `std` 处理;AppImage 场景下指向挂载点内的 exe, +/// 因此 AppImage 想要「便携到 exe 旁边」需由打包层设置环境变量接管(见 §1 细节)。 +#[must_use] +pub fn exe_dir() -> Option { + std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(Path::to_path_buf)) +} + +/// Standard 模式下的启动器数据目录(`.ULL` 的等价物)。 +/// +/// - Windows:`%APPDATA%\ULL` +/// - macOS:`~/Library/Application Support/ULL` +/// - Linux / 其它:`$XDG_DATA_HOME/ull`,回落 `~/.local/share/ull` +#[must_use] +pub fn standard_launcher_home(platform: Platform, env: &dyn EnvSource) -> Option { + match platform { + Platform::Windows => roaming_app_data(env).map(|dir| dir.join("ULL")), + Platform::MacOs => home_dir(env).map(|home| home.join("Library/Application Support/ULL")), + Platform::Linux | Platform::Other => xdg_data_home(env).map(|dir| dir.join("ull")), + } +} + +/// Standard 模式下的游戏目录(Mojang 约定,与 HMCL 的 HOME 目录一致)。 +/// +/// - Windows:`%APPDATA%\.minecraft` +/// - macOS:`~/Library/Application Support/minecraft` +/// - Linux / 其它:`~/.minecraft` +#[must_use] +pub fn standard_game_home(platform: Platform, env: &dyn EnvSource) -> Option { + match platform { + Platform::Windows => roaming_app_data(env).map(|dir| dir.join(".minecraft")), + Platform::MacOs => { + home_dir(env).map(|home| home.join("Library/Application Support/minecraft")) + } + Platform::Linux | Platform::Other => home_dir(env).map(|home| home.join(".minecraft")), + } +} + +/// `%APPDATA%`,回落 `%USERPROFILE%\AppData\Roaming`。 +fn roaming_app_data(env: &dyn EnvSource) -> Option { + env.var("APPDATA").map(PathBuf::from).or_else(|| { + env.var("USERPROFILE") + .map(|profile| PathBuf::from(profile).join("AppData/Roaming")) + }) +} + +/// `$XDG_DATA_HOME`,回落 `~/.local/share`。 +fn xdg_data_home(env: &dyn EnvSource) -> Option { + env.var("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|dir| dir.is_absolute()) + .or_else(|| home_dir(env).map(|home| home.join(".local/share"))) +} + +/// 写成可搬运字符串:相对路径统一用 `/`(HMCL `PortablePath` 做法),绝对路径原样保留。 +#[must_use] +pub fn to_portable_string(path: &Path) -> String { + let text = path.to_string_lossy(); + if path.is_absolute() { + text.into_owned() + } else { + text.replace('\\', "/") + } +} + +/// 解析可搬运字符串。 +#[must_use] +pub fn from_portable_string(text: &str) -> PathBuf { + PathBuf::from(text) +} diff --git a/crates/core/src/platform/receipt.rs b/crates/core/src/platform/receipt.rs new file mode 100644 index 0000000..5ea0a02 --- /dev/null +++ b/crates/core/src/platform/receipt.rs @@ -0,0 +1,202 @@ +//! 迁移收据:指纹 + 幂等,防重复迁移 / 半迁移(HMCL `MigrationReceipt` 思想)。 +//! +//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.8—— +//! 收据里存源文件 `sourceSHA256`,这样「用户删掉新文件、但旧文件没变」时不会重复迁移,旧文件变了才重放。 + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use super::atomic; +use super::error::PlatformError; +use super::error::PlatformResult; +use super::hash; + +/// 本实现能读懂的收据版本。 +pub const RECEIPT_SCHEMA_VERSION: u32 = 1; + +/// 收据类型。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReceiptKind { + /// 数据目录迁移。 + DataDirMove, + /// 遗留配置格式迁移。 + LegacyConfig, +} + +impl ReceiptKind { + /// 稳定名。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::DataDirMove => "data_dir_move", + Self::LegacyConfig => "legacy_config", + } + } +} + +/// 一次已完成的迁移记录。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MigrationReceipt { + /// 格式版本。 + pub schema_version: u32, + /// 收据类型。 + pub kind: ReceiptKind, + /// 源指纹(普通配置迁移 = 源文件内容 SHA-256;目录迁移 = 源/目标路径签名)。 + pub source_fingerprint: String, + /// 源路径。 + pub source: PathBuf, + /// 目标路径。 + pub target: PathBuf, + /// 完成时间(毫秒 since UNIX_EPOCH)。 + pub applied_at_ms: u64, +} + +impl MigrationReceipt { + /// 构造数据目录迁移收据。 + #[must_use] + pub fn data_dir_move(source: PathBuf, target: PathBuf, applied_at_ms: u64) -> Self { + let fingerprint = fingerprint_data_dir_move(&source, &target); + Self { + schema_version: RECEIPT_SCHEMA_VERSION, + kind: ReceiptKind::DataDirMove, + source_fingerprint: fingerprint, + source, + target, + applied_at_ms, + } + } + + /// 构造遗留配置迁移收据。 + #[must_use] + pub fn legacy_config( + source: PathBuf, + target: PathBuf, + source_fingerprint: impl Into, + applied_at_ms: u64, + ) -> Self { + Self { + schema_version: RECEIPT_SCHEMA_VERSION, + kind: ReceiptKind::LegacyConfig, + source_fingerprint: source_fingerprint.into(), + source, + target, + applied_at_ms, + } + } +} + +/// 是否该重放这次迁移。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplayDecision { + /// 收据在且源指纹相同 → 已经迁过,跳过(防重放)。 + SkipSameSource, + /// 收据在但源指纹变了 → 允许重放。 + ReplayChangedSource, + /// 没有收据 → 正常迁移。 + ReplayNoReceipt, +} + +impl ReplayDecision { + /// 是否需要执行迁移。 + #[must_use] + pub const fn should_replay(&self) -> bool { + !matches!(self, Self::SkipSameSource) + } + + /// 稳定名(日志 / 审计)。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::SkipSameSource => "skip_same_source", + Self::ReplayChangedSource => "replay_changed_source", + Self::ReplayNoReceipt => "replay_no_receipt", + } + } +} + +/// 根据已有收据与当前源指纹决定是否重放。 +#[must_use] +pub fn decide(existing: Option<&MigrationReceipt>, fingerprint: &str) -> ReplayDecision { + match existing { + None => ReplayDecision::ReplayNoReceipt, + Some(receipt) if receipt.source_fingerprint == fingerprint => { + ReplayDecision::SkipSameSource + } + Some(_) => ReplayDecision::ReplayChangedSource, + } +} + +/// 读收据;不存在返回 `None`。 +/// +/// # Errors +/// 读失败或 JSON 损坏、版本不认识时返回 [`PlatformError::ReceiptCorrupt`] / [`PlatformError::Io`]。 +pub fn read(path: &Path) -> PlatformResult> { + let Some(bytes) = atomic::read_optional(path)? else { + return Ok(None); + }; + let receipt: MigrationReceipt = + serde_json::from_slice(&bytes).map_err(|err| PlatformError::ReceiptCorrupt { + path: path.to_path_buf(), + reason: err.to_string(), + })?; + if receipt.schema_version > RECEIPT_SCHEMA_VERSION { + return Err(PlatformError::ReceiptCorrupt { + path: path.to_path_buf(), + reason: format!( + "收据版本 {} 高于本实现支持的 {RECEIPT_SCHEMA_VERSION}", + receipt.schema_version + ), + }); + } + Ok(Some(receipt)) +} + +/// 原子写收据。 +/// +/// # Errors +/// 序列化或写盘失败时返回 [`PlatformError`]。 +pub fn write(path: &Path, receipt: &MigrationReceipt) -> PlatformResult<()> { + let bytes = + serde_json::to_vec_pretty(receipt).map_err(|err| PlatformError::ReceiptCorrupt { + path: path.to_path_buf(), + reason: format!("无法序列化收据: {err}"), + })?; + atomic::write_atomic(path, &bytes) +} + +/// 内容指纹(SHA-256 十六进制)。 +#[must_use] +pub fn fingerprint_bytes(bytes: &[u8]) -> String { + hash::sha256_hex(bytes) +} + +/// 文件内容指纹;文件不存在返回 `None`。 +/// +/// # Errors +/// 读失败时返回 [`PlatformError::Io`]。 +pub fn fingerprint_file(path: &Path) -> PlatformResult> { + match hash::sha256_hex_file(path) { + Ok(hex) => Ok(Some(hex)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(PlatformError::Io { + path: path.to_path_buf(), + source: err, + }), + } +} + +/// 目录迁移的源指纹:源/目标路径签名。 +/// +/// 目录内容的哈希代价太高且天然易变(缓存/日志一直在动),因此目录迁移的抗重放主力是 +/// [`super::redirect`] 指针 + 「目标非空则拒绝」,收据负责留痕与「同源同目标不重做」。 +#[must_use] +pub fn fingerprint_data_dir_move(source: &Path, target: &Path) -> String { + let signature = format!( + "{}|{}", + super::paths::to_portable_string(source), + super::paths::to_portable_string(target) + ); + fingerprint_bytes(signature.as_bytes()) +} diff --git a/crates/core/src/platform/redirect.rs b/crates/core/src/platform/redirect.rs new file mode 100644 index 0000000..aa16a2f --- /dev/null +++ b/crates/core/src/platform/redirect.rs @@ -0,0 +1,137 @@ +//! `redirect.json` 指针文件——迁移后留在旧目录,防止便携盘被搬走后旧目录重新长出一个新实例。 +//! +//! 出处:`DevDocs/PlanDoc/04-data-and-config.md` §1 细节(「迁移 = 移动 + 原地留 redirect.json 指针」)。 + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use super::atomic; +use super::data_dir::DataMode; +use super::error::{io_at, PlatformError, PlatformResult}; +use super::layout::REDIRECT_FILE; + +/// 本实现能读懂的指针文件版本;更高版本视为不认识(避免瞎读新格式)。 +pub const REDIRECT_SCHEMA_VERSION: u32 = 1; + +/// 指针内容:旧目录 → 新目录。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RedirectPointer { + /// 格式版本。 + pub schema_version: u32, + /// 迁移时采用的模式。 + pub mode: DataMode, + /// 新的启动器数据目录。 + pub launcher_home: PathBuf, + /// 新的游戏目录。 + pub game_home: PathBuf, + /// 迁移时间(毫秒 since UNIX_EPOCH)。 + pub migrated_at_ms: u64, +} + +/// 跟随指针的结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Followed { + /// 最终命中的指针。 + pub pointer: RedirectPointer, + /// 实际跟随跳数(1 = 只跟了一跳)。 + pub hops: usize, +} + +/// `/redirect.json`。 +#[must_use] +pub fn redirect_path(dir: &Path) -> PathBuf { + dir.join(REDIRECT_FILE) +} + +/// 读指针;没有指针文件返回 `None`。 +/// +/// # Errors +/// 读失败或 JSON 损坏、版本不认识时返回 [`PlatformError`]。 +pub fn read(dir: &Path) -> PlatformResult> { + let path = redirect_path(dir); + let Some(bytes) = atomic::read_optional(&path)? else { + return Ok(None); + }; + let pointer: RedirectPointer = + serde_json::from_slice(&bytes).map_err(|err| PlatformError::RedirectCorrupt { + path: path.clone(), + reason: err.to_string(), + })?; + if pointer.schema_version > REDIRECT_SCHEMA_VERSION { + return Err(PlatformError::RedirectCorrupt { + path, + reason: format!( + "指针版本 {} 高于本实现支持的 {REDIRECT_SCHEMA_VERSION}", + pointer.schema_version + ), + }); + } + Ok(Some(pointer)) +} + +/// 原子写指针(父目录自动创建)。 +/// +/// # Errors +/// 序列化或写盘失败时返回 [`PlatformError`]。 +pub fn write(dir: &Path, pointer: &RedirectPointer) -> PlatformResult { + let path = redirect_path(dir); + let bytes = + serde_json::to_vec_pretty(pointer).map_err(|err| PlatformError::RedirectCorrupt { + path: path.clone(), + reason: format!("无法序列化指针: {err}"), + })?; + std::fs::create_dir_all(dir).map_err(io_at(dir))?; + atomic::write_atomic(&path, &bytes)?; + Ok(path) +} + +/// 删除指针(`None` = 本来就没有)。 +/// +/// # Errors +/// 删除失败时返回 [`PlatformError::Io`]。 +pub fn remove(dir: &Path) -> PlatformResult { + let path = redirect_path(dir); + match std::fs::remove_file(&path) { + Ok(()) => Ok(true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(PlatformError::Io { path, source: err }), + } +} + +/// 跟随指针链(最多 `max_hops` 跳,成环或超限报错)。 +/// +/// # Errors +/// 指针损坏或跳转成环/超限时返回 [`PlatformError`]。 +pub fn follow(dir: &Path, max_hops: usize) -> PlatformResult> { + let mut current = dir.to_path_buf(); + let mut visited: BTreeSet = BTreeSet::new(); + visited.insert(current.clone()); + let mut hops = 0usize; + + loop { + let Some(pointer) = read(¤t)? else { + return Ok(None); + }; + hops += 1; + if hops > max_hops { + return Err(PlatformError::RedirectCycle { + dir: dir.to_path_buf(), + hops, + }); + } + let next = pointer.launcher_home.clone(); + if !visited.insert(next.clone()) { + return Err(PlatformError::RedirectCycle { + dir: dir.to_path_buf(), + hops, + }); + } + // 还有下一跳就继续跟,否则这就是终点。 + if !redirect_path(&next).exists() { + return Ok(Some(Followed { pointer, hops })); + } + current = next; + } +} diff --git a/crates/core/src/platform/theme_probe.rs b/crates/core/src/platform/theme_probe.rs new file mode 100644 index 0000000..e1216de --- /dev/null +++ b/crates/core/src/platform/theme_probe.rs @@ -0,0 +1,182 @@ +//! 系统主题(浅色/深色)探测:给主题三态的 `auto` 模式做**初始化对齐**与后端不支持时的兜底跟随。 +//! +//! 出处:`DevDocs/PlanDoc/09-ui-layout.md` §2「主题模式三态」(D17 用户定案:Win/mac 用 Slint +//! `Palette.color-scheme` 原生跟随 OS 热切;Linux 依 DE 的 xdg-desktop-portal,缺省回落浅色并 +//! 允许手动钉死)。Slint 后端会在运行期自己监听 OS 主题变化,所以这里**只补两件事**: +//! +//! 1. 启动时先读一次真实系统主题,避免「auto 模式下首帧按浅色画、几百毫秒后突然翻黑」的闪变; +//! 2. 后端把 `Palette.color-scheme` 报成 `unknown`(无 portal 的 Linux / 软渲染后端)时, +//! 由 app 轮询本模块把系统主题补上,`auto` 仍然可用。 +//! +//! 与 platform 层其他探测一致(见 [`super::windows_version`]):core 是 `#![forbid(unsafe_code)]`, +//! 一律走外部命令 + 纯函数解析。解析函数跨平台编译,任意宿主都能单测(`tests/platform/theme_cases.rs`)。 + +/// 系统主题三态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SystemTheme { + /// 浅色。 + Light, + /// 深色。 + Dark, + /// 读不到(无桌面环境 / 命令缺失 / 输出无法解析)。 + Unknown, +} + +impl SystemTheme { + /// 稳定名(日志用)。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Light => "light", + Self::Dark => "dark", + Self::Unknown => "unknown", + } + } + + /// 是否深色(`Unknown` 为 `None`,调用方决定回落)。 + #[must_use] + pub const fn is_dark(&self) -> Option { + match self { + Self::Light => Some(false), + Self::Dark => Some(true), + Self::Unknown => None, + } + } +} + +/// Windows 个性化键(`AppsUseLightTheme` = 应用/任务栏主题,与 `SystemUsesLightTheme` 区分)。 +pub const WINDOWS_PERSONALIZE_KEY: &str = + r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; + +/// 真实探测系统主题。 +/// +/// - Windows:注册表 `AppsUseLightTheme`(DWORD,1 = 浅色 / 0 = 深色); +/// - macOS:`defaults read -g AppleInterfaceStyle`(深色时输出 `Dark`,浅色时该键不存在); +/// - Linux:`gsettings get org.gnome.desktop.interface color-scheme`,退到 `gtk-theme` 名字。 +#[must_use] +pub fn probe() -> SystemTheme { + #[cfg(target_os = "windows")] + { + run( + "reg", + &["query", WINDOWS_PERSONALIZE_KEY, "/v", "AppsUseLightTheme"], + ) + .and_then(|text| parse_apps_use_light_theme(&text)) + .unwrap_or(SystemTheme::Unknown) + } + #[cfg(target_os = "macos")] + { + // 键不存在 = 浅色(defaults 会非零退出,stdout 为空 → 回落 Light)。 + match run("defaults", &["read", "-g", "AppleInterfaceStyle"]) { + Some(text) => parse_macos_interface_style(&text).unwrap_or(SystemTheme::Unknown), + None => SystemTheme::Light, + } + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + probe_linux() + } +} + +/// Linux:先问 GNOME/portal 的 `color-scheme`,再退到 `gtk-theme` 名字里是否带 `-dark`。 +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn probe_linux() -> SystemTheme { + if let Some(text) = run( + "gsettings", + &["get", "org.gnome.desktop.interface", "color-scheme"], + ) { + if let Some(theme) = parse_gsettings_color_scheme(&text) { + return theme; + } + } + if let Some(text) = run( + "gsettings", + &["get", "org.gnome.desktop.interface", "gtk-theme"], + ) { + if let Some(theme) = parse_gtk_theme(&text) { + return theme; + } + } + SystemTheme::Unknown +} + +/// 解析 `reg query ... /v AppsUseLightTheme` 的输出。 +/// +/// 形如 ` AppsUseLightTheme REG_DWORD 0x1`;`0x0` = 深色。 +#[must_use] +pub fn parse_apps_use_light_theme(text: &str) -> Option { + text.lines().find_map(|line| { + let mut fields = line.split_whitespace(); + let name = fields.next()?; + if !name.eq_ignore_ascii_case("AppsUseLightTheme") { + return None; + } + let _kind = fields.next()?; + let raw = fields.next().unwrap_or_default(); + parse_dword(raw).map(|value| { + if value == 0 { + SystemTheme::Dark + } else { + SystemTheme::Light + } + }) + }) +} + +/// 解析 `defaults read -g AppleInterfaceStyle` 的输出(深色 = `Dark`;浅色时该键不存在)。 +#[must_use] +pub fn parse_macos_interface_style(text: &str) -> Option { + match text.trim() { + "" => None, + value if value.eq_ignore_ascii_case("dark") => Some(SystemTheme::Dark), + _ => Some(SystemTheme::Light), + } +} + +/// 解析 `gsettings get ... color-scheme`:`'prefer-dark'` / `'prefer-light'` / `'default'`。 +/// +/// `default` 表示「未指定」,要继续看 `gtk-theme`,故返回 `None`。 +#[must_use] +pub fn parse_gsettings_color_scheme(text: &str) -> Option { + let value = text.trim().trim_matches('\'').trim_matches('"'); + if value.eq_ignore_ascii_case("prefer-dark") { + Some(SystemTheme::Dark) + } else if value.eq_ignore_ascii_case("prefer-light") { + Some(SystemTheme::Light) + } else { + None + } +} + +/// 解析 `gsettings get ... gtk-theme`:主题名带 `dark` 即深色(`Yaru-dark`、`Adwaita-dark`)。 +#[must_use] +pub fn parse_gtk_theme(text: &str) -> Option { + let value = text.trim().trim_matches('\'').trim_matches('"'); + if value.is_empty() { + return None; + } + if value.to_ascii_lowercase().contains("dark") { + Some(SystemTheme::Dark) + } else { + Some(SystemTheme::Light) + } +} + +fn parse_dword(raw: &str) -> Option { + let raw = raw.trim(); + if let Some(hex) = raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")) { + u32::from_str_radix(hex, 16).ok() + } else { + raw.parse().ok() + } +} + +/// 跑一个外部命令,取 stdout(命令缺失 / 非零退出 / 空输出都返回 `None`)。 +fn run(program: &str, args: &[&str]) -> Option { + use std::process::Command; + let output = Command::new(program).args(args).output().ok()?; + if output.stdout.is_empty() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).into_owned()) +} diff --git a/crates/core/src/platform/vk_probe.rs b/crates/core/src/platform/vk_probe.rs new file mode 100644 index 0000000..25a817e --- /dev/null +++ b/crates/core/src/platform/vk_probe.rs @@ -0,0 +1,305 @@ +//! Vulkan 能力探测的**安全层**(无 `unsafe`,无 FFI):只报可观测事实,不下「能不能玩」的结论。 +//! +//! 依据 `DevDocs/PlanDoc/research/05-mc-vulkan.md`(M26)§2.3 与 §4.3: +//! MC 的 Vulkan 后端在 Windows/Linux 上**不打包** `vulkan-1.dll` / `libvulkan.so.1`, +//! 而是运行期去加载系统的 loader(natives 只给 macOS 发 MoltenVK)。所以「版本支持 Vulkan」 +//! 与「这台机器能跑 Vulkan」是两件事,前置探测不可省。 +//! +//! 分工照抄 [`super::linux_caps::probe_gl`]:core 只做「文件存在性 / 注册表 / 环境变量 / +//! 标准目录」这一层,真探测(建 instance、枚举物理设备、查 `VK 1.2` + +//! `VK_KHR_dynamic_rendering` + `VK_KHR_push_descriptor` 三项硬门槛)归 app 层。 +//! +//! # ⚠️ 注册表键缺失 ≠ 没有 Vulkan(真机证据) +//! +//! 实测机(Windows 11 + AMD 独显,Vulkan 1.3.217 可用)上 +//! `HKLM\SOFTWARE\Khronos\Vulkan\Drivers` **压根不存在**——ICD 由 loader 自己按 +//! `%SystemRoot%\System32` 与驱动安装目录自寻。因此该键只作**加分项**([`VkProbe::Available`]), +//! 缺失时降级为 [`VkProbe::LoaderOnly`]、**绝不**判 [`VkProbe::Missing`]。 +//! +//! 解析函数是纯函数且跨平台编译,单测用注入样本覆盖三平台(见 `tests/platform/vk_cases.rs`)。 + +use std::path::{Path, PathBuf}; + +use crate::platform::os_info::OsFamily; + +/// Windows 上的 Vulkan loader 文件名(系统目录内)。 +pub const WINDOWS_LOADER: &str = "vulkan-1.dll"; + +/// `HKLM\SOFTWARE\Khronos\Vulkan\Drivers`:显式 ICD 注册键(**只作加分**,见模块文档)。 +/// +/// ICD 注册为这个键下的**值**(值名 = manifest 绝对路径,数据是 `dword:00000000`), +/// 不是子键(Vulkan-Loader `LoaderDriverInterface.md` 的 Windows 驱动发现一节)。 +pub const VULKAN_DRIVERS_KEY: &str = r"HKLM\SOFTWARE\Khronos\Vulkan\Drivers"; + +/// 同一个键在 `reg query` 输出里的**键头行**形态。 +/// +/// `reg query` 即使传入缩写 hive 名(`HKLM`),输出也用完整 hive 名 +/// (`HKEY_LOCAL_MACHINE`)且不带尾反斜杠——解析时必须按这个形态比对。 +pub const VULKAN_DRIVERS_KEY_HEADER: &str = r"HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\Drivers"; + +/// Khronos loader 认的 ICD 路径环境变量(旧名在前,新名在后)。 +pub const ICD_ENV_VARS: &[&str] = &["VK_ICD_FILENAMES", "VK_DRIVER_FILES"]; + +/// Linux 的 ICD 清单目录(`*.json`,loader 启动时按此枚举驱动)。 +pub const ICD_DIRS: &[&str] = &[ + "/usr/share/vulkan/icd.d", + "/usr/local/share/vulkan/icd.d", + "/etc/vulkan/icd.d", +]; + +/// Vulkan 能力(四档;`Unknown` 是派生默认值,「没探过」不等于「没有」)。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum VkProbe { + /// 未探测(非探测路径构造的快照)。 + #[default] + Unknown, + /// loader 在,但没有看到任何 ICD 注册信号。 + /// + /// **不是**「没有 Vulkan」:Windows 上注册表键缺失是常态(模块文档的真机证据), + /// 设备是否存在要靠 app 层的真探测确认。 + LoaderOnly, + /// loader + 驱动信号都在(或 macOS 上 MoltenVK 在场)——大概率可用。 + Available, + /// 连 loader 都找不到:Vulkan 后端一定起不来。 + Missing, +} + +impl VkProbe { + /// 简短名(日志/摘要行用)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::LoaderOnly => "loader_only", + Self::Available => "available", + Self::Missing => "missing", + } + } + + /// loader 是否在场(`Available` 与 `LoaderOnly` 都为真)。 + #[must_use] + pub const fn has_loader(self) -> bool { + matches!(self, Self::Available | Self::LoaderOnly) + } + + /// 是否已能确定「这台机器跑不了 Vulkan」(只有 [`Self::Missing`])。 + #[must_use] + pub const fn is_missing(self) -> bool { + matches!(self, Self::Missing) + } + + /// 是否足够确定「可以用 Vulkan」(只有 [`Self::Available`])。 + /// + /// UI/建议路径用它可以保守一点:`LoaderOnly` 应当提示「先试一次再说」而不是承诺可用。 + #[must_use] + pub const fn is_available(self) -> bool { + matches!(self, Self::Available) + } +} + +/// 平台无关的探测证据(单测直接注入,不读真机)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VkEvidence { + /// 系统家族(决定判定口径)。 + pub family: OsFamily, + /// loader 文件是否找到(`vulkan-1.dll` / `libvulkan.so.1` / `libvulkan.1.dylib` / MoltenVK)。 + pub loader_present: bool, + /// 是否看到驱动信号:Windows 注册表键、Linux 的 ICD 目录/环境变量、macOS 无此项。 + pub icd_signal: bool, +} + +impl VkEvidence { + /// 构造。 + #[must_use] + pub const fn new(family: OsFamily, loader_present: bool, icd_signal: bool) -> Self { + Self { + family, + loader_present, + icd_signal, + } + } +} + +/// 证据 → 结论(纯函数,三平台共用;macOS 单独口径见函数体注释)。 +#[must_use] +pub fn classify(evidence: VkEvidence) -> VkProbe { + if evidence.family == OsFamily::MacOs { + // macOS 没有系统 Vulkan:LWJGL 自带的 MoltenVK 既是 loader 又是 ICD(Vulkan→Metal 翻译), + // 所以「MoltenVK/libvulkan.1.dylib 在」本身就是可用证据,没有 LoaderOnly 这一档。 + return if evidence.loader_present { + VkProbe::Available + } else { + VkProbe::Missing + }; + } + if !evidence.loader_present { + return VkProbe::Missing; + } + if evidence.icd_signal { + VkProbe::Available + } else { + // 注册表键缺失是常态,只降级不否定(模块文档的真机证据)。 + VkProbe::LoaderOnly + } +} + +/// 探测当前机器的 Vulkan 能力(一次性,结果由 [`crate::platform::os_info::current`] 缓存)。 +#[must_use] +pub fn probe_vk() -> VkProbe { + classify(probe_evidence()) +} + +/// 采集当前机器的探测证据(真读盘/注册表;单测请直接用 [`classify`])。 +#[must_use] +pub fn probe_evidence() -> VkEvidence { + let family = OsFamily::current(); + VkEvidence::new(family, loader_present(family), icd_signal(family)) +} + +/// loader 候选绝对路径(按平台;`SystemRoot` 缺失时回落到 `C:\Windows`)。 +#[must_use] +pub fn loader_candidates(family: OsFamily) -> Vec { + match family { + OsFamily::Windows => { + let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_owned()); + vec![Path::new(&root).join("System32").join(WINDOWS_LOADER)] + } + OsFamily::MacOs => [ + "/usr/local/lib/libvulkan.1.dylib", + "/opt/homebrew/lib/libvulkan.1.dylib", + "/usr/local/lib/libvulkan.dylib", + "/opt/homebrew/lib/libvulkan.dylib", + // MC 的 lwjgl-vulkan natives 里带的就是这个。 + "/usr/local/lib/libMoltenVK.dylib", + "/opt/homebrew/lib/libMoltenVK.dylib", + ] + .iter() + .map(PathBuf::from) + .collect(), + OsFamily::Linux => [ + "/usr/lib/x86_64-linux-gnu/libvulkan.so.1", + "/usr/lib/aarch64-linux-gnu/libvulkan.so.1", + "/usr/lib64/libvulkan.so.1", + "/usr/lib/libvulkan.so.1", + "/usr/local/lib/libvulkan.so.1", + // 开发包只给不带版本号的名字(`libvulkan.so`)。 + "/usr/lib/x86_64-linux-gnu/libvulkan.so", + "/usr/lib64/libvulkan.so", + "/usr/local/lib/libvulkan.so", + ] + .iter() + .map(PathBuf::from) + .collect(), + } +} + +fn loader_present(family: OsFamily) -> bool { + if loader_candidates(family).iter().any(|path| path.is_file()) { + return true; + } + // 标准路径没命中时,Linux 用 `ldconfig -p` 兜底(发行版把 loader 放在别处时更权威)。 + #[cfg(target_os = "linux")] + { + ldconfig_has_libvulkan(&run("ldconfig", &["-p"]).unwrap_or_default()) + } + #[cfg(not(target_os = "linux"))] + { + false + } +} + +fn icd_signal(family: OsFamily) -> bool { + match family { + OsFamily::Windows => { + let text = run("reg", &["query", VULKAN_DRIVERS_KEY]).unwrap_or_default(); + registry_has_driver_entry(&text) + } + OsFamily::Linux => { + env_icd_path(|key| std::env::var(key).ok()).is_some() + || ICD_DIRS + .iter() + .any(|dir| count_icd_manifests(Path::new(dir)) > 0) + } + // macOS 的 ICD 就是 MoltenVK 本身,已在 loader 判据里。 + OsFamily::MacOs => false, + } +} + +/// 从环境变量取 ICD 清单路径(`VK_ICD_FILENAMES` 优先,其次 `VK_DRIVER_FILES`)。 +#[must_use] +pub fn env_icd_path(get: impl Fn(&str) -> Option) -> Option { + ICD_ENV_VARS.iter().find_map(|key| { + get(key) + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + }) +} + +/// 目录下 `*.json` ICD 清单的条数(目录不存在返回 0)。 +#[must_use] +pub fn count_icd_manifests(dir: &Path) -> usize { + let Ok(entries) = std::fs::read_dir(dir) else { + return 0; + }; + entries + .flatten() + .filter(|entry| { + entry + .path() + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("json")) + }) + .count() +} + +/// `reg query <键>`(**不带** `/s`)的输出里,该键是否存在**值项**。 +/// +/// 真实格式(本机实测,Win11 22H2):一个空行 → 键头行(顶格、用完整 hive 名、无尾反斜杠) +/// → 缩进的值行 ` <值名> REG_DWORD 0x0`。Vulkan 的 ICD 注册正是「**值名 = manifest +/// 绝对路径**」(`"C:\vendor\vk.json"=dword:00000000`,见 Vulkan-Loader +/// `LoaderDriverInterface.md` 的 Windows 驱动发现一节),不是子键。 +/// +/// 所以判据是「键头行 + 其后至少一条带 `REG_` 类型标记的缩进行」:键存在但没有任何值时 +/// 只有键头行;子键在没有 `/s` 时也缩进打印,但**不带** `REG_` 标记,据此区分。键不存在时 +/// 输出只有本地化错误行(`ERROR: The system was unable to find…` / +/// `错误: 系统找不到指定的注册表项或值。`),既无键头也无数值行。 +/// +/// 注:值名可能含空格(manifest 绝对路径),所以不能按列切分,只能按 token 找类型标记。 +#[must_use] +pub fn registry_has_driver_entry(text: &str) -> bool { + let mut in_key = false; + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if !line.starts_with([' ', '\t']) { + // 顶格行 = 键头行(或错误行);遇到别的键就重置状态。 + in_key = trimmed + .trim_end_matches('\\') + .eq_ignore_ascii_case(VULKAN_DRIVERS_KEY_HEADER); + continue; + } + if in_key && trimmed.split_whitespace().any(|t| t.starts_with("REG_")) { + return true; + } + } + false +} + +/// `ldconfig -p` 输出里是否列了 `libvulkan.so.1`。 +#[must_use] +pub fn ldconfig_has_libvulkan(text: &str) -> bool { + text.lines() + .any(|line| line.contains("libvulkan.so.1") || line.contains("libvulkan.so ")) +} + +/// 跑一个只读命令取 stdout(`reg query` / `ldconfig -p`;失败返回 `None`)。 +fn run(program: &str, args: &[&str]) -> Option { + use std::process::Command; + let output = Command::new(program).args(args).output().ok()?; + if output.stdout.is_empty() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).into_owned()) +} diff --git a/crates/core/src/platform/windows_version.rs b/crates/core/src/platform/windows_version.rs new file mode 100644 index 0000000..bc9fd9b --- /dev/null +++ b/crates/core/src/platform/windows_version.rs @@ -0,0 +1,215 @@ +//! Windows 版本探测:真实的 `major.minor.build`(D19 · `PlanDoc/11-os-detection.md` §2)。 +//! +//! 数据源(从快到慢,探测发生在启动路径上): +//! +//! 1. `reg query ... /v CurrentBuildNumber` 取 build 号,由 [`from_build`] 反推 `major.minor` +//! (build 号在版本族内单调;实测单值查询约 50ms,整键查询约 150ms); +//! 2. 整键 `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion`:Win10+ 有 +//! `CurrentMajorVersionNumber` / `CurrentMinorVersionNumber`(REG_DWORD,`reg query` 以 +//! `0x..` 十六进制输出),Win7 / 8 / 8.1 只有 `CurrentVersion`(`6.1` / `6.2` / `6.3`, +//! Win10+ 上它是过期值); +//! 3. 兜底 `cmd /c ver`:输出 `Microsoft Windows [Version 10.0.19045.4046]`(中文系统为 +//! `[版本 …]`)。只取方括号内的数字段,因此与系统语言无关。 +//! +//! # 为什么不用 `RtlGetVersion` +//! +//! D19 原文方案是 `RtlGetVersion`(ntdll),但 core 是 `#![forbid(unsafe_code)]` +//! (`lib.rs`,D3 架构纪律 + workspace lint `unsafe_code = deny`),FFI 一律不可用。 +//! 上面两条路径读的是同一份真实事实(`cmd.exe` 自带 manifest,报真实版本,不受 +//! `GetVersionEx` 兼容层欺骗),差别只是启动时一次性几毫秒 —— 结果由 +//! [`crate::platform::os_info::current`] 缓存,不会重复付这个代价。 +//! +//! 解析函数是纯函数且**跨平台编译**,单测用注入样本覆盖各版本输出(见 `tests/platform/`)。 + +/// Windows 11 的起始 build 号(22H2 之前就有 22000)。 +pub const WIN11_MIN_BUILD: u32 = 22_000; + +/// 存放版本值的注册表键。 +pub const VERSION_KEY: &str = r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion"; + +/// Windows 真实版本。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WindowsVersion { + /// 主版本号(Win7 = 6,Win10/11 = 10)。 + pub major: u32, + /// 次版本号(Win7 = 1,Win10/11 = 0)。 + pub minor: u32, + /// 构建号(Win7 SP1 = 7601,Win10 22H2 = 19045,Win11 = 22000+)。 + pub build: u32, +} + +impl WindowsVersion { + /// 构造。 + pub fn new(major: u32, minor: u32, build: u32) -> Self { + Self { + major, + minor, + build, + } + } + + /// `major.minor` 元组(最低版本判定用)。 + pub fn major_minor(&self) -> (u32, u32) { + (self.major, self.minor) + } + + /// 版本串(`rules.os.version` 用,例如 `10.0.19045`)。 + pub fn version_string(&self) -> String { + format!("{}.{}.{}", self.major, self.minor, self.build) + } + + /// 产品名(由版本号推导,不用本地化的注册表 `ProductName`)。 + pub fn display_name(&self) -> &'static str { + match self.major_minor() { + (10, _) if self.build >= WIN11_MIN_BUILD => "Windows 11", + (10, _) => "Windows 10", + (6, 3) => "Windows 8.1", + (6, 2) => "Windows 8", + (6, 1) => "Windows 7", + (6, 0) => "Windows Vista", + (5, _) => "Windows XP", + _ => "Windows", + } + } +} + +/// 真实探测;非 Windows 平台恒为 `None`。 +/// +/// 三档,从快到慢(探测在启动路径上,实测整键 `reg query` 会枚举上百个子键,约 150ms: +/// 单值查询约 50ms): +/// 1. `reg query ... /v CurrentBuildNumber` 取 build,再由 [`from_build`] 反推 `major.minor`; +/// 2. 整键查询(Win7 的 `CurrentVersion`、Win10+ 的数字键都在这里解析,见 [`parse_reg_query`]); +/// 3. `cmd /c ver`(注册表被策略禁用时的兜底)。 +#[cfg(target_os = "windows")] +pub fn probe() -> Option { + let fast = run("reg", &["query", VERSION_KEY, "/v", "CurrentBuildNumber"]) + .and_then(|text| parse_build_number(&text)) + .and_then(from_build); + if fast.is_some() { + return fast; + } + if let Some(version) = + run("reg", &["query", VERSION_KEY]).and_then(|text| parse_reg_query(&text)) + { + return Some(version); + } + run("cmd", &["/c", "ver"]).and_then(|text| parse_ver_output(&text)) +} + +/// 真实探测;非 Windows 平台恒为 `None`。 +#[cfg(not(target_os = "windows"))] +pub fn probe() -> Option { + None +} + +/// 由 build 号反推 `major.minor`(build 号在版本族内单调,是唯一一条免整键查询的快路径)。 +/// +/// 7600/7601 = 6.1(Win7 / 2008 R2)、9200 = 6.2(Win8 / 2012)、9600 = 6.3(Win8.1 / 2012 R2)、 +/// 10240+ = 10.0(Win10 / Win11 / Server 2016+)。认不出的 build 返回 `None`, +/// 由 [`parse_reg_query`] / [`parse_ver_output`] 兜底。 +pub fn from_build(build: u32) -> Option { + let (major, minor) = match build { + 6000..=7599 => (6, 0), + 7600..=9199 => (6, 1), + 9200..=9599 => (6, 2), + 9600..=10239 => (6, 3), + 10240.. => (10, 0), + // Vista 之前的 build 号太小,不猜(交给整键/`ver` 解析)。 + _ => return None, + }; + Some(WindowsVersion::new(major, minor, build)) +} + +/// 从 `reg query ... /v CurrentBuildNumber` 的输出取 build 号。 +pub fn parse_build_number(text: &str) -> Option { + text.lines().find_map(|line| { + let mut fields = line.split_whitespace(); + let name = fields.next()?; + if !name.eq_ignore_ascii_case("currentbuildnumber") { + return None; + } + let _kind = fields.next(); + parse_number(fields.next().unwrap_or_default()) + }) +} + +/// 解析 `reg query ` 的输出。 +/// +/// 形如 ` CurrentBuildNumber REG_SZ 19045`;DWORD 值以 `0x..` 输出。 +pub fn parse_reg_query(text: &str) -> Option { + let mut major = None; + let mut minor = None; + let mut build = None; + let mut legacy = None; + for line in text.lines() { + let mut fields = line.split_whitespace(); + let Some(name) = fields.next() else { + continue; + }; + let _kind = fields.next(); + let value = fields.next().unwrap_or_default(); + match name.to_ascii_lowercase().as_str() { + "currentmajorversionnumber" => major = parse_number(value), + "currentminorversionnumber" => minor = parse_number(value), + "currentbuildnumber" => build = parse_number(value), + "currentversion" => legacy = parse_version_pair(value), + _ => {} + } + } + let build = build?; + let (major, minor) = match major { + Some(major) => (major, minor.unwrap_or(0)), + // Win7 / 8 / 8.1:没有 CurrentMajorVersionNumber,只能读 CurrentVersion。 + None => legacy?, + }; + Some(WindowsVersion::new(major, minor, build)) +} + +/// 解析 `cmd /c ver` 的输出(`Microsoft Windows [Version 10.0.19045.4046]`, +/// 中文系统 `Microsoft Windows [版本 10.0.19045.4046]`)。 +pub fn parse_ver_output(text: &str) -> Option { + let start = text.find('[')? + 1; + let rest = &text[start..]; + let inner = &rest[..rest.find(']')?]; + // 只保留数字与点,语言相关的字样(`Version` / `版本`)会被切成空段。 + let digits = inner + .split(|c: char| !(c.is_ascii_digit() || c == '.')) + .find(|segment| segment.contains('.'))?; + let parts: Vec = digits + .split('.') + .take(3) + .map(str::parse) + .collect::>() + .ok()?; + match parts.as_slice() { + [major, minor, build] => Some(WindowsVersion::new(*major, *minor, *build)), + _ => None, + } +} + +fn parse_number(value: &str) -> Option { + let trimmed = value.trim(); + if let Some(hex) = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + { + u32::from_str_radix(hex, 16).ok() + } else { + trimmed.parse().ok() + } +} + +fn parse_version_pair(value: &str) -> Option<(u32, u32)> { + let (major, minor) = value.trim().split_once('.')?; + Some((major.parse().ok()?, minor.parse().ok()?)) +} + +#[cfg(target_os = "windows")] +fn run(program: &str, args: &[&str]) -> Option { + use std::process::Command; + let output = Command::new(program).args(args).output().ok()?; + if output.stdout.is_empty() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).into_owned()) +} diff --git a/crates/core/src/setting/appearance.rs b/crates/core/src/setting/appearance.rs new file mode 100644 index 0000000..704c3ac --- /dev/null +++ b/crates/core/src/setting/appearance.rs @@ -0,0 +1,226 @@ +//! 「外观(高级)」配置簇的读写(D17 · `09-ui-layout.md` §2 G6 + §111 约束)。 +//! +//! 为什么放在 core:主题令牌的**取值与校验**是配置事实,视觉实现(Slint 侧)只消费结果。 +//! 分层纪律要求 core 零 UI 依赖——本文件只认 `appearance.*` 这些键与它们的取值形状, +//! 不认任何像素/控件([`super::keys`] 登记同一份键表,两边由 `tests/setting/appearance_keys.rs` 固定)。 +//! +//! 设计约束(09 §111):一切高级特性**默认关闭**(出厂 Metro 纯色);全部走 D6 schema +//! (AI 可写白名单内,可被 agent 调节)。因此默认值必须等于「什么都没开」的出厂态: +//! `panel_alpha = 1.0`、`glass = false`、`bg_image_enabled = false`、`accent` 空串(= 出厂 accent)。 + +use serde_json::Value; + +use super::document::SettingDocument; +use super::store::SettingStore; + +/// `appearance.bg_fill` 的允许值(09 §101:cover / contain / tile)。**顺序即 UI 分段控件的顺序**。 +pub const BG_FILLS: &[&str] = &["cover", "contain", "tile"]; + +/// 出厂强调色(Metro 蓝,D14);`appearance.accent_color` 为空串时用它。 +pub const DEFAULT_ACCENT: &str = "#0078D4"; + +/// `appearance.panel_alpha` 下限:再低前景就压不住背景图(09 §111「对比度检查提示」的硬底)。 +pub const PANEL_ALPHA_MIN: f64 = 0.4; +/// `appearance.bg_dim` 下限(0 = 不暗化,允许但设置页会提示对比度风险)。 +pub const BG_DIM_MIN: f64 = 0.0; +/// `appearance.bg_dim` 上限:再高背景图基本看不见,等于关掉。 +pub const BG_DIM_MAX: f64 = 0.95; + +/// 背景图填充模式。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BgFill { + /// 等比铺满、裁切溢出(默认:不露底色) + #[default] + Cover, + /// 等比完整显示、留底色 + Contain, + /// 原始尺寸平铺 + Tile, +} + +impl BgFill { + /// 落盘取值。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Cover => "cover", + Self::Contain => "contain", + Self::Tile => "tile", + } + } + + /// 从落盘取值解析;未知值按 `cover` 兜底(手改坏配置也不崩)。 + #[must_use] + pub fn from_key_value(value: &str) -> Self { + match value { + "contain" => Self::Contain, + "tile" => Self::Tile, + _ => Self::Cover, + } + } +} + +/// 外观(高级)配置簇:**只是取值快照**,读写都走 [`super::store::SettingStore`]。 +#[derive(Debug, Clone, PartialEq)] +pub struct Appearance { + /// 主题三态:`auto` / `light` / `dark`(与 `prefs::ThemeMode` 同名同值)。 + pub theme_mode: String, + /// 自定义窗口背景图开关(默认关)。 + pub bg_image_enabled: bool, + /// 背景图路径(PNG;空 = 未设置)。 + pub bg_image: String, + /// 背景图填充模式。 + pub bg_fill: BgFill, + /// 叠加暗化层强度 0.0~0.95(越大前景越清晰)。 + pub bg_dim: f64, + /// 全局面板半透明度 0.4~1.0(1.0 = 出厂不透明)。 + pub panel_alpha: f64, + /// 玻璃拟态卡片(预生成模糊背景层)。 + pub glass: bool, + /// 自定义强调色 `#RRGGBB`(空串 = 出厂 [`DEFAULT_ACCENT`])。 + pub accent_color: String, +} + +impl Default for Appearance { + fn default() -> Self { + Self { + theme_mode: "auto".to_owned(), + bg_image_enabled: false, + bg_image: String::new(), + bg_fill: BgFill::Cover, + bg_dim: 0.35, + panel_alpha: 1.0, + glass: false, + accent_color: String::new(), + } + } +} + +impl Appearance { + /// 从配置文档读一份快照(文件值 → 默认值;坏值按默认值兜底,绝不 panic)。 + #[must_use] + pub fn read(doc: &SettingDocument) -> Self { + let default = Self::default(); + let text = |key: &str| doc.effective_str(key).map(str::to_owned); + let boolean = |key: &str| doc.effective(key).and_then(Value::as_bool); + let number = |key: &str| doc.effective(key).and_then(Value::as_f64); + + Self { + theme_mode: text("appearance.theme_mode").unwrap_or(default.theme_mode), + bg_image_enabled: boolean("appearance.bg_image_enabled").unwrap_or(false), + bg_image: text("appearance.bg_image").unwrap_or_default(), + bg_fill: text("appearance.bg_fill") + .map_or(default.bg_fill, |value| BgFill::from_key_value(&value)), + bg_dim: number("appearance.bg_dim") + .map_or(default.bg_dim, |value| clamp(value, BG_DIM_MIN, BG_DIM_MAX)), + panel_alpha: number("appearance.panel_alpha").map_or(default.panel_alpha, |value| { + clamp(value, PANEL_ALPHA_MIN, 1.0) + }), + glass: boolean("appearance.glass").unwrap_or(false), + accent_color: text("appearance.accent_color") + .map(|value| normalize_hex(&value).unwrap_or_default()) + .unwrap_or_default(), + } + } + + /// 从 store 读(配置读写的统一入口)。 + #[must_use] + pub fn from_store(store: &SettingStore) -> Self { + Self::read(store.doc()) + } + + /// 实际生效的强调色:未自定义时回落到出厂 accent。 + #[must_use] + pub fn accent_or_default(&self) -> &str { + if self.accent_color.is_empty() { + DEFAULT_ACCENT + } else { + &self.accent_color + } + } + + /// 背景图是否真的会用上(开关打开 **且** 路径非空)。 + #[must_use] + pub fn bg_image_active(&self) -> bool { + self.bg_image_enabled && !self.bg_image.trim().is_empty() + } + + /// 写回一组键(调用方负责 `save()`;默认值不落盘由 `SettingDocument` 决定)。 + /// + /// 值先过一遍规范化:`bg_fill` 认枚举、`bg_dim`/`panel_alpha` 夹到范围内、 + /// `accent_color` 只接受 `#RRGGBB`(空串表示清掉自定义)。 + /// + /// # Errors + /// 键值被 schema 拒绝(只读文件 / 未知键 / 越界)时返回 [`super::error::SettingError`]。 + pub fn write(&self, store: &mut SettingStore) -> super::error::SettingResult<()> { + store.set( + "appearance.theme_mode", + Value::from(self.theme_mode.as_str()), + )?; + store.set( + "appearance.bg_image_enabled", + Value::from(self.bg_image_enabled), + )?; + store.set("appearance.bg_image", Value::from(self.bg_image.as_str()))?; + store.set("appearance.bg_fill", Value::from(self.bg_fill.as_str()))?; + store.set( + "appearance.bg_dim", + Value::from(clamp(self.bg_dim, BG_DIM_MIN, BG_DIM_MAX)), + )?; + store.set( + "appearance.panel_alpha", + Value::from(clamp(self.panel_alpha, PANEL_ALPHA_MIN, 1.0)), + )?; + store.set("appearance.glass", Value::from(self.glass))?; + store.set( + "appearance.accent_color", + Value::from(self.accent_color.as_str()), + )?; + Ok(()) + } +} + +/// `#RRGGBB` 规范化(大写、去空格);不合法返回 `None`。 +#[must_use] +pub fn normalize_hex(text: &str) -> Option { + let trimmed = text.trim(); + let body = trimmed.strip_prefix('#').unwrap_or(trimmed); + if body.len() != 6 || !body.chars().all(|ch| ch.is_ascii_hexdigit()) { + return None; + } + Some(format!("#{}", body.to_ascii_uppercase())) +} + +/// `#RRGGBB` → `(r, g, b)`(UI 侧构造颜色用)。 +#[must_use] +pub fn hex_to_rgb(text: &str) -> Option<(u8, u8, u8)> { + let hex = normalize_hex(text)?; + let byte = |range: std::ops::Range| u8::from_str_radix(&hex[range], 16).ok(); + Some((byte(1..3)?, byte(3..5)?, byte(5..7)?)) +} + +/// 相对亮度(WCAG 2.x,`0.0`~`1.0`):设置页「对比度检查提示」用它判断前景是否够读。 +#[must_use] +pub fn relative_luminance(text: &str) -> Option { + let (r, g, b) = hex_to_rgb(text)?; + let channel = |value: u8| { + let value = f64::from(value) / 255.0; + if value <= 0.039_28 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } + }; + Some(0.2126f64.mul_add( + channel(r), + 0.7152f64.mul_add(channel(g), 0.0722 * channel(b)), + )) +} + +fn clamp(value: f64, min: f64, max: f64) -> f64 { + if value.is_nan() { + min + } else { + value.clamp(min, max) + } +} diff --git a/crates/core/src/setting/catalog.rs b/crates/core/src/setting/catalog.rs new file mode 100644 index 0000000..2535618 --- /dev/null +++ b/crates/core/src/setting/catalog.rs @@ -0,0 +1,195 @@ +//! 一份配置文件的键目录:[`SettingSchema`] 把 key → 元数据查表能力收拢在一处。 +//! +//! 与 [`super::spec`] 的分工:`spec` 定义「一个键长什么样」,这里定义「一份文件的键集合」, +//! 并对外提供 AI 白名单 / 校验 / 工具 JSON Schema 投影(M16 `ToolSpec` 直接吃这份投影)。 + +use std::collections::BTreeSet; + +use serde_json::{json, Map, Value}; + +use super::error::{SettingError, SettingResult}; +use super::schema::{SchemaId, SchemaRef, SchemaVersion}; +use super::spec::{Sensitivity, SettingSpec}; + +/// 一份配置文件里所有键的集合。 +#[derive(Debug, Clone, PartialEq)] +pub struct SettingSchema { + id: SchemaId, + version: SchemaVersion, + specs: Vec, +} + +impl SettingSchema { + /// 空 schema。 + #[must_use] + pub fn new(id: SchemaId, version: SchemaVersion) -> Self { + Self { + id, + version, + specs: Vec::new(), + } + } + + /// 链式加一个键(重复键直接 panic —— 这是编译期就该发现的表写错)。 + #[must_use] + pub fn with(mut self, spec: SettingSpec) -> Self { + assert!( + !self.specs.iter().any(|existing| existing.key == spec.key), + "重复的配置键: {}", + spec.key + ); + self.specs.push(spec); + self + } + + /// 种类。 + #[must_use] + pub const fn id(&self) -> SchemaId { + self.id + } + + /// 版本。 + #[must_use] + pub const fn version(&self) -> SchemaVersion { + self.version + } + + /// `$schema` 标识。 + #[must_use] + pub const fn schema_ref(&self) -> SchemaRef { + SchemaRef::new(self.id, self.version) + } + + /// 全部键元数据。 + #[must_use] + pub fn specs(&self) -> &[SettingSpec] { + &self.specs + } + + /// 按键名取元数据。 + #[must_use] + pub fn spec(&self, key: &str) -> Option<&SettingSpec> { + self.specs.iter().find(|spec| spec.key == key) + } + + /// 是否定义了该键。 + #[must_use] + pub fn is_known(&self, key: &str) -> bool { + self.spec(key).is_some() + } + + /// 是否是某个已知键的**前缀路径**(用于区分「嵌套对象」与「陌生整棵子树」)。 + #[must_use] + pub fn has_prefix(&self, segments: &[&str]) -> bool { + let mut prefix = segments.join("."); + prefix.push('.'); + self.specs.iter().any(|spec| spec.key.starts_with(&prefix)) + } + + /// 默认值。 + #[must_use] + pub fn default_value(&self, key: &str) -> Option<&Value> { + self.spec(key).map(|spec| &spec.default) + } + + /// 校验一个键值对。 + /// + /// # Errors + /// 未定义的键 → [`SettingError::UnknownKey`];值不合法 → [`SettingError::InvalidValue`]。 + pub fn validate(&self, key: &str, value: &Value) -> SettingResult<()> { + let spec = self.spec(key).ok_or_else(|| SettingError::UnknownKey { + key: key.to_owned(), + })?; + spec.kind + .check(value) + .map_err(|reason| SettingError::InvalidValue { + key: key.to_owned(), + reason, + }) + } + + /// AI 可写键白名单(供 M16 `set_config` 前置检查)。 + #[must_use] + pub fn ai_writable_keys(&self) -> Vec<&'static str> { + self.specs + .iter() + .filter(|spec| spec.ai.writable && !spec.is_deprecated()) + .map(|spec| spec.key) + .collect() + } + + /// AI 是否允许写该键(未定义的键 = 不允许)。 + #[must_use] + pub fn accepts_ai_write(&self, key: &str) -> bool { + self.spec(key) + .is_some_and(|spec| spec.ai.writable && !spec.is_deprecated()) + } + + /// 已废弃键清单(只读兼容,不再落盘)。 + #[must_use] + pub fn deprecated_keys(&self) -> Vec<&'static str> { + self.specs + .iter() + .filter(|spec| spec.is_deprecated()) + .map(|spec| spec.key) + .collect() + } + + /// 某键的替代键(不是废弃键则 `None`)。 + #[must_use] + pub fn replacement_of(&self, key: &str) -> Option<&'static str> { + self.spec(key)?.deprecated.map(|info| info.replaced_by) + } + + /// 敏感分级。 + #[must_use] + pub fn sensitivity(&self, key: &str) -> Option { + self.spec(key).map(|spec| spec.ai.sensitivity) + } + + /// 顶层键名集合(`download.concurrency` → `download`)。 + #[must_use] + pub fn top_level_keys(&self) -> BTreeSet<&'static str> { + self.specs + .iter() + .filter_map(|spec| spec.key.split('.').next()) + .collect() + } + + /// 生成给 AI 工具用的 JSON Schema(只含 AI 可写键)。 + #[must_use] + pub fn to_json_schema(&self) -> Value { + let mut properties = Map::new(); + for spec in self + .specs + .iter() + .filter(|spec| spec.ai.writable && !spec.is_deprecated()) + { + let mut property = match spec.kind.to_json_schema() { + Value::Object(map) => map, + other => { + let mut map = Map::new(); + map.insert("schema".to_owned(), other); + map + } + }; + property.insert("description".to_owned(), Value::from(spec.doc)); + property.insert( + "x-ai-sensitivity".to_owned(), + Value::from(spec.ai.sensitivity.as_str()), + ); + if !spec.default.is_null() { + property.insert("default".to_owned(), spec.default.clone()); + } + properties.insert(spec.key.to_owned(), Value::Object(property)); + } + + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": format!("ull.{}", self.id.as_str()), + "type": "object", + "additionalProperties": false, + "properties": properties, + }) + } +} diff --git a/crates/core/src/setting/document.rs b/crates/core/src/setting/document.rs new file mode 100644 index 0000000..f59d050 --- /dev/null +++ b/crates/core/src/setting/document.rs @@ -0,0 +1,399 @@ +//! 配置文档模型:嵌套点路径取值、脏字段追踪、未知字段保留、`$schema` 保留。 +//! +//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.2-§1.3(HMCL `DirtyTracker` + +//! `unknownFields` + `RawPreservingProperty`)与 `DevDocs/PlanDoc/04-data-and-config.md` §3。 +//! +//! 三个关键取舍: +//! 1. **默认值不进文件**——只有「文件里读到的键」和「本次显式设过的键」会出现在落盘视图里, +//! 因此改默认值不需要写迁移代码; +//! 2. **未知字段整棵保留**——旧版本打开新版本写的文件不会丢配置; +//! 3. **坏值原样保留**——单个键解析/校验失败只记 `LoadProblem`,不丢数据、不整文件报废。 + +use std::collections::BTreeSet; +use std::fmt; + +use serde_json::{Map, Value}; + +use super::catalog::SettingSchema; +use super::error::{SettingError, SettingResult}; +use super::json_path::{collect_unknown, flatten, insert_at, lookup, merge_maps, segments}; +use super::schema::{ + SchemaCompat, SchemaRef, SchemaVersion, SCHEMA_KEY, SCHEMA_URL_PREFIX, SCHEMA_VERSION_KEY, +}; + +/// 落盘视图模式。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RenderMode { + /// 完整视图:`$schema` + `schemaVersion` + 已读/已设键 + 未知键(保存用,无损)。 + Full, + /// 只含本次脏字段(AI 增量 / IPC 同步 / 审计用)。 + DirtyOnly, +} + +/// 加载时发现的问题(都不致命,配置照常用)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoadProblem { + /// 文件里有、schema 不认识的键(整棵子树原样保留)。 + UnknownKey { + /// 点分键;陌生子树按**最上层**节点报一次(不做逐叶展开)。 + key: String, + }, + /// 已知键但值不合法(原样保留,等用户/AI 修)。 + InvalidValue { + /// 点分键。 + key: String, + /// 原因。 + reason: String, + }, + /// 已知键的父路径类型不符(如 `download` 本该是对象却是字符串)。 + InvalidShape { + /// 点分键。 + key: String, + /// 原因。 + reason: String, + }, + /// `schemaVersion` 镜像与 `$schema` 尾部不一致(以 `$schema` 为准)。 + SchemaVersionMismatch { + /// 文件里写的整数。 + declared: u64, + /// `$schema` 推出的 major。 + expected: u32, + }, + /// 文件用的是已废弃键:值已按映射带到替代键,下次保存只写替代键。 + DeprecatedKey { + /// 已废弃的键。 + key: String, + /// 替代键。 + replaced_by: &'static str, + }, + /// `$schema` 本身的状态(缺失 / 非法 / 更高版本等)。 + Schema(SchemaCompat), + /// 有 `$schema` 字符串但认不出前缀(M40:值照读、文件按只读,不再静默回落默认值)。 + UnknownSchema { + /// 文件里写的原串。 + url: String, + }, +} + +impl fmt::Display for LoadProblem { + /// 人读一行(CLI 的 `problems` 通道、日志与设置页共用一份文案)。 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownKey { key } => write!(f, "{key}:schema 不认识的键(已原样保留)"), + Self::InvalidValue { key, reason } => write!(f, "{key}:值不合法({reason}),已保留"), + Self::InvalidShape { key, reason } => write!(f, "{key}:结构不符({reason})"), + Self::SchemaVersionMismatch { declared, expected } => { + write!( + f, + "schemaVersion={declared} 不是 $schema 的 major {expected}" + ) + } + Self::DeprecatedKey { key, replaced_by } => write!(f, "{key}:改用 {replaced_by}"), + Self::Schema(compat) => write!(f, "$schema 状态:{}", compat.as_str()), + Self::UnknownSchema { url } => { + write!(f, "$schema 认不出「{url}」,前缀需为 {SCHEMA_URL_PREFIX}") + } + } + } +} + +/// 一份配置文件的内存视图。 +#[derive(Debug, Clone)] +pub struct SettingDocument { + schema: SettingSchema, + version: SchemaVersion, + values: Map, + /// 已废弃键的原始值:只供诊断/UI 提示,**永远不落盘**。 + legacy: Map, + /// 废弃键(点分)清单。 + legacy_keys: BTreeSet, + dirty: BTreeSet, + unknown: Map, + unknown_keys: BTreeSet, + preserve_schema: Option, +} + +impl SettingDocument { + /// 空文档(全部走默认值),版本取 schema 版本。 + #[must_use] + pub fn new(schema: SettingSchema) -> Self { + let version = schema.version(); + Self { + schema, + version, + values: Map::new(), + legacy: Map::new(), + legacy_keys: BTreeSet::new(), + dirty: BTreeSet::new(), + unknown: Map::new(), + unknown_keys: BTreeSet::new(), + preserve_schema: None, + } + } + + /// 从文件内容加载;返回文档与问题清单(永不失败)。 + #[must_use] + pub fn load(schema: &SettingSchema, raw: &Value) -> (Self, Vec) { + let mut problems = Vec::new(); + let mut document = Self::new(schema.clone()); + + let original_schema = raw + .get(SCHEMA_KEY) + .and_then(Value::as_str) + .map(str::to_owned); + let compat = match &original_schema { + Some(url) => SchemaCompat::check_url(url, schema.schema_ref()), + None => SchemaCompat::Missing, + }; + if compat.preserve_schema() { + document.preserve_schema = original_schema.clone(); + } + // 认得 `$schema` 键、但前缀解析不出来:别静默按「没有声明」装默认值。 + if let (SchemaCompat::Invalid, Some(url)) = (compat, original_schema.clone()) { + problems.push(LoadProblem::UnknownSchema { url }); + } + if let Some(parsed) = original_schema.as_deref().and_then(SchemaRef::parse_url) { + document.version = parsed.version; + } + if let Some(declared) = raw.get(SCHEMA_VERSION_KEY).and_then(Value::as_u64) { + let expected = document.version.major; + if declared != u64::from(expected) { + problems.push(LoadProblem::SchemaVersionMismatch { declared, expected }); + } + } + + let Some(object) = raw.as_object() else { + if !raw.is_null() { + problems.push(LoadProblem::Schema(compat)); + } + return (document, problems); + }; + + for spec in schema.specs() { + let path = segments(spec.key); + let Some(value) = lookup(object, &path) else { + continue; + }; + + if let Some(deprecation) = spec.deprecated { + // 废弃键:原文进 legacy(不落盘),值按映射带到替代键(新键已在则新键优先)。 + insert_at(&mut document.legacy, &path, value.clone()); + problems.push(LoadProblem::DeprecatedKey { + key: spec.key.to_owned(), + replaced_by: deprecation.replaced_by, + }); + let replacement = segments(deprecation.replaced_by); + if lookup(object, &replacement).is_none() { + let translated = deprecation.translate(value); + if schema + .validate(deprecation.replaced_by, &translated) + .is_err() + { + problems.push(LoadProblem::InvalidValue { + key: deprecation.replaced_by.to_owned(), + reason: format!("旧键 {} 的值 {value} 无法映射到新键", spec.key), + }); + } + insert_at(&mut document.values, &replacement, translated); + } + continue; + } + + if let Err(err) = schema.validate(spec.key, value) { + problems.push(LoadProblem::InvalidValue { + key: spec.key.to_owned(), + reason: err.to_string(), + }); + } + insert_at(&mut document.values, &path, value.clone()); + } + + document.legacy_keys = flatten(&document.legacy); + + let unknown = collect_unknown(&[], object, schema, &mut problems); + document.unknown_keys = flatten(&unknown); + document.unknown = unknown; + + (document, problems) + } + + /// 键表。 + #[must_use] + pub const fn schema(&self) -> &SettingSchema { + &self.schema + } + + /// 文档版本(来自文件 `$schema`;迁移后用 [`Self::set_version`] 推进)。 + #[must_use] + pub const fn version(&self) -> SchemaVersion { + self.version + } + + /// 设置文档版本(迁移链推进后调用)。 + pub const fn set_version(&mut self, version: SchemaVersion) { + self.version = version; + } + + /// 文件里显式出现过的值(不含默认值)。 + #[must_use] + pub fn get(&self, key: &str) -> Option<&Value> { + let path = segments(key); + lookup(&self.values, &path).or_else(|| lookup(&self.legacy, &path)) + } + + /// 已废弃键在文件里的原文(仅供诊断/UI 提示;不参与落盘)。 + #[must_use] + pub fn legacy_value(&self, key: &str) -> Option<&Value> { + lookup(&self.legacy, &segments(key)) + } + + /// 废弃键清单(文件里真的出现过的)。 + pub fn legacy_keys(&self) -> impl Iterator { + self.legacy_keys.iter().map(String::as_str) + } + + /// 生效值:文件值 → schema 默认值(默认值为 `null` 视为未设置)。 + #[must_use] + pub fn effective(&self, key: &str) -> Option<&Value> { + self.get(key) + .or_else(|| self.schema.default_value(key)) + .filter(|value| !value.is_null()) + } + + /// 生效布尔值。 + #[must_use] + pub fn effective_bool(&self, key: &str) -> Option { + self.effective(key).and_then(Value::as_bool) + } + + /// 生效整数。 + #[must_use] + pub fn effective_i64(&self, key: &str) -> Option { + self.effective(key).and_then(Value::as_i64) + } + + /// 生效浮点。 + #[must_use] + pub fn effective_f64(&self, key: &str) -> Option { + self.effective(key).and_then(Value::as_f64) + } + + /// 生效文本。 + #[must_use] + pub fn effective_str(&self, key: &str) -> Option<&str> { + self.effective(key).and_then(Value::as_str) + } + + /// 写一个键(走 schema 校验,并标脏)。 + /// + /// # Errors + /// 未定义的键或值不合法时返回 [`SettingError`]。 + pub fn set(&mut self, key: &str, value: Value) -> SettingResult<()> { + if let Some(replaced_by) = self.schema.replacement_of(key) { + return Err(SettingError::DeprecatedKey { + key: key.to_owned(), + replaced_by, + }); + } + self.schema.validate(key, &value)?; + insert_at(&mut self.values, &segments(key), value); + self.dirty.insert(key.to_owned()); + Ok(()) + } + + /// 是否有未落盘的改动。 + #[must_use] + pub fn is_dirty(&self) -> bool { + !self.dirty.is_empty() + } + + /// 脏键清单。 + pub fn dirty_keys(&self) -> impl Iterator { + self.dirty.iter().map(String::as_str) + } + + /// 把当前所有已读/已设键全部标脏(迁移后强制回写用)。 + /// + /// 废弃键跳过:它们只做读兼容、不落盘,不需要回写。 + pub fn mark_all_dirty(&mut self) { + for spec in self.schema.specs() { + if spec.is_deprecated() { + continue; + } + if self.get(spec.key).is_some() { + self.dirty.insert(spec.key.to_owned()); + } + } + } + + /// 清脏(保存成功后调用)。 + pub fn mark_clean(&mut self) { + self.dirty.clear(); + } + + /// 未知键(点分)清单。 + pub fn unknown_keys(&self) -> impl Iterator { + self.unknown_keys.iter().map(String::as_str) + } + + /// 文件里原有的 `$schema`(仅 `preserve_schema` 状态下有值)。 + #[must_use] + pub fn preserve_schema(&self) -> Option<&str> { + self.preserve_schema.as_deref() + } + + /// 显式指定保存时沿用的 `$schema` 字符串。 + pub fn set_preserve_schema(&mut self, url: Option) { + self.preserve_schema = url; + } + + /// 落盘视图。 + #[must_use] + pub fn render(&self, mode: RenderMode) -> Value { + let mut root = Map::new(); + root.insert(SCHEMA_KEY.to_owned(), Value::String(self.schema_url())); + root.insert( + SCHEMA_VERSION_KEY.to_owned(), + Value::from(self.version.major), + ); + match mode { + RenderMode::Full => { + merge_maps(&mut root, &self.values); + merge_maps(&mut root, &self.unknown); + } + RenderMode::DirtyOnly => { + let patch = self.dirty_patch_map(); + merge_maps(&mut root, &patch); + } + } + Value::Object(root) + } + + /// 完整落盘视图(等价 `render(RenderMode::Full)`)。 + #[must_use] + pub fn to_value(&self) -> Value { + self.render(RenderMode::Full) + } + + /// 只含脏字段的嵌套对象。 + #[must_use] + pub fn dirty_patch(&self) -> Value { + Value::Object(self.dirty_patch_map()) + } + + fn dirty_patch_map(&self) -> Map { + let mut patch = Map::new(); + for key in &self.dirty { + if let Some(value) = lookup(&self.values, &segments(key)) { + insert_at(&mut patch, &segments(key), value.clone()); + } + } + patch + } + + fn schema_url(&self) -> String { + self.preserve_schema + .clone() + .unwrap_or_else(|| self.schema.schema_ref().url()) + } +} diff --git a/crates/core/src/setting/error.rs b/crates/core/src/setting/error.rs new file mode 100644 index 0000000..186a29b --- /dev/null +++ b/crates/core/src/setting/error.rs @@ -0,0 +1,121 @@ +//! setting 层错误类型与结果别名。 + +use std::fmt; +use std::io; +use std::path::PathBuf; + +use crate::platform::error::PlatformError; + +use super::schema::{SchemaCompat, SchemaVersion}; + +/// setting 层的结果别名。 +pub type SettingResult = Result; + +/// 配置读写与迁移可能出的问题。 +#[derive(Debug)] +pub enum SettingError { + /// 文件系统操作失败。 + Io { + /// 出错路径。 + path: PathBuf, + /// 底层错误。 + source: io::Error, + }, + /// JSON 解析/序列化失败。 + Json { + /// 出错路径。 + path: PathBuf, + /// 底层错误。 + source: serde_json::Error, + }, + /// 文件是只读档(`READ_ONLY`),拒绝保存。 + ReadOnly { + /// 文件路径。 + path: PathBuf, + }, + /// 文件不可读档(`UNREADABLE`),全程不写。 + Unreadable { + /// 文件路径。 + path: PathBuf, + /// 判定依据。 + compat: SchemaCompat, + }, + /// schema 里没有这个键(AI 白名单外的键也走这里)。 + UnknownKey { + /// 键。 + key: String, + }, + /// 值不合法(类型/范围/枚举)。 + InvalidValue { + /// 键。 + key: String, + /// 原因。 + reason: String, + }, + /// 键已废弃,写必须走替代键。 + DeprecatedKey { + /// 用户/调用方给的旧键。 + key: String, + /// 替代键。 + replaced_by: &'static str, + }, + /// 迁移链断链/缺环/顺序错。 + ChainBroken { + /// 原因。 + reason: String, + }, + /// 单步迁移失败。 + Migration { + /// 起始版本。 + from: SchemaVersion, + /// 目标版本。 + to: SchemaVersion, + /// 原因。 + reason: String, + }, + /// platform 层错误透传。 + Platform(PlatformError), +} + +impl fmt::Display for SettingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { path, source } => write!(f, "IO 失败 @ {}: {source}", path.display()), + Self::Json { path, source } => write!(f, "JSON 失败 @ {}: {source}", path.display()), + Self::ReadOnly { path } => write!(f, "文件只读,拒绝保存: {}", path.display()), + Self::Unreadable { path, compat } => write!( + f, + "文件不可读({}),全程不写: {}", + compat.as_str(), + path.display() + ), + Self::UnknownKey { key } => write!(f, "schema 未定义该键: {key}"), + Self::InvalidValue { key, reason } => write!(f, "值不合法 [{key}]: {reason}"), + Self::DeprecatedKey { key, replaced_by } => { + write!(f, "键 {key} 已废弃,请改用 {replaced_by}") + } + Self::ChainBroken { reason } => write!(f, "迁移链不完整: {reason}"), + Self::Migration { from, to, reason } => { + write!(f, "迁移 {from} → {to} 失败: {reason}") + } + Self::Platform(err) => write!(f, "{err}"), + } + } +} + +impl std::error::Error for SettingError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io { source, .. } => Some(source), + Self::Json { source, .. } => Some(source), + Self::Platform(err) => Some(err), + _ => None, + } + } +} + +impl From for SettingError { + fn from(err: PlatformError) -> Self { + Self::Platform(err) + } +} diff --git a/crates/core/src/setting/instance_keys.rs b/crates/core/src/setting/instance_keys.rs new file mode 100644 index 0000000..aaa7948 --- /dev/null +++ b/crates/core/src/setting/instance_keys.rs @@ -0,0 +1,155 @@ +//! `config/profiles.json` 与 `config/instances/.json` 的键表。 +//! +//! 实例模型预告见 `DevDocs/PlanDoc/04-data-and-config.md` §4: +//! 每实例 = 一个 `.minecraft` 目录绑定 + 独立设置覆盖链(全局 → 实例 → 临时启动参数), +//! 实例 id 用稳定 UUID(不用路径——路径会变)。 +//! +//! # 实例文件的形状(D20 定案,research/04 §5.1) +//! +//! 实例文件是**稀疏 camelCase 扁平结构**,与 `game::install::instance::InstanceConfig` +//! 的 serde 形状逐键一致(那份结构才是写入者,本文件是它的 schema 白名单视图): +//! +//! ```jsonc +//! { +//! "schemaVersion": 1, +//! "id": "", +//! "versionId": "1.20.1", +//! "isolation": "custom", // 缺键 = inherit(跟随全局,不落盘) +//! "gameDir": "D:/mcs/farm", // 仅 custom 时有值 +//! "overrides": ["maxMemory"], +//! "lastModeChange": { "at": "2026-10-30T12:00:00Z", "from": "shared", "to": "isolated", +//! "probe": { "isolatedSide": 0, "sharedSide": 12 }, "warned": true } +//! } +//! ``` +//! +//! 三条不变量: +//! 1. `isolation` 默认 `inherit` 且**不落盘**——改全局模式对存量实例立即生效 +//! (`document` 的「默认值不进文件」保证这一点,直接避开 PCL2 的默认值固化缺陷); +//! 显式写 `"isolation": "inherit"` 也合法:`InstanceConfig` 的反序列化把它归一成 +//! 「键不存在」(等价语义,不会出现两层读写回环不一致); +//! 2. `isolation`/`lastModeChange` 由用户(前者)与启动器(后者)维护,**AI 不可写**; +//! 3. 派生的绝对路径不入档:`isolated` 只是「运行目录 = `versions/`」的语法糖, +//! 派生实现归 `game::isolation`。 + +use super::catalog::SettingSchema; +use super::schema::{SchemaId, SchemaVersion}; +use super::spec::{AiPolicy, SettingSpec}; + +/// 实例级 `isolation` 允许值(`inherit` = 键缺失,跟随全局 `instances.mode`)。 +pub const ISOLATION_OVERRIDES: &[&str] = &["inherit", "isolated", "shared", "custom"]; +/// 切换检测记录里 `from`/`to` 的允许值(`inherit` 不是生效档位,故不在表里)。 +pub const ISOLATION_RESOLVED: &[&str] = &["isolated", "shared", "custom"]; +/// 实例可覆盖的设置键(enum 白名单,拼错即校验失败而不是静默不生效)。 +pub const OVERRIDE_KEYS: &[&str] = &["maxMemory", "javaType", "runningDirectory"]; + +/// profiles.json 当前 schema 版本。 +#[must_use] +pub fn profiles_version() -> SchemaVersion { + SchemaVersion::new(1, 0, 0) +} + +/// instances/.json 当前 schema 版本。 +/// +/// `1.1.0` 登记 D20 的 `isolation` 档位(`inherit`/`isolated`/`shared`/`custom`)与 +/// `lastModeChange` 切换检测键;老文件缺键即默认值,不需要迁移步骤(major 未变)。 +#[must_use] +pub fn instance_version() -> SchemaVersion { + SchemaVersion::new(1, 1, 0) +} + +/// `config/profiles.json` 的 schema。 +#[must_use] +pub fn profiles_schema() -> SettingSchema { + SettingSchema::new(SchemaId::PROFILES, profiles_version()) + .with(SettingSpec::text( + "selected_profile_id", + "", + 64, + AiPolicy::audited(), + "上次选中的实例 id", + )) + .with(SettingSpec::any( + "profiles", + AiPolicy::locked(), + "实例档案列表(数据而非配置,由 game 模块维护;AI 不得整体改写)", + )) +} + +/// `config/instances/.json` 的 schema。 +#[must_use] +pub fn instance_schema() -> SettingSchema { + SettingSchema::new(SchemaId::INSTANCE, instance_version()) + .with(SettingSpec::text( + "id", + "", + 64, + AiPolicy::locked(), + "实例稳定 id(UUID,与文件名一致;不允许改写身份)", + )) + .with(SettingSpec::text( + "versionId", + "", + 128, + AiPolicy::audited(), + "绑定的版本 id(versions/)", + )) + .with(SettingSpec::enumeration( + "isolation", + "inherit", + ISOLATION_OVERRIDES, + AiPolicy::sensitive(), + "实例隔离档位:inherit(跟随全局 instances.mode,不落盘)/ isolated(运行目录派生 versions/)/ shared(共用 .minecraft 根)/ custom(用 gameDir)", + )) + .with(SettingSpec::path( + "gameDir", + AiPolicy::sensitive(), + "自定义运行目录(仅 isolation=custom 生效)", + )) + .with(SettingSpec::enum_list( + "overrides", + OVERRIDE_KEYS, + AiPolicy::locked(), + "该实例覆盖了全局哪些设置键(enum 白名单,拼错即校验失败)", + )) + .with(SettingSpec::text( + "lastModeChange.at", + "", + 40, + AiPolicy::locked(), + "上次切换隔离档位的时间(RFC-3339,启动器写入)", + )) + .with(SettingSpec::enumeration( + "lastModeChange.from", + "isolated", + ISOLATION_RESOLVED, + AiPolicy::locked(), + "切换前的生效档位(启动器写入)", + )) + .with(SettingSpec::enumeration( + "lastModeChange.to", + "isolated", + ISOLATION_RESOLVED, + AiPolicy::locked(), + "切换后的生效档位(启动器写入)", + )) + .with(SettingSpec::int( + "lastModeChange.probe.isolatedSide", + 0, + (0, 1_000_000_000), + AiPolicy::locked(), + "切换时隔离侧(versions/)的内容条目数(只探测,不迁移)", + )) + .with(SettingSpec::int( + "lastModeChange.probe.sharedSide", + 0, + (0, 1_000_000_000), + AiPolicy::locked(), + "切换时共享侧(.minecraft 根)的内容条目数(只探测,不迁移)", + )) + .with(SettingSpec::bool( + "lastModeChange.warned", + false, + AiPolicy::locked(), + "该次切换的提示是否已展示过(保证切换后首次启动只提示一次)", + )) +} diff --git a/crates/core/src/setting/json_path.rs b/crates/core/src/setting/json_path.rs new file mode 100644 index 0000000..3cfaf93 --- /dev/null +++ b/crates/core/src/setting/json_path.rs @@ -0,0 +1,124 @@ +//! 点分键(`download.concurrency`)与嵌套 JSON 之间的最小工具集。 +//! +//! 单独成文件:这些函数是 [`super::document`] 的机械部件,和「配置语义」无关, +//! 也是容易出边界 bug(空路径、中间层不是对象)的地方。 + +use std::collections::BTreeSet; + +use serde_json::{Map, Value}; + +use super::catalog::SettingSchema; +use super::document::LoadProblem; +use super::schema::{SCHEMA_KEY, SCHEMA_VERSION_KEY}; + +/// 点分键 → 路径段。 +pub(crate) fn segments(key: &str) -> Vec<&str> { + key.split('.').filter(|part| !part.is_empty()).collect() +} + +/// 沿路径取嵌套值。 +pub(crate) fn lookup<'a>(root: &'a Map, path: &[&str]) -> Option<&'a Value> { + let (head, tail) = path.split_first()?; + let value = root.get(*head)?; + if tail.is_empty() { + Some(value) + } else { + lookup(value.as_object()?, tail) + } +} + +/// 沿路径写嵌套值(中间层不是对象则重建)。 +pub(crate) fn insert_at(root: &mut Map, path: &[&str], value: Value) { + let Some((head, tail)) = path.split_first() else { + return; + }; + if tail.is_empty() { + root.insert((*head).to_owned(), value); + return; + } + let entry = root + .entry((*head).to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + if !entry.is_object() { + *entry = Value::Object(Map::new()); + } + if let Some(child) = entry.as_object_mut() { + insert_at(child, tail, value); + } +} + +/// 深合并(`extra` 覆盖 `target` 的同名叶子)。 +pub(crate) fn merge_maps(target: &mut Map, extra: &Map) { + for (key, value) in extra { + if let Some(Value::Object(existing)) = target.get_mut(key) { + if let Some(incoming) = value.as_object() { + merge_maps(existing, incoming); + continue; + } + } + target.insert(key.clone(), value.clone()); + } +} + +/// 收集 schema 不认识的键(整棵保留),并把形状问题记进 `problems`。 +pub(crate) fn collect_unknown( + prefix: &[&str], + node: &Map, + schema: &SettingSchema, + problems: &mut Vec, +) -> Map { + let mut out = Map::new(); + for (key, value) in node { + if prefix.is_empty() && (key == SCHEMA_KEY || key == SCHEMA_VERSION_KEY) { + continue; + } + let mut path = prefix.to_vec(); + path.push(key.as_str()); + let dotted = path.join("."); + if schema.is_known(&dotted) { + continue; + } + if schema.has_prefix(&path) { + match value.as_object() { + Some(child) => { + let nested = collect_unknown(&path, child, schema, problems); + if !nested.is_empty() { + out.insert(key.clone(), Value::Object(nested)); + } + } + None => problems.push(LoadProblem::InvalidShape { + key: path.join("."), + reason: "期望对象(其下还有已知键)".to_owned(), + }), + } + continue; + } + problems.push(LoadProblem::UnknownKey { + key: path.join("."), + }); + out.insert(key.clone(), value.clone()); + } + out +} + +/// 把嵌套对象摊平成点分叶子集合。 +pub(crate) fn flatten(map: &Map) -> BTreeSet { + fn walk(prefix: &str, node: &Map, out: &mut BTreeSet) { + for (key, value) in node { + let dotted = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + match value.as_object() { + Some(child) if !child.is_empty() => walk(&dotted, child, out), + _ => { + out.insert(dotted); + } + } + } + } + let mut out = BTreeSet::new(); + walk("", map, &mut out); + out +} diff --git a/crates/core/src/setting/keys.rs b/crates/core/src/setting/keys.rs new file mode 100644 index 0000000..0a4b0ee --- /dev/null +++ b/crates/core/src/setting/keys.rs @@ -0,0 +1,352 @@ +//! `config/launcher.json` 的键表(全局设置)。 +//! +//! 键名与默认值对齐 `DevDocs/PlanDoc/04-data-and-config.md` §2-§3 与 `05/06/07/08` 各专题; +//! AI 可写 + 敏感分级按 `02-ai-agent-design.md` §3.1 的边界落:**密钥类一律 locked**。 + +use super::catalog::SettingSchema; +use super::schema::{SchemaId, SchemaVersion}; +use super::spec::{AiPolicy, SettingSpec}; + +/// `core.data_mode` 允许值。 +pub const DATA_MODES: &[&str] = &["portable", "standard", "custom"]; +/// `download.region` 允许值。 +pub const REGIONS: &[&str] = &["auto", "cn", "global"]; +/// `download.mirror_preference` 允许值。 +pub const MIRROR_PREFERENCES: &[&str] = &["auto", "official", "mirror"]; +/// `download.proxy_mode` 允许值。 +pub const PROXY_MODES: &[&str] = &["none", "system", "manual"]; +/// `ai.provider` 允许值。 +pub const AI_PROVIDERS: &[&str] = &["openai_compatible", "anthropic", "ollama", "disabled"]; +/// `ui.language` 允许值。 +pub const LANGUAGES: &[&str] = &["zh_cn", "en_us"]; +/// `appearance.theme_mode` 允许值(D17 定案:auto/light/dark)。 +pub const THEME_MODES: &[&str] = &["auto", "light", "dark"]; +/// `ui.theme` 的旧允许值(该键已废弃,仅读兼容,不再落盘)。 +pub const LEGACY_THEMES: &[&str] = &["system", "metro_light", "metro_dark"]; +/// 旧 `ui.theme` 值 → 新 `appearance.theme_mode` 值(tower 键冲突裁决的读兼容映射)。 +pub const THEME_ALIASES: &[(&str, &str)] = &[ + ("system", "auto"), + ("metro_light", "light"), + ("metro_dark", "dark"), +]; +/// `log.level` 允许值。 +pub const LOG_LEVELS: &[&str] = &["error", "warn", "info", "debug", "trace"]; +/// `instances.mode` 允许值(D20 定案:默认隔离,用户可手动切共享)。 +pub const INSTANCE_MODES: &[&str] = &["isolated", "shared"]; +/// `java.installRoot` 未设置时的哨兵(空串 = 按数据目录模式派生落点)。 +pub const JAVA_INSTALL_ROOT_UNSET: &str = ""; +/// `java.vendorPriority` 的允许值(D23:四家发行版,顺序即优先级)。 +/// +/// 与 `crate::game::java::vendor::VENDOR_IDS` 必须是同一份集合——setting 层不依赖 game 层 +/// (分层纪律),一致性由 `tests/setting/java_install_keys.rs` 的契约测试固定。 +pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"]; + +/// launcher.json 当前 schema 版本。 +/// +/// `1.1.0` 登记 D20 实例隔离键(`instances.mode` 等)。 +/// `1.2.0` 登记 D23 的 JDK 落点与发行版优先级键(`java.installRoot` / `java.vendorPriority`): +/// 同样只推 minor、不强制迁移——[`super::document`] 的「默认值不落盘」让老文件缺键即取默认, +/// 迁移链(按 major 走)不需要新步骤;`1.1.0 → 1.2.0` 的差异只体现在文件里出现新键之后。 +/// `1.3.0` 登记 D17 的「外观(高级)」P1 键(`appearance.bg_image*` / `panel_alpha` / `glass` / +/// `accent_color`):同一套纯增量语义——出厂全默认(高级特性全关)即等于老文件缺键。 +#[must_use] +pub fn launcher_version() -> SchemaVersion { + SchemaVersion::new(1, 3, 0) +} + +/// `config/launcher.json` 的 schema。 +#[must_use] +pub fn launcher_schema() -> SettingSchema { + SettingSchema::new(SchemaId::LAUNCHER, launcher_version()) + .with(SettingSpec::enumeration( + "core.data_mode", + "portable", + DATA_MODES, + AiPolicy::sensitive(), + "数据目录模式:portable(exe 旁)/ standard(系统标准目录)/ custom(自定义)", + )) + .with(SettingSpec::path( + "core.data_custom_root", + AiPolicy::sensitive(), + "custom 模式的根目录(必须是绝对路径)", + )) + .with(SettingSpec::path( + "core.launcher_home", + AiPolicy::sensitive(), + "显式覆盖启动器数据目录(.ULL 的等价物)", + )) + .with(SettingSpec::path( + "core.game_home", + AiPolicy::sensitive(), + "游戏目录(.minecraft),与数据目录分离指定", + )) + // D20(research/04 §5.1):只留一个全局默认档,实例级 override 在 + // `instance_keys::instance_schema`(`isolation`),派生逻辑在 `game::isolation`。 + .with(SettingSpec::enumeration( + "instances.mode", + "isolated", + INSTANCE_MODES, + AiPolicy::sensitive(), + "实例默认隔离模式:isolated(运行目录派生 versions/<版本 id>,D20 默认)/ shared(所有实例共用 .minecraft 根)", + )) + .with(SettingSpec::int( + "download.concurrency", + 8, + (1, 256), + AiPolicy::silent(), + "下载并发数", + )) + .with(SettingSpec::int( + "download.retry_limit", + 3, + (0, 10), + AiPolicy::silent(), + "单个文件重试次数", + )) + .with(SettingSpec::enumeration( + "download.region", + "auto", + REGIONS, + AiPolicy::audited(), + "下载地区判定:auto(时区+语言+探测)/ cn / global", + )) + .with(SettingSpec::enumeration( + "download.mirror_preference", + "auto", + MIRROR_PREFERENCES, + AiPolicy::audited(), + "镜像偏好:auto(健康度评分)/ official(官方源优先)/ mirror(镜像优先)", + )) + .with(SettingSpec::enumeration( + "download.proxy_mode", + "none", + PROXY_MODES, + AiPolicy::sensitive(), + "代理模式:none / system / manual", + )) + .with(SettingSpec::text( + "download.proxy_url", + "", + 512, + AiPolicy::sensitive(), + "manual 代理地址(如 http://127.0.0.1:7890)", + )) + .with(SettingSpec::path( + "java.selected_path", + AiPolicy::sensitive(), + "用户选定的 Java 可执行文件路径", + )) + .with(SettingSpec::bool( + "java.auto_install", + true, + AiPolicy::audited(), + "缺 Java 时自动下载(D13 基准 JDK17)", + )) + // D23:托管 JDK 的落点三模式。语义在 `game::java::location`: + // 未设置(空串)→ 按 `core.data_mode` 派生(Portable 数据根 / Standard 系统目录); + // 设置了 → 覆盖前两者,按 `/` 分层。 + .with(SettingSpec::text( + "java.installRoot", + JAVA_INSTALL_ROOT_UNSET, + 512, + AiPolicy::sensitive(), + "托管 JDK 的自定义安装根(留空 = 按数据目录模式:portable 进数据根、standard 进 %APPDATA%\\Java);设置后覆盖两者,JDK 落在 /<发行方>/<版本>/", + )) + // D23:发行版回退链的用户优先级。空数组 = 按地区默认序 + // (国内 Temurin→Oracle→Microsoft→Zulu;国外 Temurin→Microsoft→Oracle→Zulu)。 + .with(SettingSpec::enum_list( + "java.vendorPriority", + JDK_VENDORS, + AiPolicy::audited(), + "JDK 发行版优先级(temurin/oracle/microsoft/zulu);留空按地区默认序,只写几家则其余按默认序补齐", + )) + .with(SettingSpec::int( + "java.default_memory_mb", + 2048, + (512, 65536), + AiPolicy::silent(), + "默认分配给游戏的最大堆内存(MB)", + )) + .with(SettingSpec::text( + "java.extra_args", + "", + 1024, + AiPolicy::sensitive(), + "附加 JVM 参数(原样拼到命令行)", + )) + .with(SettingSpec::bool( + "ai.enabled", + true, + AiPolicy::silent(), + "启用内置 AI agent(关闭后规则引擎诊断仍可用)", + )) + .with(SettingSpec::enumeration( + "ai.provider", + "openai_compatible", + AI_PROVIDERS, + AiPolicy::sensitive(), + "LLM 提供方:openai_compatible(含本地 llama.cpp/DeepSeek/Qwen)/ anthropic / ollama / disabled", + )) + .with(SettingSpec::text( + "ai.base_url", + "", + 512, + AiPolicy::sensitive(), + "LLM 端点 base_url(OpenAI 兼容形态)", + )) + .with(SettingSpec::text( + "ai.model", + "", + 128, + AiPolicy::audited(), + "模型名(如 gpt-4o-mini / deepseek-chat)", + )) + .with(SettingSpec::bool( + "ai.yolo", + false, + AiPolicy::sensitive(), + "自动批准全部工具调用(审计照记,白名单不变)", + )) + .with(SettingSpec::new( + "ai.api_key", + super::spec::ValueKind::Text { max_len: 4096 }, + serde_json::Value::Null, + AiPolicy::locked(), + "LLM API 密钥——AI 永不读写的凭据(D9)", + )) + // M39(P3 MCP server,02 §0「传输定论」):对外 agent 工具服务。 + // 三个开关默认「关 / 只读」——外部客户端拿到的是与内置客户端**同一注册表**的工具, + // 但写权限必须用户显式打开(02 §1「同一注册表、同一安全策略、同一审计日志」)。 + .with(SettingSpec::bool( + "agent.mcp.enabled", + false, + AiPolicy::sensitive(), + "对外 MCP server 总开关(127.0.0.1 上的 Streamable HTTP;stdio 形态按需显式启动)", + )) + .with(SettingSpec::int( + "agent.mcp.port", + 47823, + (1024, 65535), + AiPolicy::sensitive(), + "MCP server 的 Streamable HTTP 端口(只绑 127.0.0.1)", + )) + .with(SettingSpec::bool( + "agent.mcp.allow_write", + false, + AiPolicy::sensitive(), + "允许外部 MCP 客户端调用写工具(默认只读;敏感键仍受分级确认与 YOLO 规则约束)", + )) + .with(SettingSpec::text( + "agent.mcp.token", + "", + 256, + AiPolicy::locked(), + "MCP server 本地访问令牌(Authorization: Bearer;空 = 不校验,仅绑 127.0.0.1)", + )) + .with(SettingSpec::enumeration( + "ui.language", + "zh_cn", + LANGUAGES, + AiPolicy::audited(), + "界面语言", + )) + .with(SettingSpec::enumeration( + "appearance.theme_mode", + "auto", + THEME_MODES, + AiPolicy::silent(), + "主题模式:auto(跟随系统)/ light(Metro 浅色)/ dark(Metro 深色)", + )) + // D17「外观(高级)」P1 四件(09 §101-104 / §111):**默认全部关闭**(出厂 Metro 纯色)。 + // 分级一律 audited(Medium):全部是纯视觉、可一键复原的键,没有安全面(不碰凭据/网络/ + // 数据根),谈不上 High 级确认;但 AI 改过什么要留审计痕迹,故不用 silent。 + .with(SettingSpec::bool( + "appearance.bg_image_enabled", + false, + AiPolicy::audited(), + "自定义窗口背景图开关(默认关;开启时设置页给出对比度提示)", + )) + .with(SettingSpec::text( + "appearance.bg_image", + "", + 512, + AiPolicy::audited(), + "背景图路径(PNG;空 = 未设置。Slint 默认解码器只带 PNG)", + )) + .with(SettingSpec::enumeration( + "appearance.bg_fill", + "cover", + super::appearance::BG_FILLS, + AiPolicy::audited(), + "背景图填充模式:cover(铺满裁切)/ contain(完整留底色)/ tile(平铺)", + )) + .with(SettingSpec::float( + "appearance.bg_dim", + 0.35, + ( + super::appearance::BG_DIM_MIN, + super::appearance::BG_DIM_MAX, + ), + AiPolicy::audited(), + "背景图叠加暗化层强度(越大前景越清晰;09 §101 的对比度保底手法)", + )) + .with(SettingSpec::float( + "appearance.panel_alpha", + 1.0, + (super::appearance::PANEL_ALPHA_MIN, 1.0), + AiPolicy::audited(), + "全局面板半透明度(卡片/导航/AI 栏统一透明度;1.0 = 出厂不透明)", + )) + .with(SettingSpec::bool( + "appearance.glass", + false, + AiPolicy::audited(), + "玻璃拟态卡片(背景图预生成模糊层作卡片底;Slint 无 backdrop-filter 的替代)", + )) + .with(SettingSpec::text( + "appearance.accent_color", + "", + 9, + AiPolicy::audited(), + "自定义强调色 #RRGGBB(空 = 出厂 #0078D4;accent 令牌全域生效)", + )) + // 已废弃:D17 定案 appearance.theme_mode 为准(旧值读兼容映射,写永远走新键)。 + .with( + SettingSpec::new( + "ui.theme", + super::spec::ValueKind::Enum(LEGACY_THEMES), + serde_json::Value::Null, + AiPolicy::retired(), + "【已废弃】改用 appearance.theme_mode;旧值读兼容:system→auto / metro_light→light / metro_dark→dark", + ) + .deprecated("appearance.theme_mode", THEME_ALIASES), + ) + .with(SettingSpec::float( + "ui.scale", + 1.0, + (0.75, 2.0), + AiPolicy::silent(), + "界面缩放系数", + )) + .with(SettingSpec::int( + "ui.window.width", + 1280, + (320, 16384), + AiPolicy::silent(), + "主窗口宽度(逻辑像素)", + )) + .with(SettingSpec::int( + "ui.window.height", + 800, + (200, 16384), + AiPolicy::silent(), + "主窗口高度(逻辑像素)", + )) + .with(SettingSpec::enumeration( + "log.level", + "info", + LOG_LEVELS, + AiPolicy::audited(), + "启动器日志级别", + )) +} diff --git a/crates/core/src/setting/migrate.rs b/crates/core/src/setting/migrate.rs new file mode 100644 index 0000000..03bd13a --- /dev/null +++ b/crates/core/src/setting/migrate.rs @@ -0,0 +1,222 @@ +//! 迁移链:`schemaVersion N → N+1` 顺序迁移框架 + 收据闸门(防重放)。 +//! +//! 出处:`DevDocs/PlanDoc/04-data-and-config.md` §3(迁移链 + 迁移收据)、 +//! `DevDocs/analyse/07-settings-packaging-update.md` §1.8(收据存源文件 `sourceSHA256`)。 +//! +//! 与 HMCL 的差别:HMCL 完全靠 `$schema` 字段做兼容、迁移只处理遗留格式;ULL 在这之上补一条 +//! 「major 逐级迁移链」——每一步只跨一个 major,缺环/重复/逆序在 `push` 时就报错, +//! 避免出现「跳版迁移脚本」这种组合爆炸。 + +use std::path::Path; + +use serde_json::{Map, Value}; + +use crate::platform::error::PlatformResult; +use crate::platform::receipt::{self, MigrationReceipt, ReplayDecision}; + +use super::error::{SettingError, SettingResult}; +use super::schema::{SchemaVersion, SCHEMA_VERSION_KEY}; + +/// 一步迁移。实现者只管把 `root` 从 `from` 改造成 `to`。 +pub trait SettingMigration: Send + Sync { + /// 起始版本。 + fn from(&self) -> SchemaVersion; + /// 目标版本(必须等于 `from.next_major()`)。 + fn to(&self) -> SchemaVersion; + /// 一句话说明(进日志与迁移报告)。 + fn describe(&self) -> &'static str; + /// 就地改 JSON 对象;`Err(String)` 会被包成 [`SettingError::Migration`]。 + /// + /// # Errors + /// 结构不符合预期(该有的键没有/类型不对)时返回原因。 + fn apply(&self, root: &mut Map) -> Result<(), String>; +} + +/// 迁移报告(进日志 / 遥测 / 收据)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MigrationReport { + /// 起始版本。 + pub from: SchemaVersion, + /// 结束版本。 + pub to: SchemaVersion, + /// 依次执行的步骤说明。 + pub applied: Vec<&'static str>, +} + +impl MigrationReport { + /// 是否真的做了迁移。 + #[must_use] + pub fn is_noop(&self) -> bool { + self.applied.is_empty() + } +} + +/// 有序迁移链。 +#[derive(Default)] +pub struct MigrationChain { + steps: Vec>, +} + +impl std::fmt::Debug for MigrationChain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_list() + .entries(self.steps.iter().map(|step| step.describe())) + .finish() + } +} + +impl MigrationChain { + /// 空链。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 追加一步;必须与上一环首尾相接,且只跨一个 major。 + /// + /// # Errors + /// 跨多个 major、起始版本重复、顺序错乱时返回 [`SettingError::ChainBroken`]。 + pub fn push(&mut self, step: Box) -> SettingResult<()> { + let from = step.from(); + let to = step.to(); + if to != from.next_major() { + return Err(SettingError::ChainBroken { + reason: format!("一步迁移只能跨一个 major:{from} → {to}"), + }); + } + if self.steps.iter().any(|existing| existing.from() == from) { + return Err(SettingError::ChainBroken { + reason: format!("{from} 已有迁移步骤"), + }); + } + if let Some(last) = self.steps.last() { + if last.to() != from { + return Err(SettingError::ChainBroken { + reason: format!("顺序错误:上一环到 {},本环从 {from}", last.to()), + }); + } + } + self.steps.push(step); + Ok(()) + } + + /// 步骤数。 + #[must_use] + pub fn len(&self) -> usize { + self.steps.len() + } + + /// 是否没有步骤。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.steps.is_empty() + } + + /// 步骤说明清单(日志用)。 + #[must_use] + pub fn describe(&self) -> Vec<&'static str> { + self.steps.iter().map(|step| step.describe()).collect() + } + + /// 链能达到的最新版本。 + #[must_use] + pub fn latest(&self) -> Option { + self.steps.last().map(|step| step.to()) + } + + /// 从 `from` 起要执行的步骤(含 `from` 那一步)。 + /// + /// # Errors + /// 链里没有以 `from` 开头的步骤(版本不认识)时返回 [`SettingError::ChainBroken`]。 + pub fn plan_from(&self, from: SchemaVersion) -> SettingResult> { + let start = self + .steps + .iter() + .position(|step| step.from() == from) + .ok_or_else(|| SettingError::ChainBroken { + reason: format!("链里没有以 {from} 起始的迁移步骤"), + })?; + Ok(self.steps[start..] + .iter() + .map(|step| step.as_ref()) + .collect()) + } + + /// 顺序执行迁移,并把 `schemaVersion` 镜像更新成最终 major。 + /// + /// # Errors + /// 缺环 → [`SettingError::ChainBroken`];某步失败 → [`SettingError::Migration`]。 + pub fn migrate( + &self, + from: SchemaVersion, + root: &mut Map, + ) -> SettingResult { + let plan = self.plan_from(from)?; + let mut current = from; + let mut applied = Vec::with_capacity(plan.len()); + for step in plan { + step.apply(root).map_err(|reason| SettingError::Migration { + from: current, + to: step.to(), + reason, + })?; + current = step.to(); + applied.push(step.describe()); + } + root.insert(SCHEMA_VERSION_KEY.to_owned(), Value::from(current.major)); + Ok(MigrationReport { + from, + to: current, + applied, + }) + } +} + +/// 收据闸门:迁移前问一句「这次该不该跑」。 +#[derive(Debug, Clone, Copy)] +pub struct MigrationGate<'a> { + receipt_path: &'a Path, + now_ms: u64, +} + +impl<'a> MigrationGate<'a> { + /// 绑定收据路径与当前时间(毫秒 since UNIX_EPOCH,注入便于测试)。 + #[must_use] + pub const fn new(receipt_path: &'a Path, now_ms: u64) -> Self { + Self { + receipt_path, + now_ms, + } + } + + /// 当前时间。 + #[must_use] + pub const fn now_ms(&self) -> u64 { + self.now_ms + } + + /// 读收据并判断是否重放。 + /// + /// # Errors + /// 收据损坏或读失败时返回 [`crate::platform::error::PlatformError`]。 + pub fn evaluate(&self, source_fingerprint: &str) -> PlatformResult { + let existing = receipt::read(self.receipt_path)?; + Ok(receipt::decide(existing.as_ref(), source_fingerprint)) + } + + /// 是否该执行迁移(收据在且源指纹相同 → `false`)。 + /// + /// # Errors + /// 同 [`Self::evaluate`]。 + pub fn should_run(&self, source_fingerprint: &str) -> PlatformResult { + Ok(self.evaluate(source_fingerprint)?.should_replay()) + } + + /// 记录一次迁移收据。 + /// + /// # Errors + /// 写盘失败时返回 [`crate::platform::error::PlatformError`]。 + pub fn record(&self, receipt: &MigrationReceipt) -> PlatformResult<()> { + receipt::write(self.receipt_path, receipt) + } +} diff --git a/crates/core/src/setting/mod.rs b/crates/core/src/setting/mod.rs new file mode 100644 index 0000000..f968aae --- /dev/null +++ b/crates/core/src/setting/mod.rs @@ -0,0 +1,36 @@ +//! 配置与数据目录:三模式数据根(Portable 默认 / Standard / Custom)、schema 化配置、原子写、迁移收据。 +//! +//! 设计定稿:`DevDocs/PlanDoc/04-data-and-config.md`(决策 D6)。 +//! 刻意把 setting 放在 core 而不是 UI 层——这是 HMCL 的架构债 +//! (`DevDocs/analyse/01-architecture-overview.md` §3.5),我们反过来做。 +//! +//! 与其它模块的契约:所有模块的配置读写统一走本模块的 schema API, +//! 模块自有配置键需先在 `DevDocs/SubAgentsTalk/` 报备(见该目录 README 的对齐点)。 +//! 暴露给各模块的签名清单见 `DevDocs/SubAgentsTalk/setting-接口约定.md`。 +//! +//! 公共面(M12 已落地): +//! - [`schema`]:`$schema` / `schemaVersion` 标识、8 态兼容判定 → 三档访问权; +//! - [`spec`] + [`catalog`]:键元数据(类型/范围、默认值)与键目录; +//! - [`policy`]:AI 可写白名单与敏感分级(`setting::spec::AiPolicy` 仍是兼容别名); +//! - [`keys`] / [`instance_keys`]:`launcher.json`、`profiles.json`、`instances/.json` 的键表; +//! - [`appearance`]:D17「外观(高级)」键簇的取值快照与校验(主题三态 / 背景图 / 面板透明度 / +//! 玻璃拟态 / 强调色); +//! - [`document`]:内存文档(点分键、脏字段、未知字段保留、默认值不落盘); +//! - [`store`]:单文件加载降级 + 原子保存 + 坏文件备份; +//! - [`save`] / [`save_worker`]:200ms 去抖攒批与后台保存线程; +//! - [`migrate`]:`schemaVersion N → N+1` 迁移链 + 收据闸门。 + +pub mod appearance; +pub mod catalog; +pub mod document; +pub mod error; +pub mod instance_keys; +pub mod json_path; +pub mod keys; +pub mod migrate; +pub mod policy; +pub mod save; +pub mod save_worker; +pub mod schema; +pub mod spec; +pub mod store; diff --git a/crates/core/src/setting/policy.rs b/crates/core/src/setting/policy.rs new file mode 100644 index 0000000..f071ace --- /dev/null +++ b/crates/core/src/setting/policy.rs @@ -0,0 +1,92 @@ +//! 键的 AI 边界:可写白名单与敏感分级(弹窗确认)。 +//! +//! 出处:`DevDocs/PlanDoc/02-ai-agent-design.md` §3.1(AI 安全边界:白名单 + 类型/范围校验 + +//! 分级确认)、`DevDocs/PlanDoc/04-data-and-config.md` §3(schema 化)。 +//! +//! 从 [`super::spec`] 拆出来的原因很实际:`spec.rs` 摸到 400 行硬上限(`guards.yml` 的 +//! file-size 门禁),而「策略」与「键的形状」本来就是两件事。 + +/// 敏感分级:决定 AI 改这个键时要不要弹窗。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Sensitivity { + /// 普通键:静默生效。 + Low, + /// 静默生效但审计标记(便于回看「AI 动过什么」)。 + Medium, + /// 敏感键:必须 UI 弹窗确认。 + High, +} + +impl Sensitivity { + /// 稳定名。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + } + } + + /// 是否必须人工确认。 + #[must_use] + pub const fn requires_confirmation(&self) -> bool { + matches!(self, Self::High) + } +} + +/// 一个键的 AI 可写策略。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AiPolicy { + /// AI 是否允许写这个键(白名单)。 + pub writable: bool, + /// 敏感分级。 + pub sensitivity: Sensitivity, +} + +impl AiPolicy { + /// 普通键:AI 可写,静默生效。 + #[must_use] + pub const fn silent() -> Self { + Self { + writable: true, + sensitivity: Sensitivity::Low, + } + } + + /// 可写但要审计标记。 + #[must_use] + pub const fn audited() -> Self { + Self { + writable: true, + sensitivity: Sensitivity::Medium, + } + } + + /// 可写但必须弹窗确认(java 路径 / 数据目录 / 代理 / AI 端点 / 隔离档位)。 + #[must_use] + pub const fn sensitive() -> Self { + Self { + writable: true, + sensitivity: Sensitivity::High, + } + } + + /// 已废弃键:AI 不可写,读兼容映射到替代键后由替代键承担策略。 + #[must_use] + pub const fn retired() -> Self { + Self { + writable: false, + sensitivity: Sensitivity::Low, + } + } + + /// 锁死:AI 永远不能写(凭据/密钥,D9「AI 永不触凭据」)。 + #[must_use] + pub const fn locked() -> Self { + Self { + writable: false, + sensitivity: Sensitivity::High, + } + } +} diff --git a/crates/core/src/setting/save.rs b/crates/core/src/setting/save.rs new file mode 100644 index 0000000..679469d --- /dev/null +++ b/crates/core/src/setting/save.rs @@ -0,0 +1,217 @@ +//! 保存通道的**去抖攒批**部分(纯逻辑,可注入时钟,确定性单测)。 +//! +//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.6(HMCL `FileSaver`): +//! 入队 → 消费线程先 `sleep(200ms)` 等更多改动 → 全量 drain 去重 → 逐条 `saveSync`(tmp + move)。 +//! 效果:一次窗口拖拽只产生一次写盘,而不是 60 次。 +//! +//! 分层:本文件只管「什么时候写、写什么」;真正落盘由 [`JobSink`] 决定 +//! (生产用 [`AtomicSink`],测试用记录型 sink)。 + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use super::error::SettingResult; + +/// 默认去抖窗口(analyse/07 §1.6 的 200ms)。 +pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(200); + +/// 一条待写任务。 +#[derive(Debug, Clone, PartialEq, Eq)] +struct Pending { + payload: Vec, + queued_at: Instant, +} + +/// 按 key 去重的去抖队列:同一 key 的新内容覆盖旧内容,但**保留首次入队时间** +/// (窗口从第一次改动开始算,避免连续改动把写盘无限推迟)。 +#[derive(Debug, Clone)] +pub struct DebounceQueue { + debounce: Duration, + pending: BTreeMap, +} + +impl DebounceQueue { + /// 新建一个去抖队列。 + #[must_use] + pub fn new(debounce: Duration) -> Self { + Self { + debounce, + pending: BTreeMap::new(), + } + } + + /// 去抖窗口。 + #[must_use] + pub const fn debounce(&self) -> Duration { + self.debounce + } + + /// 入队(同 key 覆盖内容)。返回 `true` 表示是新增条目。 + pub fn enqueue(&mut self, key: K, payload: impl Into>, now: Instant) -> bool { + match self.pending.get_mut(&key) { + Some(existing) => { + existing.payload = payload.into(); + false + } + None => { + self.pending.insert( + key, + Pending { + payload: payload.into(), + queued_at: now, + }, + ); + true + } + } + } + + /// 待写条目数。 + #[must_use] + pub fn len(&self) -> usize { + self.pending.len() + } + + /// 是否没有待写任务。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.pending.is_empty() + } + + /// 某 key 是否在队列里。 + #[must_use] + pub fn contains(&self, key: &K) -> bool { + self.pending.contains_key(key) + } + + /// 最早该写盘的时刻。 + #[must_use] + pub fn next_due(&self) -> Option { + self.pending + .values() + .map(|pending| pending.queued_at + self.debounce) + .min() + } + + /// 取出所有已过窗口的任务(按键排序,保证写盘顺序稳定)。 + pub fn drain_due(&mut self, now: Instant) -> Vec<(K, Vec)> { + let due: Vec = self + .pending + .iter() + .filter(|(_, pending)| pending.queued_at + self.debounce <= now) + .map(|(key, _)| key.clone()) + .collect(); + self.take(due) + } + + /// 取出全部任务(关停兜底)。 + pub fn drain_all(&mut self) -> Vec<(K, Vec)> { + let all: Vec = self.pending.keys().cloned().collect(); + self.take(all) + } + + fn take(&mut self, keys: Vec) -> Vec<(K, Vec)> { + keys.into_iter() + .filter_map(|key| { + self.pending + .remove(&key) + .map(|pending| (key, pending.payload)) + }) + .collect() + } +} + +impl Default for DebounceQueue { + fn default() -> Self { + Self::new(DEFAULT_DEBOUNCE) + } +} + +/// 真正落盘的执行者(测试可注入记录型实现)。 +pub trait JobSink { + /// 写一个文件。 + /// + /// # Errors + /// 落盘失败时返回 [`super::error::SettingError`]。 + fn write(&self, path: &Path, payload: &[u8]) -> SettingResult<()>; +} + +/// 生产用 sink:tmp → fsync → rename。 +#[derive(Debug, Clone, Copy, Default)] +pub struct AtomicSink; + +impl JobSink for AtomicSink { + fn write(&self, path: &Path, payload: &[u8]) -> SettingResult<()> { + crate::platform::atomic::write_atomic(path, payload)?; + Ok(()) + } +} + +/// 去抖队列 + sink 的组合体(app 层持有它,或交给 [`super::save_worker::SaveWorker`] 托管)。 +#[derive(Debug, Clone)] +pub struct CoalescingWriter { + queue: DebounceQueue, + sink: S, +} + +impl CoalescingWriter { + /// 组合去抖队列与 sink。 + #[must_use] + pub fn new(sink: S, debounce: Duration) -> Self { + Self { + queue: DebounceQueue::new(debounce), + sink, + } + } + + /// 入队一次写盘。 + pub fn queue(&mut self, path: impl Into, payload: impl Into>, now: Instant) { + self.queue.enqueue(path.into(), payload, now); + } + + /// 待写条目数。 + #[must_use] + pub fn pending_len(&self) -> usize { + self.queue.len() + } + + /// 是否没有待写任务。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.queue.is_empty() + } + + /// 最早该写盘的时刻。 + #[must_use] + pub fn next_due(&self) -> Option { + self.queue.next_due() + } + + /// 写掉已过窗口的任务,返回写了几个文件。 + /// + /// # Errors + /// sink 落盘失败时返回 [`super::error::SettingError`]。 + pub fn flush_due(&mut self, now: Instant) -> SettingResult { + let jobs = self.queue.drain_due(now); + self.run(jobs) + } + + /// 写掉全部任务(关停兜底),返回写了几个文件。 + /// + /// # Errors + /// sink 落盘失败时返回 [`super::error::SettingError`]。 + pub fn flush_all(&mut self) -> SettingResult { + let jobs = self.queue.drain_all(); + self.run(jobs) + } + + fn run(&mut self, jobs: Vec<(PathBuf, Vec)>) -> SettingResult { + let mut written = 0usize; + for (path, payload) in jobs { + self.sink.write(&path, &payload)?; + written += 1; + } + Ok(written) + } +} diff --git a/crates/core/src/setting/save_worker.rs b/crates/core/src/setting/save_worker.rs new file mode 100644 index 0000000..78adf63 --- /dev/null +++ b/crates/core/src/setting/save_worker.rs @@ -0,0 +1,181 @@ +//! 去抖保存的线程封装:app 层持有它,界面改配置只入队,后台线程负责攒批落盘。 +//! +//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.6(HMCL `FileSaver` 消费线程 + +//! 关停钩子 `waitForAllSaves()`)。本实现的「攒批/去抖/落盘」逻辑在 [`super::save`], +//! 这里只做线程与命令通道,方便对时序逻辑保持确定性单测。 + +use std::io; +use std::path::PathBuf; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use super::error::{SettingError, SettingResult}; +use super::save::{AtomicSink, CoalescingWriter, DEFAULT_DEBOUNCE}; + +/// 线程没起时的兜底路径标记(只为让 [`SettingError::Io`] 有出处)。 +const WORKER_PATH: &str = ""; + +enum Command { + Queue { path: PathBuf, payload: Vec }, + Flush(Sender>), + Shutdown(Sender>), +} + +/// 后台保存线程句柄。 +#[derive(Debug)] +pub struct SaveWorker { + tx: Option>, + handle: Option>, +} + +impl SaveWorker { + /// 用默认 200ms 去抖窗口起线程。 + /// + /// # Errors + /// 线程创建失败时返回 [`SettingError::Io`]。 + pub fn spawn_default() -> SettingResult { + Self::spawn(DEFAULT_DEBOUNCE) + } + + /// 用指定去抖窗口起线程。 + /// + /// # Errors + /// 线程创建失败时返回 [`SettingError::Io`]。 + pub fn spawn(debounce: Duration) -> SettingResult { + let (tx, rx) = mpsc::channel(); + let handle = thread::Builder::new() + .name("ull-save".to_owned()) + .spawn(move || run_loop(&rx, debounce)) + .map_err(io_error)?; + Ok(Self { + tx: Some(tx), + handle: Some(handle), + }) + } + + /// 入队一次写盘(不阻塞)。 + /// + /// # Errors + /// 保存线程已停止时返回 [`SettingError::Io`]。 + pub fn queue( + &self, + path: impl Into, + payload: impl Into>, + ) -> SettingResult<()> { + let command = Command::Queue { + path: path.into(), + payload: payload.into(), + }; + self.send(command) + } + + /// 同步落盘(返回时队列已空,且错误已带回当前线程)。 + /// + /// # Errors + /// 保存线程已停止,或后台落盘失败时返回 [`SettingError`]。 + pub fn flush(&self) -> SettingResult<()> { + let (ack_tx, ack_rx) = mpsc::channel(); + self.send(Command::Flush(ack_tx))?; + ack_rx.recv().map_err(|_| { + io_error(io::Error::new( + io::ErrorKind::BrokenPipe, + "保存线程未回传结果", + )) + })? + } + + /// drain 全部任务后停止线程。 + /// + /// # Errors + /// 落盘失败时返回 [`SettingError`]。 + pub fn shutdown(&mut self) -> SettingResult<()> { + let Some(tx) = self.tx.take() else { + return Ok(()); + }; + let (ack_tx, ack_rx) = mpsc::channel(); + let sent = tx.send(Command::Shutdown(ack_tx)); + let result = match sent { + Ok(()) => ack_rx.recv().unwrap_or_else(|_| { + Err(io_error(io::Error::new( + io::ErrorKind::BrokenPipe, + "保存线程未回传结果", + ))) + }), + Err(_) => Ok(()), + }; + if let Some(handle) = self.handle.take() { + handle.join().ok(); + } + result + } + + fn send(&self, command: Command) -> SettingResult<()> { + let Some(tx) = self.tx.as_ref() else { + return Err(io_error(io::Error::new( + io::ErrorKind::BrokenPipe, + "保存线程已停止", + ))); + }; + tx.send(command) + .map_err(|_| io_error(io::Error::new(io::ErrorKind::BrokenPipe, "保存线程已停止"))) + } +} + +impl Drop for SaveWorker { + fn drop(&mut self) { + self.shutdown().ok(); + } +} + +fn run_loop(rx: &Receiver, debounce: Duration) { + let mut writer = CoalescingWriter::new(AtomicSink, debounce); + let mut last_error: Option = None; + + loop { + let wait = writer + .next_due() + .map(|due| due.saturating_duration_since(Instant::now())) + .unwrap_or(Duration::from_secs(3600)) + .max(Duration::from_millis(1)); + + match rx.recv_timeout(wait) { + Ok(Command::Queue { path, payload }) => { + writer.queue(path, payload, Instant::now()); + } + Ok(Command::Flush(ack)) => { + let result = combine(last_error.take(), writer.flush_all()); + ack.send(result).ok(); + } + Ok(Command::Shutdown(ack)) => { + let result = combine(last_error.take(), writer.flush_all()); + ack.send(result).ok(); + break; + } + Err(RecvTimeoutError::Timeout) => { + if let Err(err) = writer.flush_due(Instant::now()) { + last_error = Some(err); + } + } + Err(RecvTimeoutError::Disconnected) => { + writer.flush_all().ok(); + break; + } + } + } +} + +/// 后台静默失败过的错误优先上报(不吞掉)。 +fn combine(previous: Option, result: SettingResult) -> SettingResult<()> { + match previous { + Some(err) => Err(err), + None => result.map(|_| ()), + } +} + +fn io_error(source: io::Error) -> SettingError { + SettingError::Io { + path: PathBuf::from(WORKER_PATH), + source, + } +} diff --git a/crates/core/src/setting/schema.rs b/crates/core/src/setting/schema.rs new file mode 100644 index 0000000..5849f00 --- /dev/null +++ b/crates/core/src/setting/schema.rs @@ -0,0 +1,292 @@ +//! `$schema` 与 `schemaVersion`:版本标识、8 态兼容判定、三档访问权。 +//! +//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.5(`JsonSchema.check` 8 态 + +//! `SettingFileAccess` 三档),`DevDocs/PlanDoc/04-data-and-config.md` §3。 +//! +//! 我们抄的是「一个 `$schema` 字段决定可读/可写/是否保留原 schema」,不抄 HMCL 的 +//! `major.minor.patch` 三级分支——ULL 的迁移链按 **major** 走:`1.0.0 → 2.0.0 → 3.0.0`。 + +use std::fmt; + +use serde_json::Value; + +/// `$schema` 键名。 +pub const SCHEMA_KEY: &str = "$schema"; +/// `schemaVersion` 键名(整数镜像 = major)。 +pub const SCHEMA_VERSION_KEY: &str = "schemaVersion"; +/// schema URL 前缀(逻辑标识,暂不对外发布)。 +pub const SCHEMA_URL_PREFIX: &str = "https://schemas.unlucky-launcher.dev/ull/"; + +/// 配置文件的语义版本。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +pub struct SchemaVersion { + /// 主版本(迁移链单位)。 + pub major: u32, + /// 次版本(新版本文件只读)。 + pub minor: u32, + /// 修订号。 + pub patch: u32, +} + +impl SchemaVersion { + /// 构造。 + #[must_use] + pub const fn new(major: u32, minor: u32, patch: u32) -> Self { + Self { + major, + minor, + patch, + } + } + + /// 解析 `1.0.0` 形式。 + #[must_use] + pub fn parse(text: &str) -> Option { + let mut parts = text.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + Some(Self::new(major, minor, patch)) + } + + /// 下一个主版本(迁移链的一步)。 + #[must_use] + pub const fn next_major(&self) -> Self { + Self::new(self.major + 1, 0, 0) + } +} + +impl fmt::Display for SchemaVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + } +} + +/// 配置文件种类。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SchemaId(&'static str); + +impl SchemaId { + /// `config/launcher.json`。 + pub const LAUNCHER: Self = Self("launcher"); + /// `config/profiles.json`。 + pub const PROFILES: Self = Self("profiles"); + /// `config/instances/.json`。 + pub const INSTANCE: Self = Self("instance"); + + /// 全部已知 id。 + pub const ALL: [Self; 3] = [Self::LAUNCHER, Self::PROFILES, Self::INSTANCE]; + + /// 稳定名。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + self.0 + } + + /// 从字符串解析(只认已知 id)。 + #[must_use] + pub fn parse(text: &str) -> Option { + Self::ALL.into_iter().find(|id| id.0 == text) + } +} + +/// `$schema` 的完整标识:种类 + 版本。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SchemaRef { + /// 种类。 + pub id: SchemaId, + /// 版本。 + pub version: SchemaVersion, +} + +impl SchemaRef { + /// 构造。 + #[must_use] + pub const fn new(id: SchemaId, version: SchemaVersion) -> Self { + Self { id, version } + } + + /// 写进文件的 `$schema` 字符串。 + #[must_use] + pub fn url(&self) -> String { + format!( + "{SCHEMA_URL_PREFIX}{}/{}.{}.{}", + self.id.as_str(), + self.version.major, + self.version.minor, + self.version.patch + ) + } + + /// 反解 `$schema` 字符串。 + #[must_use] + pub fn parse_url(text: &str) -> Option { + let tail = text.strip_prefix(SCHEMA_URL_PREFIX)?; + let (id, version) = tail.split_once('/')?; + Some(Self::new( + SchemaId::parse(id)?, + SchemaVersion::parse(version)?, + )) + } +} + +/// 文件与本实现期望 schema 的兼容状态(8 态)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SchemaCompat { + /// 同版本或更旧的版本:可读可写,正常升级。 + ReadWrite, + /// 同 minor、更高 patch:可读可写,但保留原 `$schema`。 + ReadWritePreserveSchema, + /// 同 major、更高 minor:可读不可写(新版写的文件),保留原 `$schema`。 + ReadOnlyPreserveSchema, + /// 文件没有 `$schema`(首建 / 遗留格式)。 + Missing, + /// `$schema` 不是我们能解析的 URL。 + /// + /// 手写配置最容易踩的一格:前缀写错(`https://unlucky-launcher.local/schema/...`)后整份 + /// 文件的价值都不该被丢掉,所以 M40 起按**只读**处理——值照读、不静默回落默认值, + /// 同时由 [`super::document::LoadProblem::UnknownSchema`] 明确告警。 + Invalid, + /// 文件连 JSON 都解析不了(由加载方给出)。 + Unparseable, + /// `$schema` 指向别的配置文件种类。 + UnexpectedId, + /// 文件 major 高于本实现——不认识,绝不能覆盖。 + UnsupportedMajor, +} + +impl SchemaCompat { + /// 判定文件里的 `$schema`(缺失时由调用方给 [`Self::Missing`])。 + #[must_use] + pub fn check(value: &Value, expected: SchemaRef) -> Self { + match value.get(SCHEMA_KEY).and_then(Value::as_str) { + None => Self::Missing, + Some(url) => Self::check_url(url, expected), + } + } + + /// 判定 `$schema` 字符串。 + #[must_use] + pub fn check_url(url: &str, expected: SchemaRef) -> Self { + let Some(found) = SchemaRef::parse_url(url) else { + return Self::Invalid; + }; + if found.id != expected.id { + return Self::UnexpectedId; + } + if found.version.major > expected.version.major { + return Self::UnsupportedMajor; + } + if found.version.major < expected.version.major { + // 旧文件:正常升级(迁移链负责搬运语义)。 + return Self::ReadWrite; + } + if found.version.minor > expected.version.minor { + return Self::ReadOnlyPreserveSchema; + } + if found.version.minor == expected.version.minor + && found.version.patch > expected.version.patch + { + return Self::ReadWritePreserveSchema; + } + Self::ReadWrite + } + + /// 稳定名(日志 / 收据)。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::ReadWrite => "read_write", + Self::ReadWritePreserveSchema => "read_write_preserve_schema", + Self::ReadOnlyPreserveSchema => "read_only_preserve_schema", + Self::Missing => "missing", + Self::Invalid => "invalid", + Self::Unparseable => "unparseable", + Self::UnexpectedId => "unexpected_id", + Self::UnsupportedMajor => "unsupported_major", + } + } + + /// 是否可读。 + #[must_use] + pub const fn readable(&self) -> bool { + matches!( + self, + Self::ReadWrite + | Self::ReadWritePreserveSchema + | Self::ReadOnlyPreserveSchema + | Self::Missing + | Self::Invalid + ) + } + + /// 保存时是否沿用文件里原有的 `$schema` 字符串(不降级覆盖新版本标记)。 + #[must_use] + pub const fn preserve_schema(&self) -> bool { + matches!( + self, + Self::ReadWritePreserveSchema | Self::ReadOnlyPreserveSchema + ) + } + + /// 映射到运行时三档访问权。 + /// + /// [`Self::Invalid`] 走**只读**:认不出的 `$schema` 前缀不该让整份配置失效(值照读, + /// 由 [`super::document::LoadProblem::UnknownSchema`] 告警),但也不该被我们悄悄改写。 + #[must_use] + pub const fn access(&self) -> FileAccess { + match self { + Self::ReadWrite | Self::ReadWritePreserveSchema | Self::Missing => { + FileAccess::ReadWrite + } + Self::ReadOnlyPreserveSchema | Self::Invalid => FileAccess::ReadOnly, + Self::Unparseable | Self::UnexpectedId | Self::UnsupportedMajor => { + FileAccess::Unreadable + } + } + } +} + +/// 运行时访问权三档(锁文件/权限异常时降级运行而非崩溃)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileAccess { + /// 可读可写。 + ReadWrite, + /// 可读不可写(新版文件 / 权限受限)。 + ReadOnly, + /// 读不了:值不可信,全程不写(防止覆盖新版本配置)。 + Unreadable, +} + +impl FileAccess { + /// 能否使用文件里的值。 + #[must_use] + pub const fn can_read(&self) -> bool { + !matches!(self, Self::Unreadable) + } + + /// 能否保存。 + #[must_use] + pub const fn can_save(&self) -> bool { + matches!(self, Self::ReadWrite) + } + + /// UI 是否该禁用编辑。 + #[must_use] + pub const fn blocks_editing(&self) -> bool { + !self.can_save() + } + + /// 稳定名。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::ReadWrite => "read_write", + Self::ReadOnly => "read_only", + Self::Unreadable => "unreadable", + } + } +} diff --git a/crates/core/src/setting/spec.rs b/crates/core/src/setting/spec.rs new file mode 100644 index 0000000..90a751f --- /dev/null +++ b/crates/core/src/setting/spec.rs @@ -0,0 +1,331 @@ +//! 配置键元数据:类型/范围、默认值、AI 可写白名单、敏感分级。 +//! +//! 出处:`DevDocs/PlanDoc/04-data-and-config.md` §3(schema 化 + AI 可写键 = schema 子集)、 +//! `DevDocs/PlanDoc/02-ai-agent-design.md` §3.1(AI 安全边界:白名单 + 类型/范围校验 + 分级确认)。 +//! +//! 这份元数据是「唯一真相源」:UI 设置页、AI 的 `get_config`/`set_config`、审计日志都读它。 + +use serde_json::{json, Value}; + +/// 键的值类型与约束。 +#[derive(Debug, Clone, PartialEq)] +pub enum ValueKind { + /// 布尔。 + Bool, + /// 整数(含闭区间范围)。 + Int { + /// 下界(含)。 + min: i64, + /// 上界(含)。 + max: i64, + }, + /// 浮点(含闭区间范围)。 + Float { + /// 下界(含)。 + min: f64, + /// 上界(含)。 + max: f64, + }, + /// 文本(长度上限,按字符数)。 + Text { + /// 最大字符数。 + max_len: usize, + }, + /// 枚举(只允许列出的字面量)。 + Enum(&'static [&'static str]), + /// 路径(非空字符串;绝对/相对都由业务层决定)。 + Path, + /// 字符串列表。 + TextList, + /// 枚举字面量数组:每个元素都必须落在白名单里(`overrides` 这类键用)。 + EnumList(&'static [&'static str]), + /// 任意 JSON(数据结构本身不参与校验,用于档案列表这类「数据而非配置」的键)。 + Any, +} + +impl ValueKind { + /// 校验;返回中文原因便于直接给用户看。 + pub fn check(&self, value: &Value) -> Result<(), String> { + match self { + Self::Bool => value + .is_boolean() + .then_some(()) + .ok_or_else(|| format!("期望布尔,实际是 {value}")), + Self::Int { min, max } => { + let Some(number) = value.as_i64() else { + return Err(format!("期望整数,实际是 {value}")); + }; + if number < *min || number > *max { + return Err(format!("{number} 超出范围 [{min}, {max}]")); + } + Ok(()) + } + Self::Float { min, max } => { + let Some(number) = value.as_f64() else { + return Err(format!("期望数字,实际是 {value}")); + }; + if number < *min || number > *max { + return Err(format!("{number} 超出范围 [{min}, {max}]")); + } + Ok(()) + } + Self::Text { max_len } => { + let Some(text) = value.as_str() else { + return Err(format!("期望文本,实际是 {value}")); + }; + let len = text.chars().count(); + if len > *max_len { + return Err(format!("文本 {len} 字符,超过上限 {max_len}")); + } + Ok(()) + } + Self::Enum(allowed) => { + let Some(text) = value.as_str() else { + return Err(format!("期望枚举字面量,实际是 {value}")); + }; + if allowed.contains(&text) { + Ok(()) + } else { + Err(format!("{text:?} 不在 {allowed:?} 之中")) + } + } + Self::Path => match value.as_str() { + Some(text) if !text.trim().is_empty() => Ok(()), + _ => Err(format!("期望非空路径字符串,实际是 {value}")), + }, + Self::TextList => { + let Some(items) = value.as_array() else { + return Err(format!("期望字符串数组,实际是 {value}")); + }; + if items.iter().all(Value::is_string) { + Ok(()) + } else { + Err("数组里含非字符串元素".to_owned()) + } + } + Self::EnumList(allowed) => { + let Some(items) = value.as_array() else { + return Err(format!("期望枚举字面量数组,实际是 {value}")); + }; + for item in items { + let Some(text) = item.as_str() else { + return Err(format!("数组里含非字符串元素: {item}")); + }; + if !allowed.contains(&text) { + return Err(format!("{text:?} 不在 {allowed:?} 之中")); + } + } + Ok(()) + } + Self::Any => Ok(()), + } + } + + /// 转成 JSON Schema 片段(喂给 M16 的 `ToolSpec`)。 + #[must_use] + pub(crate) fn to_json_schema(&self) -> Value { + match self { + Self::Bool => json!({ "type": "boolean" }), + Self::Int { min, max } => json!({ "type": "integer", "minimum": min, "maximum": max }), + Self::Float { min, max } => json!({ "type": "number", "minimum": min, "maximum": max }), + Self::Text { max_len } => json!({ "type": "string", "maxLength": max_len }), + Self::Enum(allowed) => json!({ "type": "string", "enum": allowed }), + Self::Path => json!({ "type": "string", "minLength": 1 }), + Self::TextList => json!({ "type": "array", "items": { "type": "string" } }), + Self::EnumList(allowed) => { + json!({ "type": "array", "items": { "type": "string", "enum": allowed } }) + } + Self::Any => json!({}), + } + } +} + +// AI 边界类型从 [`super::policy`] 拆出去(`spec.rs` 摸到 400 行硬上限), +// 路径保持不变:`setting::spec::AiPolicy` 仍是可用名。 +pub use super::policy::{AiPolicy, Sensitivity}; + +/// 键废弃信息:替代键 + 旧值映射表。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Deprecation { + /// 替代键(写永远走这里)。 + pub replaced_by: &'static str, + /// 旧值 → 新值映射(读兼容);表里没有的值原样带过去。 + pub aliases: &'static [(&'static str, &'static str)], +} + +impl Deprecation { + /// 把旧值翻译成新键能接受的值(非字符串或表里没有的值原样返回)。 + #[must_use] + pub fn translate(&self, legacy: &Value) -> Value { + match legacy.as_str() { + Some(text) => self + .aliases + .iter() + .find(|(old, _)| *old == text) + .map_or_else(|| legacy.clone(), |(_, new)| Value::from(*new)), + None => legacy.clone(), + } + } +} + +/// 一个配置键的完整元数据。 +#[derive(Debug, Clone, PartialEq)] +pub struct SettingSpec { + /// 点分键名(如 `download.concurrency`)。 + pub key: &'static str, + /// 类型与约束。 + pub kind: ValueKind, + /// 默认值;`Value::Null` 表示「无默认」(如用户必须先选的路径)。 + pub default: Value, + /// AI 策略。 + pub ai: AiPolicy, + /// 一句话说明(UI 提示 + AI 工具描述)。 + pub doc: &'static str, + /// 废弃信息:有值表示这个键只做**读兼容**,写必须走替代键。 + pub deprecated: Option, +} + +impl SettingSpec { + /// 完全手工构造。 + #[must_use] + pub const fn new( + key: &'static str, + kind: ValueKind, + default: Value, + ai: AiPolicy, + doc: &'static str, + ) -> Self { + Self { + key, + kind, + default, + ai, + doc, + deprecated: None, + } + } + + /// 标记为「已废弃,读兼容到 `replaced_by`」,并按 `aliases` 翻译旧值。 + /// + /// 用于键改名/语义变更(如 `ui.theme` → `appearance.theme_mode`,tower 键冲突裁决): + /// 旧文件照读、下次保存只写新键,AI 白名单与工具 JSON Schema 自动排除本键。 + #[must_use] + pub const fn deprecated( + mut self, + replaced_by: &'static str, + aliases: &'static [(&'static str, &'static str)], + ) -> Self { + self.deprecated = Some(Deprecation { + replaced_by, + aliases, + }); + self + } + + /// 该键是否已废弃。 + #[must_use] + pub const fn is_deprecated(&self) -> bool { + self.deprecated.is_some() + } + + /// 布尔键。 + #[must_use] + pub fn bool(key: &'static str, default: bool, ai: AiPolicy, doc: &'static str) -> Self { + Self::new(key, ValueKind::Bool, Value::Bool(default), ai, doc) + } + + /// 整数键(闭区间)。 + #[must_use] + pub fn int( + key: &'static str, + default: i64, + (min, max): (i64, i64), + ai: AiPolicy, + doc: &'static str, + ) -> Self { + Self::new( + key, + ValueKind::Int { min, max }, + Value::from(default), + ai, + doc, + ) + } + + /// 浮点键(闭区间)。 + #[must_use] + pub fn float( + key: &'static str, + default: f64, + (min, max): (f64, f64), + ai: AiPolicy, + doc: &'static str, + ) -> Self { + Self::new( + key, + ValueKind::Float { min, max }, + Value::from(default), + ai, + doc, + ) + } + + /// 文本键。 + #[must_use] + pub fn text( + key: &'static str, + default: &str, + max_len: usize, + ai: AiPolicy, + doc: &'static str, + ) -> Self { + Self::new( + key, + ValueKind::Text { max_len }, + Value::from(default), + ai, + doc, + ) + } + + /// 枚举键。 + #[must_use] + pub fn enumeration( + key: &'static str, + default: &'static str, + allowed: &'static [&'static str], + ai: AiPolicy, + doc: &'static str, + ) -> Self { + Self::new(key, ValueKind::Enum(allowed), Value::from(default), ai, doc) + } + + /// 路径键(无默认值,未设置时为 `null`)。 + #[must_use] + pub fn path(key: &'static str, ai: AiPolicy, doc: &'static str) -> Self { + Self::new(key, ValueKind::Path, Value::Null, ai, doc) + } + + /// 枚举字面量数组键(默认空数组;元素逐个校验)。 + #[must_use] + pub fn enum_list( + key: &'static str, + allowed: &'static [&'static str], + ai: AiPolicy, + doc: &'static str, + ) -> Self { + Self::new( + key, + ValueKind::EnumList(allowed), + Value::Array(Vec::new()), + ai, + doc, + ) + } + + /// 任意 JSON 键(数据结构不参与校验)。 + #[must_use] + pub fn any(key: &'static str, ai: AiPolicy, doc: &'static str) -> Self { + Self::new(key, ValueKind::Any, Value::Null, ai, doc) + } +} diff --git a/crates/core/src/setting/store.rs b/crates/core/src/setting/store.rs new file mode 100644 index 0000000..737bc04 --- /dev/null +++ b/crates/core/src/setting/store.rs @@ -0,0 +1,301 @@ +//! 一个配置文件 = 一个 [`SettingStore`]:加载降级 → 编辑 → 原子保存 → 坏文件备份。 +//! +//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.5-§1.6(8 态 schema / 三档访问权 / +//! 「文件损坏 → 默认值 + 下次保存前备份 `xxx.json.1`」),`DevDocs/PlanDoc/04-data-and-config.md` §3。 +//! +//! 加载永不 panic:读不了/版本不认识就降级成只读或不可读,让启动器带着默认值继续跑 +//! (analyse/07 §1「锁文件/权限异常时降级运行而非崩溃」)。 + +use std::io; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::platform::atomic; + +use super::catalog::SettingSchema; +use super::document::{LoadProblem, SettingDocument}; +use super::error::{SettingError, SettingResult}; +use super::migrate::{MigrationChain, MigrationReport}; +use super::schema::{FileAccess, SchemaCompat, SCHEMA_KEY, SCHEMA_VERSION_KEY}; + +/// 保存结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SaveOutcome { + /// 已原子落盘。 + Saved(PathBuf), + /// 没有写盘,原因见 [`SkipReason`]。 + Skipped(SkipReason), +} + +impl SaveOutcome { + /// 是否真的写了盘。 + #[must_use] + pub fn wrote(&self) -> bool { + matches!(self, Self::Saved(_)) + } +} + +/// 跳过保存的原因。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkipReason { + /// 没有脏字段。 + NotDirty, + /// 文件只读(`READ_ONLY`)。 + ReadOnly, + /// 文件不可信(`UNREADABLE`),全程不写。 + Unreadable, +} + +impl SkipReason { + /// 稳定名。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::NotDirty => "not_dirty", + Self::ReadOnly => "read_only", + Self::Unreadable => "unreadable", + } + } +} + +/// 单个配置文件的内存 + 磁盘视图。 +#[derive(Debug, Clone)] +pub struct SettingStore { + path: PathBuf, + doc: SettingDocument, + access: FileAccess, + compat: SchemaCompat, + problems: Vec, + backup_on_next_save: bool, +} + +impl SettingStore { + /// 加载(或首建)一个配置文件;永不失败,状态从 [`Self::access`] 读。 + #[must_use] + pub fn load(path: impl Into, schema: SettingSchema) -> Self { + let mut store = Self { + path: path.into(), + doc: SettingDocument::new(schema.clone()), + access: FileAccess::ReadWrite, + compat: SchemaCompat::Missing, + problems: Vec::new(), + backup_on_next_save: false, + }; + store.reload_inner(); + store + } + + /// 文件路径。 + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } + + /// 访问权三档。 + #[must_use] + pub const fn access(&self) -> FileAccess { + self.access + } + + /// `$schema` 兼容状态。 + #[must_use] + pub const fn compat(&self) -> SchemaCompat { + self.compat + } + + /// 加载时的问题清单(未知键 / 坏值 / 版本镜像不一致…)。 + #[must_use] + pub fn problems(&self) -> &[LoadProblem] { + &self.problems + } + + /// 只读视图。 + #[must_use] + pub const fn doc(&self) -> &SettingDocument { + &self.doc + } + + /// 可变视图(迁移 / 导入用;正常改配置请走 [`Self::set`],那里带访问权检查)。 + pub const fn doc_mut(&mut self) -> &mut SettingDocument { + &mut self.doc + } + + /// 生效值:文件值 → 默认值。 + #[must_use] + pub fn effective(&self, key: &str) -> Option<&Value> { + self.doc.effective(key) + } + + /// 是否有未落盘改动。 + #[must_use] + pub fn is_dirty(&self) -> bool { + self.doc.is_dirty() + } + + /// 改一个键(只读/不可读时直接拒绝,不静默丢改动)。 + /// + /// # Errors + /// 文件只读 → [`SettingError::ReadOnly`];不可读 → [`SettingError::Unreadable`]; + /// 键值不合法 → [`SettingError::InvalidValue`] / [`SettingError::UnknownKey`]。 + pub fn set(&mut self, key: &str, value: Value) -> SettingResult<()> { + match self.access { + FileAccess::ReadWrite => self.doc.set(key, value), + FileAccess::ReadOnly => Err(SettingError::ReadOnly { + path: self.path.clone(), + }), + FileAccess::Unreadable => Err(SettingError::Unreadable { + path: self.path.clone(), + compat: self.compat, + }), + } + } + + /// 保存(只写脏字段、原子落盘;不满足条件时返回 [`SaveOutcome::Skipped`])。 + /// + /// # Errors + /// 序列化 / 备份 / 写盘失败时返回 [`SettingError`]。 + pub fn save(&mut self) -> SettingResult { + if !self.access.can_save() { + let reason = if matches!(self.access, FileAccess::ReadOnly) { + SkipReason::ReadOnly + } else { + SkipReason::Unreadable + }; + return Ok(SaveOutcome::Skipped(reason)); + } + if !self.doc.is_dirty() { + return Ok(SaveOutcome::Skipped(SkipReason::NotDirty)); + } + if self.backup_on_next_save { + self.backup_existing()?; + } + self.write_document()?; + Ok(SaveOutcome::Saved(self.path.clone())) + } + + /// 显式强写(用户在 UI 上确认「覆盖只读/损坏文件」后调用):先备份,再覆盖,恢复可写。 + /// + /// # Errors + /// 备份或写盘失败时返回 [`SettingError`]。 + pub fn save_forced(&mut self) -> SettingResult { + self.backup_existing()?; + self.write_document()?; + self.access = FileAccess::ReadWrite; + self.backup_on_next_save = false; + Ok(SaveOutcome::Saved(self.path.clone())) + } + + /// 重新从磁盘加载(丢弃内存改动)。 + pub fn reload(&mut self) { + self.reload_inner(); + } + + /// 按迁移链把文件从旧版本推进到 schema 版本(补丁后再落盘由调用方决定)。 + /// + /// # Errors + /// 链不完整或某步迁移失败时返回 [`SettingError`]。 + pub fn apply_chain( + &mut self, + chain: &MigrationChain, + ) -> SettingResult> { + let schema = self.doc.schema().clone(); + if !self.access.can_read() || self.doc.version() == schema.version() { + return Ok(None); + } + let Value::Object(mut root) = self.doc.to_value() else { + return Ok(None); + }; + let report = chain.migrate(self.doc.version(), &mut root)?; + root.insert( + SCHEMA_KEY.to_owned(), + Value::String(schema.schema_ref().url()), + ); + root.insert( + SCHEMA_VERSION_KEY.to_owned(), + Value::from(schema.version().major), + ); + + let (mut document, problems) = SettingDocument::load(&schema, &Value::Object(root)); + document.set_version(schema.version()); + document.mark_all_dirty(); + self.doc = document; + self.problems = problems; + Ok(Some(report)) + } + + /// 下一个空位的备份路径(`launcher.json.1`、`.2`…)。 + #[must_use] + pub fn next_backup_path(&self) -> PathBuf { + for index in 1..=999u32 { + let candidate = PathBuf::from(format!("{}.{index}", self.path.display())); + if !candidate.exists() { + return candidate; + } + } + PathBuf::from(format!("{}.1", self.path.display())) + } + + fn reload_inner(&mut self) { + let schema = self.doc.schema().clone(); + self.doc = SettingDocument::new(schema.clone()); + self.problems.clear(); + self.access = FileAccess::ReadWrite; + self.compat = SchemaCompat::Missing; + self.backup_on_next_save = false; + + let bytes = match std::fs::read(&self.path) { + Ok(bytes) => bytes, + // 首建:可写 + 全默认值。 + Err(err) if err.kind() == io::ErrorKind::NotFound => return, + // 读不了(权限/被占用):值不可信,全程不写。 + Err(_) => { + self.access = FileAccess::Unreadable; + self.compat = SchemaCompat::Unparseable; + return; + } + }; + + match serde_json::from_slice::(&bytes) { + // 文件损坏:默认值 + 下次保存前先备份(不把坏文件当未知字段回写)。 + Err(_) => { + self.compat = SchemaCompat::Unparseable; + self.backup_on_next_save = true; + } + Ok(value) => { + let compat = SchemaCompat::check(&value, schema.schema_ref()); + self.compat = compat; + self.access = compat.access(); + if !self.access.can_read() { + return; + } + let (document, problems) = SettingDocument::load(&schema, &value); + self.doc = document; + self.problems = problems; + } + } + } + + fn write_document(&mut self) -> SettingResult<()> { + let value = self.doc.to_value(); + let bytes = serde_json::to_vec_pretty(&value).map_err(|source| SettingError::Json { + path: self.path.clone(), + source, + })?; + atomic::write_atomic(&self.path, &bytes)?; + self.doc.mark_clean(); + Ok(()) + } + + fn backup_existing(&mut self) -> SettingResult> { + if !self.path.exists() { + return Ok(None); + } + let backup = self.next_backup_path(); + std::fs::rename(&self.path, &backup).map_err(|source| SettingError::Io { + path: self.path.clone(), + source, + })?; + Ok(Some(backup)) + } +} diff --git a/crates/core/tests/auth/auth_failure_journal.rs b/crates/core/tests/auth/auth_failure_journal.rs new file mode 100644 index 0000000..a7e6755 --- /dev/null +++ b/crates/core/tests/auth/auth_failure_journal.rs @@ -0,0 +1,151 @@ +//! M38:认证失败事件落盘(`.ULL/logs/auth.jsonl`)与凭据红线。 +//! +//! 验收场景:`ull accounts add msa` 失败后,终端文案会滚走;日志区里要留下 +//! 一行结构化记录(`code` / `detail_code` / `status` / `content_type` / `body_kind` / +//! 已脱敏摘要),且**不含任何 token**。便携数据根还没初始化时先补建 `logs/`。 + +use serde_json::json; +use unlucky_core::auth::{AuthError, AuthFailureEvent, AuthJournal, BodyKind}; + +use crate::support::{ + assert_no_credentials, auth_for, expired_session, full_routes, routes_until_login, temp_dir, + AuthMock, MockReply, HTML_BLOCK_PAGE, +}; + +const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g"; + +#[tokio::test] +async fn body_digest_never_carries_credentials() { + let mut routes = routes_until_login(); + routes.push(( + "/login_with_xbox", + vec![MockReply::json_status( + 403, + json!({ "error": "DENIED", "echo": "xsts-token-value", "jwt": JWT }), + )], + )); + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + + let message = error.message(); + assert_no_credentials(&message); + // 长不透明串(JWT)必须打码,别把 token 回显抄进摘要。 + assert!(!message.contains("eyJhbGci"), "JWT 必须脱敏:{message}"); + assert!(message.contains("redacted"), "{message}"); + + // 事件层同样不落凭据。 + let event = AuthFailureEvent::of("device_code_login", &error); + let json = event.to_json().to_string(); + assert_no_credentials(&json); + assert!(!json.contains("eyJhbGci"), "{json}"); + assert!(json.contains("login_with_xbox_403"), "{json}"); + mock.stop(); +} + +#[tokio::test] +async fn failure_journal_initializes_logs_and_records_the_403() { + let root = temp_dir("diag-journal"); + let logs = root.join(".ULL").join("logs"); + let journal = AuthJournal::in_logs_dir(&logs); + assert!(!logs.exists(), "模拟未初始化的便携数据根"); + + let mut routes = routes_until_login(); + routes.push(( + "/login_with_xbox", + vec![MockReply::html(403, HTML_BLOCK_PAGE)], + )); + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .with_journal(Some(journal.clone())) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + + let text = std::fs::read_to_string(journal.path()).expect("失败日志必须落盘"); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 1, "一次失败一条记录:{text}"); + let record: serde_json::Value = serde_json::from_str(lines[0]).expect("合法 JSONL"); + assert_eq!(record["event"], "auth_failure"); + assert_eq!(record["level"], "error"); + assert_eq!(record["scope"], "device_code_login"); + assert_eq!(record["step"], "login_with_xbox"); + assert_eq!(record["detail_code"], "login_with_xbox_non_json"); + assert_eq!(record["status"], 403); + assert_eq!(record["body_kind"], "html"); + assert_eq!(record["transient"], true); + assert_eq!( + record["message"], + error.message(), + "事件里的文案要与返回的错误一致" + ); + assert_no_credentials(&text); + mock.stop(); + let _ = std::fs::remove_dir_all(&root); +} + +#[tokio::test] +async fn journal_event_shape_covers_non_chain_errors() { + let event = AuthFailureEvent::of("refresh", &AuthError::Canceled); + assert_eq!(event.detail_code, "canceled"); + assert_eq!(event.step, None); + assert_eq!(event.status, None); + assert_eq!(event.body_kind, None); + assert!(!event.transient); + assert_eq!(event.to_json()["scope"], "refresh"); + + let detail = unlucky_core::auth::ResponseDetail { + step: "profile".to_string(), + what: "拉取 Minecraft 档案被拒".to_string(), + status: 401, + content_type: Some("application/json".to_string()), + kind: BodyKind::Json, + server_message: Some("Unauthorized".to_string()), + digest: "{\"error\":\"Unauthorized\"}".to_string(), + }; + let chain = AuthFailureEvent::of("device_code_login", &AuthError::Response(Box::new(detail))); + assert_eq!(chain.step.as_deref(), Some("profile")); + assert_eq!(chain.detail_code, "profile_401"); + assert_eq!(chain.status, Some(401)); + assert!(!chain.transient, "JSON 拒绝不是可重试的网络问题"); +} + +#[tokio::test] +async fn successful_login_writes_no_journal_and_refresh_failures_do() { + let root = temp_dir("diag-journal-ok"); + let journal = AuthJournal::in_logs_dir(root.join("logs")); + + // 成功:不落盘。 + let mock = AuthMock::start(full_routes()).await; + auth_for(&mock.base()) + .with_journal(Some(journal.clone())) + .device_code_login(|_| {}) + .await + .expect("应登录成功"); + assert!(!journal.path().exists(), "成功路径不写失败日志"); + mock.stop(); + + // 刷新失败:落一条(凭据被撤销 = 要人重新登录)。 + let failing = AuthMock::start(vec![( + "/token", + vec![MockReply::json_status( + 400, + json!({ "error": "invalid_grant", "error_description": "AADSTS70000" }), + )], + )]) + .await; + let error = auth_for(&failing.base()) + .with_journal(Some(journal.clone())) + .refresh(&expired_session()) + .await + .expect_err("应失败"); + assert!(error.is_credential_expired()); + let text = std::fs::read_to_string(journal.path()).expect("刷新失败也要落盘"); + assert_eq!(text.lines().count(), 1, "{text}"); + assert!(text.contains("refresh"), "{text}"); + assert!(text.contains("credential_expired"), "{text}"); + failing.stop(); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/core/tests/auth/authlib_api.rs b/crates/core/tests/auth/authlib_api.rs new file mode 100644 index 0000000..dcdb8d7 --- /dev/null +++ b/crates/core/tests/auth/authlib_api.rs @@ -0,0 +1,307 @@ +//! Yggdrasil(authlib-injector)四动作的报文级对接:登录 / 刷新 / 校验 / 注销。 + +use serde_json::json; +use unlucky_core::auth::authlib::{ + AuthlibAuth, AuthlibEndpoints, AuthlibProfile, AuthlibServerMeta, AuthlibSession, +}; +use unlucky_core::auth::{AuthError, Secret}; + +use crate::support::{AuthMock, MockReply}; + +const API_ROOT: &str = "https://example.invalid/api/yggdrasil/"; +const PROFILE_ID: &str = "0123456789abcdef0123456789abcdef"; + +fn paths() -> (&'static str, &'static str, &'static str, &'static str) { + ( + "/api/yggdrasil/authserver/authenticate", + "/api/yggdrasil/authserver/refresh", + "/api/yggdrasil/authserver/validate", + "/api/yggdrasil/authserver/invalidate", + ) +} + +/// 把 API 根改指到 mock(`base` 代替 https://example.invalid)。 +fn mock_root(mock: &AuthMock) -> String { + format!("{}/api/yggdrasil", mock.base()) +} + +fn session() -> AuthlibSession { + AuthlibSession { + access_token: Secret::new("access-token-value"), + client_token: Secret::new("client-token-value"), + selected_profile: Some(AuthlibProfile { + id: PROFILE_ID.to_string(), + name: "Steve".to_string(), + }), + } +} + +#[tokio::test] +async fn authenticate_sends_the_canonical_body() { + let (authenticate, _, _, _) = paths(); + let mock = AuthMock::start(vec![( + authenticate, + vec![MockReply::json(json!({ + "accessToken": "access-token-value", + "clientToken": "client-token-value", + "selectedProfile": { "id": PROFILE_ID, "name": "Steve" } + }))], + )]) + .await; + let auth = AuthlibAuth::new().expect("构造"); + let login = auth + .authenticate( + &mock_root(&mock), + "steve@example.com", + &Secret::new("hunter2-password"), + &Secret::new("client-token-value"), + ) + .await + .expect("登录成功"); + + assert_eq!(login.access_token.expose(), "access-token-value"); + assert_eq!(login.client_token.expose(), "client-token-value"); + assert!(login.has_profile()); + assert_eq!( + login.selected_profile.as_ref().map(|p| p.name.as_str()), + Some("Steve") + ); + + let body = &mock.bodies_on(authenticate)[0]; + assert!( + body.contains("\"agent\":{\"name\":\"Minecraft\",\"version\":1}"), + "{body}" + ); + assert!(body.contains("\"requestUser\":true"), "{body}"); + assert!( + body.contains("\"username\":\"steve@example.com\""), + "{body}" + ); + assert!( + body.contains("\"clientToken\":\"client-token-value\""), + "{body}" + ); + // 密码确实发了(否则登录不了),但会话 Debug 里看不到它。 + assert!(body.contains("hunter2-password")); + assert!(!format!("{login:?}").contains("hunter2-password")); + mock.stop(); +} + +#[tokio::test] +async fn wrong_password_is_denied_with_server_message() { + let (authenticate, _, _, _) = paths(); + let mock = AuthMock::start(vec![( + authenticate, + vec![MockReply::json_status( + 403, + json!({ + "error": "ForbiddenOperationException", + "errorMessage": "Invalid credentials. Invalid username or password." + }), + )], + )]) + .await; + let auth = AuthlibAuth::new().expect("构造"); + let error = auth + .authenticate( + &mock_root(&mock), + "steve", + &Secret::new("wrong"), + &Secret::new("client-token-value"), + ) + .await + .expect_err("应失败"); + match error { + AuthError::Denied { code, description } => { + assert_eq!(code, "ForbiddenOperationException"); + assert!(description.contains("Invalid credentials")); + } + other => panic!("应是 Denied,实际 {other:?}"), + } + mock.stop(); +} + +#[tokio::test] +async fn server_must_echo_the_client_token() { + let (authenticate, _, _, _) = paths(); + let mock = AuthMock::start(vec![( + authenticate, + vec![MockReply::json(json!({ + "accessToken": "access-token-value", + "clientToken": "someone-elses-token", + "selectedProfile": { "id": PROFILE_ID, "name": "Steve" } + }))], + )]) + .await; + let auth = AuthlibAuth::new().expect("构造"); + let error = auth + .authenticate( + &mock_root(&mock), + "steve", + &Secret::new("pw"), + &Secret::new("client-token-value"), + ) + .await + .expect_err("应拒绝"); + assert_eq!(error.code(), "malformed"); + assert!( + error.message().contains("clientToken"), + "{}", + error.message() + ); + mock.stop(); +} + +#[tokio::test] +async fn refresh_can_select_a_character() { + let (_, refresh, _, _) = paths(); + let mock = AuthMock::start(vec![( + refresh, + vec![MockReply::json(json!({ + "accessToken": "access-token-2", + "clientToken": "client-token-value", + "selectedProfile": { "id": PROFILE_ID, "name": "Alex" } + }))], + )]) + .await; + let auth = AuthlibAuth::new().expect("构造"); + let wanted = AuthlibProfile { + id: PROFILE_ID.to_string(), + name: "Alex".to_string(), + }; + let refreshed = auth + .refresh(&mock_root(&mock), &session(), Some(&wanted)) + .await + .expect("刷新成功"); + assert_eq!(refreshed.access_token.expose(), "access-token-2"); + assert_eq!(refreshed.selected_profile.expect("角色").name, "Alex"); + // 新的 refresh_token 不存在于 Yggdrasil:clientToken 保持不变。 + assert_eq!(refreshed.client_token.expose(), "client-token-value"); + + let body = &mock.bodies_on(refresh)[0]; + assert!(body.contains("\"selectedProfile\":{\"id\":\"0123456789abcdef0123456789abcdef\"")); + assert!(body.contains("\"accessToken\":\"access-token-value\"")); + mock.stop(); +} + +#[tokio::test] +async fn refresh_that_did_not_switch_character_is_malformed() { + let (_, refresh, _, _) = paths(); + let mock = AuthMock::start(vec![( + refresh, + vec![MockReply::json(json!({ + "accessToken": "access-token-2", + "clientToken": "client-token-value", + "selectedProfile": { "id": PROFILE_ID, "name": "Steve" } + }))], + )]) + .await; + let auth = AuthlibAuth::new().expect("构造"); + let wanted = AuthlibProfile { + id: "ffffffffffffffffffffffffffffffff".to_string(), + name: "Ghost".to_string(), + }; + let error = auth + .refresh(&mock_root(&mock), &session(), Some(&wanted)) + .await + .expect_err("应失败"); + assert_eq!(error.code(), "malformed"); + mock.stop(); +} + +#[tokio::test] +async fn forbidden_on_refresh_means_credential_expired() { + let (_, refresh, _, _) = paths(); + let mock = AuthMock::start(vec![( + refresh, + vec![MockReply::json_status( + 403, + json!({ "error": "ForbiddenOperationException", "errorMessage": "Invalid token." }), + )], + )]) + .await; + let auth = AuthlibAuth::new().expect("构造"); + let error = auth + .refresh(&mock_root(&mock), &session(), None) + .await + .expect_err("应失败"); + assert!(error.is_credential_expired(), "{error:?}"); + mock.stop(); +} + +#[tokio::test] +async fn validate_distinguishes_invalid_token_from_server_trouble() { + let (_, _, validate, _) = paths(); + // 空体 200 → 有效 + let ok = AuthMock::start(vec![(validate, vec![MockReply::status(200)])]).await; + let auth = AuthlibAuth::new().expect("构造"); + assert!(auth + .validate(&mock_root(&ok), &session()) + .await + .expect("有效")); + ok.stop(); + + // 403 ForbiddenOperationException → 无效(返回值 false,不抛错) + let invalid = AuthMock::start(vec![( + validate, + vec![MockReply::json_status( + 403, + json!({ "error": "ForbiddenOperationException", "errorMessage": "Invalid token." }), + )], + )]) + .await; + assert!(!auth + .validate(&mock_root(&invalid), &session()) + .await + .expect("无效")); + invalid.stop(); + + // 500 → 服务器问题,必须抛错(调用方才会重试而不是刷新) + let broken = AuthMock::start(vec![(validate, vec![MockReply::status(500)])]).await; + let error = auth + .validate(&mock_root(&broken), &session()) + .await + .expect_err("应失败"); + assert_eq!(error.code(), "network"); + broken.stop(); +} + +#[tokio::test] +async fn invalidate_tolerates_already_invalid_tokens() { + let (_, _, _, invalidate) = paths(); + let auth = AuthlibAuth::new().expect("构造"); + let ok = AuthMock::start(vec![(invalidate, vec![MockReply::status(200)])]).await; + auth.invalidate(&mock_root(&ok), &session()) + .await + .expect("注销成功"); + ok.stop(); + + let gone = AuthMock::start(vec![( + invalidate, + vec![MockReply::json_status( + 403, + json!({ "error": "ForbiddenOperationException" }), + )], + )]) + .await; + auth.invalidate(&mock_root(&gone), &session()) + .await + .expect("已失效也算成功"); + gone.stop(); +} + +#[test] +fn endpoints_and_metadata_stay_in_sync_with_the_protocol() { + let endpoints = AuthlibEndpoints::from_api_root(API_ROOT).expect("端点"); + assert_eq!( + endpoints.authenticate, + format!("{API_ROOT}authserver/authenticate") + ); + let meta = AuthlibServerMeta::from_value(&json!({ + "meta": { "serverName": "Example", "feature.non_email_login": true }, + "skinDomains": ["example.invalid"] + })); + assert_eq!(meta.name.as_deref(), Some("Example")); + assert!(meta.non_email_login); + assert_eq!(meta.skin_domains, vec!["example.invalid".to_string()]); +} diff --git a/crates/core/tests/auth/main.rs b/crates/core/tests/auth/main.rs new file mode 100644 index 0000000..8f79b50 --- /dev/null +++ b/crates/core/tests/auth/main.rs @@ -0,0 +1,19 @@ +//! `core::auth` 的集成测试入口。 +//! +//! 每个子模块对应 mission 的一项验收: +//! - `offline_uuid`:离线 UUID 与 JDK(= HMCL 算法)**逐字节对拍**的已知向量; +//! - `store_separation`:元数据/凭据分文件、原子写、写序、容错与孤儿清理; +//! - `microsoft_flow`:device-code 全链(含 pending / slow_down / 取消 / 超时 / XErr); +//! - `microsoft_diagnostics`:非 2xx 正文诊断(JSON/HTML/空体分流)+ 失败日志落盘(M38); +//! - `microsoft_refresh`:refresh 轮换、profile 变脸防线、validate 语义、404 二义; +//! - `authlib_api`:Yggdrasil 四动作的报文级对接。 + +mod support; + +mod auth_failure_journal; +mod authlib_api; +mod microsoft_diagnostics; +mod microsoft_flow; +mod microsoft_refresh; +mod offline_uuid; +mod store_separation; diff --git a/crates/core/tests/auth/microsoft_diagnostics.rs b/crates/core/tests/auth/microsoft_diagnostics.rs new file mode 100644 index 0000000..1a90e11 --- /dev/null +++ b/crates/core/tests/auth/microsoft_diagnostics.rs @@ -0,0 +1,231 @@ +//! M38:非 2xx 应答正文诊断(`AuthError::Response`)。 +//! +//! 覆盖 MSA 真机验收里分不开的那几类: +//! - **JSON 403**:真实服务拒绝(服务端文案直出,引导查许可/区域); +//! - **HTML 403**:网关/风控拦截页(引导查网络/代理,独立细分码 `*_non_json`); +//! - **空体 403**:同样按「不确定」分流(`*_empty_body`); +//! - 4xx / 5xx 各一,外加 XErr 优先级与 JSON 码的向后兼容。 +//! +//! 失败日志(JSONL)与凭据红线在同一目录的 `auth_failure_journal.rs`。 + +use serde_json::json; +use unlucky_core::auth::{AuthError, BodyKind}; + +use crate::support::{ + assert_no_credentials, auth_for, full_routes, response_of, routes_until_login, AuthMock, + MockReply, HTML_BLOCK_PAGE, +}; + +#[tokio::test] +async fn json_403_surfaces_server_message_and_points_at_license() { + let mut routes = routes_until_login(); + routes.push(( + "/login_with_xbox", + vec![MockReply::json_status( + 403, + json!({ + "path": "/authentication/login_with_xbox", + "error": "UNAUTHORIZED", + "errorMessage": "You do not own Minecraft Java Edition" + }), + )], + )); + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + + assert_eq!(error.code(), "denied", "粗分流层保持 M31 的 denied"); + assert_eq!(error.detail_code(), "login_with_xbox_403"); + assert!(!error.is_transient(), "服务端明确拒绝,重试无意义"); + let detail = response_of(&error); + assert_eq!(detail.status, 403); + assert_eq!(detail.kind, BodyKind::Json); + assert_eq!(detail.content_type.as_deref(), Some("application/json")); + let message = error.message(); + // 服务端文案直出(验收要看到真实原因)。 + assert!(message.contains("You do not own"), "{message}"); + assert!(message.contains("UNAUTHORIZED"), "{message}"); + assert!(message.contains("许可"), "应引导查许可:{message}"); + assert!( + !message.contains("代理"), + "JSON 拒绝不该往网络引:{message}" + ); + assert!(message.contains("响应摘要"), "{message}"); + assert_no_credentials(&message); + mock.stop(); +} + +#[tokio::test] +async fn html_403_is_routed_to_network_and_proxy() { + let mut routes = routes_until_login(); + routes.push(( + "/login_with_xbox", + vec![MockReply::html(403, HTML_BLOCK_PAGE)], + )); + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + + assert_eq!(error.code(), "denied"); + assert_eq!( + error.detail_code(), + "login_with_xbox_non_json", + "拦截页要有独立细分码" + ); + assert!(error.is_transient(), "拦截页可以换代理重试"); + let detail = response_of(&error); + assert_eq!(detail.kind, BodyKind::Html); + assert!(detail.is_interception()); + assert_eq!(detail.status, 403); + assert!( + detail + .content_type + .as_deref() + .is_some_and(|value| value.contains("text/html")), + "{:?}", + detail.content_type + ); + let message = error.message(); + assert!(message.contains("响应非 JSON"), "{message}"); + assert!(message.contains("网络"), "{message}"); + assert!(message.contains("代理"), "{message}"); + assert!( + message.contains("blocked by gateway"), + "摘要要留下证据:{message}" + ); + assert!( + !message.contains("许可"), + "拦截页不该把用户引向许可:{message}" + ); + assert_no_credentials(&message); + mock.stop(); +} + +#[tokio::test] +async fn empty_403_reports_the_empty_body() { + let mut routes = routes_until_login(); + routes.push(("/login_with_xbox", vec![MockReply::status(403)])); + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + + assert_eq!(error.detail_code(), "login_with_xbox_empty_body"); + let detail = response_of(&error); + assert_eq!(detail.kind, BodyKind::Empty); + assert!(detail.digest.is_empty()); + assert!(error.message().contains("为空"), "{}", error.message()); + mock.stop(); +} + +#[tokio::test] +async fn profile_4xx_keeps_the_step_status_code() { + let mut routes = full_routes(); + routes[6].1 = vec![MockReply::json_status( + 401, + json!({ "path": "/minecraft/profile", "error": "Unauthorized" }), + )]; + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + assert_eq!(error.detail_code(), "profile_401"); + assert!(error.message().contains("档案"), "{}", error.message()); + assert_no_credentials(&error.message()); + mock.stop(); +} + +#[tokio::test] +async fn entitlements_5xx_text_is_an_interception_not_a_license_verdict() { + let mut routes = full_routes(); + routes[5].1 = vec![MockReply::text(503, "Service Unavailable")]; + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + // 关键:网关页不能报成「你没买游戏」(M31 的 no_entitlement 只留给 JSON/空体)。 + assert_eq!(error.detail_code(), "entitlements_non_json"); + let detail = response_of(&error); + assert_eq!(detail.kind, BodyKind::Text); + assert_eq!(detail.status, 503); + assert!( + error.message().contains("Service Unavailable"), + "{}", + error.message() + ); + mock.stop(); +} + +#[tokio::test] +async fn entitlements_404_with_empty_body_still_reports_no_entitlement() { + let mut routes = full_routes(); + routes[5].1 = vec![MockReply::status(404)]; + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + // 向后兼容:M31 的稳定码 `no_entitlement` 不动。 + match error { + AuthError::Denied { code, description } => { + assert_eq!(code, "no_entitlement"); + assert!(description.contains("404"), "{description}"); + } + other => panic!("应是 Denied,实际 {other:?}"), + } + mock.stop(); +} + +#[tokio::test] +async fn entitlements_403_with_empty_body_is_not_a_license_verdict() { + let mut routes = full_routes(); + routes[5].1 = vec![MockReply::status(403)]; + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + // 403 且没正文:说不清是许可还是拦截,按「不确定」报,不下「没买游戏」的结论。 + assert_eq!(error.detail_code(), "entitlements_empty_body"); + mock.stop(); +} + +#[tokio::test] +async fn xsts_401_without_xerr_still_reports_body_context() { + let mut routes = routes_until_login(); + routes[3].1 = vec![MockReply::html(401, HTML_BLOCK_PAGE)]; + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + assert_eq!(error.detail_code(), "xsts_non_json"); + assert!(error.message().contains("XSTS"), "{}", error.message()); + mock.stop(); +} + +#[tokio::test] +async fn xerr_still_wins_over_the_body_diagnostic() { + let mut routes = routes_until_login(); + routes[3].1 = vec![MockReply::json_status( + 401, + json!({ "XErr": 2_148_916_235_u32, "Message": "country unavailable" }), + )]; + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + match error { + AuthError::Xbox { code } => assert_eq!(code, 2_148_916_235), + other => panic!("XErr 判定必须优先,实际 {other:?}"), + } + mock.stop(); +} diff --git a/crates/core/tests/auth/microsoft_flow.rs b/crates/core/tests/auth/microsoft_flow.rs new file mode 100644 index 0000000..3cc3cb3 --- /dev/null +++ b/crates/core/tests/auth/microsoft_flow.rs @@ -0,0 +1,274 @@ +//! device-code 全链(打到进程内 mock 服务器):报文形状 + 轮询状态机 + 错误分流。 + +use std::time::Duration; + +use serde_json::json; +use unlucky_core::auth::microsoft::{ + DeviceCodePrompt, MicrosoftAuth, MicrosoftEndpoints, PollPolicy, +}; +use unlucky_core::auth::AuthError; + +use crate::support::{AuthMock, MockReply}; + +const PROFILE_ID: &str = "0f8fad5bd9cb469fa16570867728950e"; +const CLIENT_ID: &str = "test-client-id"; + +fn fast_poll() -> PollPolicy { + PollPolicy { + min_interval: Duration::from_millis(1), + slow_down_step: Duration::from_millis(1), + max_wait: Duration::from_secs(5), + } +} + +fn device_code_reply() -> MockReply { + MockReply::json(json!({ + "user_code": "ABCD-1234", + "device_code": "device-code-secret-value", + "verification_uri": "https://www.microsoft.com/link", + "expires_in": 900, + "interval": 0, + "message": "go to the url" + })) +} + +fn chain_replies() -> Vec<(&'static str, Vec)> { + vec![ + ("/devicecode", vec![device_code_reply()]), + ( + "/token", + vec![ + MockReply::json(json!({ "error": "authorization_pending" })), + MockReply::json(json!({ + "access_token": "live-access-token-value", + "refresh_token": "live-refresh-token-value" + })), + ], + ), + ( + "/xbl", + vec![MockReply::json(json!({ + "Token": "xbl-token-value", + "DisplayClaims": { "xui": [{ "uhs": "uhs-1" }] } + }))], + ), + ( + "/xsts", + vec![MockReply::json(json!({ + "Token": "xsts-token-value", + "DisplayClaims": { "xui": [{ "uhs": "uhs-1" }] } + }))], + ), + ( + "/login_with_xbox", + vec![MockReply::json(json!({ + "username": "Steve", + "access_token": "mc-access-token-value", + "token_type": "Bearer", + "expires_in": 86_400 + }))], + ), + ( + "/entitlements/mcstore", + vec![MockReply::json(json!({ "items": [] }))], + ), + ( + "/profile", + vec![MockReply::json( + json!({ "id": PROFILE_ID, "name": "Steve" }), + )], + ), + ] +} + +#[tokio::test] +async fn device_code_login_runs_the_whole_chain() { + let mock = AuthMock::start(chain_replies()).await; + let auth = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(&mock.base())) + .with_poll_policy(fast_poll()); + + let mut prompted: Option = None; + let session = auth + .device_code_login(|prompt| prompted = Some(prompt.clone())) + .await + .expect("登录成功"); + + // 1. 用户码/URL 在轮询前就交给宿主了(CLI 才能先打印再等)。 + let prompt = prompted.expect("必须回调展示用户码"); + assert_eq!(prompt.user_code, "ABCD-1234"); + assert_eq!(prompt.verification_uri, "https://www.microsoft.com/link"); + assert_eq!(prompt.expires_in, Duration::from_secs(900)); + assert_eq!(prompt.interval, Duration::ZERO, "服务端 interval 原样收下"); + let (line1, line2) = prompt.lines(); + assert!(line1.contains("https://www.microsoft.com/link")); + assert!(line2.contains("ABCD-1234")); + + // 2. 会话字段:MC token 落 access_token,Live refresh 落 refresh_token。 + assert_eq!(session.profile_id, PROFILE_ID); + 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!(session.not_after_ms > 1_577_836_800_000); + assert!(session.has_profile_name()); + // Debug 里不能出现任何 token。 + let debug = format!("{session:?}"); + assert!(!debug.contains("mc-access-token-value"), "{debug}"); + assert!(!debug.contains("live-refresh-token-value"), "{debug}"); + + // 3. 报文形状(这几处抄错就会 400,必须逐条盯住)。 + let device_bodies = mock.bodies_on("/devicecode"); + assert_eq!(device_bodies.len(), 1); + assert!(device_bodies[0].contains("client_id=test-client-id")); + assert!( + device_bodies[0].contains("scope=XboxLive.signin+offline_access") + || device_bodies[0].contains("scope=XboxLive.signin%20offline_access") + ); + + let token_bodies = mock.bodies_on("/token"); + assert_eq!(token_bodies.len(), 2, "pending 一次后成功"); + for body in &token_bodies { + assert!(body.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code")); + assert!(body.contains("code=device-code-secret-value")); + assert!(!body.contains("{"), "表单体不该是 JSON:{body}"); + } + + let xbl = &mock.bodies_on("/xbl")[0]; + // `d=` 前缀是 MSA 消费版票据标记(漏了就 400)。 + assert!( + xbl.contains("\"RpsTicket\":\"d=live-access-token-value\""), + "{xbl}" + ); + assert!(xbl.contains("user.auth.xboxlive.com")); + + let xsts = &mock.bodies_on("/xsts")[0]; + assert!(xsts.contains("rp://api.minecraftservices.com/"), "{xsts}"); + assert!(xsts.contains("\"SandboxId\":\"RETAIL\""), "{xsts}"); + assert!(xsts.contains("xbl-token-value"), "{xsts}"); + + let login = &mock.bodies_on("/login_with_xbox")[0]; + assert!( + login.contains("XBL3.0 x=uhs-1;xsts-token-value"), + "identityToken 拼接错:{login}" + ); + + mock.stop(); +} + +#[tokio::test] +async fn slow_down_backs_off_instead_of_failing() { + let mut routes = chain_replies(); + // 把 /token 换成 pending → slow_down → 成功。 + routes[1].1 = vec![ + MockReply::json(json!({ "error": "authorization_pending" })), + MockReply::json(json!({ "error": "slow_down" })), + MockReply::json(json!({ + "access_token": "live-access-token-value", + "refresh_token": "live-refresh-token-value" + })), + ]; + let mock = AuthMock::start(routes).await; + let auth = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(&mock.base())) + .with_poll_policy(fast_poll()); + let session = auth.device_code_login(|_| {}).await.expect("登录成功"); + assert_eq!(session.profile_name, "Steve"); + assert_eq!(mock.bodies_on("/token").len(), 3, "三次轮询"); + mock.stop(); +} + +#[tokio::test] +async fn access_denied_is_reported_as_canceled() { + let mut routes = chain_replies(); + routes[1].1 = vec![MockReply::json(json!({ "error": "access_denied" }))]; + let mock = AuthMock::start(routes).await; + let auth = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(&mock.base())) + .with_poll_policy(fast_poll()); + let error = auth.device_code_login(|_| {}).await.expect_err("应取消"); + assert_eq!(error, AuthError::Canceled); + assert_eq!(error.code(), "canceled"); + mock.stop(); +} + +#[tokio::test] +async fn expired_device_code_is_a_timeout() { + let mut routes = chain_replies(); + routes[1].1 = vec![MockReply::json(json!({ "error": "expired_token" }))]; + let mock = AuthMock::start(routes).await; + let auth = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(&mock.base())) + .with_poll_policy(fast_poll()); + let error = auth.device_code_login(|_| {}).await.expect_err("应超时"); + assert_eq!(error.code(), "timeout"); + mock.stop(); +} + +#[tokio::test] +async fn polling_gives_up_after_the_wait_budget() { + let mut routes = chain_replies(); + let pending = MockReply::json(json!({ "error": "authorization_pending" })); + routes[1].1 = vec![pending; 64]; + let mock = AuthMock::start(routes).await; + let auth = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(&mock.base())) + .with_poll_policy(PollPolicy { + min_interval: Duration::from_millis(1), + slow_down_step: Duration::from_millis(1), + max_wait: Duration::from_millis(20), + }); + let error = auth.device_code_login(|_| {}).await.expect_err("应超时"); + assert_eq!(error.code(), "timeout"); + assert!(mock.bodies_on("/token").len() < 64, "超时后必须停止轮询"); + mock.stop(); +} + +#[tokio::test] +async fn xsts_401_with_xerr_is_mapped() { + let mut routes = chain_replies(); + routes[2].1 = vec![MockReply::json_status( + 401, + json!({ "Identity": "0", "XErr": 2_148_916_233_u32, "Message": "no xbox account" }), + )]; + let mock = AuthMock::start(routes).await; + let auth = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(&mock.base())) + .with_poll_policy(fast_poll()); + let error = auth.device_code_login(|_| {}).await.expect_err("应失败"); + match error { + AuthError::Xbox { code } => { + assert_eq!(code, 2_148_916_233); + assert!(unlucky_core::auth::error::xbox_hint(code).contains("Xbox 档案")); + } + other => panic!("应是 Xbox 错误,实际 {other:?}"), + } + mock.stop(); +} + +#[tokio::test] +async fn device_code_endpoint_failure_is_denied() { + let mock = AuthMock::start(vec![( + "/devicecode", + vec![MockReply::json_status( + 400, + json!({ "error": "invalid_client", "error_description": "bad client id" }), + )], + )]) + .await; + let auth = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(&mock.base())) + .with_poll_policy(fast_poll()); + let error = auth.device_code_login(|_| {}).await.expect_err("应失败"); + match error { + AuthError::Denied { code, .. } => assert_eq!(code, "invalid_client"), + other => panic!("应是 Denied,实际 {other:?}"), + } + mock.stop(); +} diff --git a/crates/core/tests/auth/microsoft_refresh.rs b/crates/core/tests/auth/microsoft_refresh.rs new file mode 100644 index 0000000..1b475ba --- /dev/null +++ b/crates/core/tests/auth/microsoft_refresh.rs @@ -0,0 +1,283 @@ +//! refresh 语义与 validate 分支:轮换、换号防线、404 二义、拥有权门禁。 + +use std::time::Duration; + +use serde_json::json; +use unlucky_core::auth::microsoft::{ + MicrosoftAuth, MicrosoftEndpoints, MicrosoftSession, PollPolicy, RefreshDecision, RefreshReason, +}; +use unlucky_core::auth::Secret; + +use crate::support::{AuthMock, MockReply}; + +const PROFILE_ID: &str = "0f8fad5bd9cb469fa16570867728950e"; +const OTHER_PROFILE_ID: &str = "11111111111111111111111111111111"; +const CLIENT_ID: &str = "test-client-id"; + +fn auth_for(base: &str) -> MicrosoftAuth { + MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(base)) + .with_poll_policy(PollPolicy { + min_interval: Duration::from_millis(1), + slow_down_step: Duration::from_millis(1), + max_wait: Duration::from_secs(5), + }) +} + +/// 一个「已过期」的旧会话(`not_after` 早于当前时刻)。 +fn expired_session() -> MicrosoftSession { + MicrosoftSession::new( + PROFILE_ID, + "Steve", + Secret::new("mc-access-token-1"), + Secret::new("live-refresh-token-1"), + 3_600, + 0, + None, + ) +} + +/// 一个「新鲜」的会话。 +fn fresh_session() -> MicrosoftSession { + MicrosoftSession::new( + PROFILE_ID, + "Steve", + Secret::new("mc-access-token-1"), + Secret::new("live-refresh-token-1"), + 86_400, + unlucky_core::auth::microsoft::now_ms(), + None, + ) +} + +/// 刷新链(Live refresh + 五步)。 +fn refresh_routes(profile_id: &str, license: bool) -> Vec<(&'static str, Vec)> { + vec![ + ( + "/token", + vec![MockReply::json(json!({ + "access_token": "live-access-token-2", + "refresh_token": "live-refresh-token-2" + }))], + ), + ( + "/xbl", + vec![MockReply::json(json!({ + "Token": "xbl-token-2", + "DisplayClaims": { "xui": [{ "uhs": "uhs-2" }] } + }))], + ), + ( + "/xsts", + vec![MockReply::json(json!({ + "Token": "xsts-token-2", + "DisplayClaims": { "xui": [{ "uhs": "uhs-2" }] } + }))], + ), + ( + "/login_with_xbox", + vec![MockReply::json(json!({ + "username": "Steve", + "access_token": "mc-access-token-2", + "expires_in": 86_400 + }))], + ), + ( + "/entitlements/mcstore", + vec![MockReply::json(json!({ "items": [] }))], + ), + ( + "/entitlements/license", + vec![MockReply::json(if license { + json!({ "items": [{ "name": "game_minecraft" }] }) + } else { + json!({ "items": [{ "name": "product_minecraft" }] }) + })], + ), + ( + "/profile", + vec![MockReply::json( + json!({ "id": profile_id, "name": "Steve" }), + )], + ), + ] +} + +#[test] +fn refresh_decision_table() { + let expired = expired_session(); + assert_eq!( + expired.refresh_decision(unlucky_core::auth::microsoft::now_ms()), + RefreshDecision::Refresh(RefreshReason::Expired) + ); + let fresh = fresh_session(); + assert_eq!( + fresh.refresh_decision(unlucky_core::auth::microsoft::now_ms()), + RefreshDecision::Reuse + ); +} + +#[tokio::test] +async fn refresh_rotates_tokens_and_keeps_profile() { + let mock = AuthMock::start(refresh_routes(PROFILE_ID, true)).await; + let auth = auth_for(&mock.base()); + let old = expired_session(); + let (refreshed, reason) = auth.ensure_fresh(&old).await.expect("刷新成功"); + + assert_eq!(reason, Some(RefreshReason::Expired)); + assert_eq!(refreshed.profile_id, PROFILE_ID); + // 轮换:access 与 refresh **都**换新(refresh 返回新 refresh_token)。 + assert_eq!(refreshed.access_token.expose(), "mc-access-token-2"); + assert_eq!(refreshed.refresh_token.expose(), "live-refresh-token-2"); + assert_ne!(refreshed.access_token, old.access_token); + assert_ne!(refreshed.refresh_token, old.refresh_token); + assert!(refreshed.not_after_ms > old.not_after_ms); + + // 刷新报文:拿旧 refresh_token 走 refresh_token grant。 + let body = &mock.bodies_on("/token")[0]; + assert!(body.contains("grant_type=refresh_token"), "{body}"); + assert!( + body.contains("refresh_token=live-refresh-token-1"), + "{body}" + ); + // 整链重跑:XBL 用的是**新的** Live access token。 + let xbl = &mock.bodies_on("/xbl")[0]; + assert!(xbl.contains("d=live-access-token-2"), "{xbl}"); + mock.stop(); +} + +#[tokio::test] +async fn fresh_session_needs_no_network() { + let mock = AuthMock::start(vec![("/token", vec![MockReply::status(500)])]).await; + let auth = auth_for(&mock.base()); + let (session, reason) = auth.ensure_fresh(&fresh_session()).await.expect("直接复用"); + assert!(reason.is_none()); + assert_eq!(session.access_token.expose(), "mc-access-token-1"); + assert!(mock.requests().is_empty(), "不该打网络"); + mock.stop(); +} + +#[tokio::test] +async fn refresh_that_changes_profile_is_rejected() { + let mock = AuthMock::start(refresh_routes(OTHER_PROFILE_ID, true)).await; + let auth = auth_for(&mock.base()); + let error = auth.refresh(&expired_session()).await.expect_err("应拒绝"); + assert_eq!(error.code(), "malformed"); + assert!(error.message().contains("档案变了"), "{}", error.message()); + mock.stop(); +} + +#[tokio::test] +async fn revoked_refresh_token_requires_relogin() { + let mock = AuthMock::start(vec![( + "/token", + vec![MockReply::json_status( + 400, + json!({ + "error": "invalid_grant", + "error_description": "AADSTS70000: The grant is expired or revoked" + }), + )], + )]) + .await; + let auth = auth_for(&mock.base()); + let error = auth.refresh(&expired_session()).await.expect_err("应失败"); + assert!(error.is_credential_expired(), "{error:?}"); + mock.stop(); +} + +#[tokio::test] +async fn validate_reports_token_state_without_swallowing_profile_errors() { + // 200 → 有效 + let ok = AuthMock::start(vec![( + "/profile", + vec![MockReply::json( + json!({ "id": PROFILE_ID, "name": "Steve" }), + )], + )]) + .await; + assert!(auth_for(&ok.base()) + .validate(&fresh_session()) + .await + .expect("有效")); + ok.stop(); + + // 401 → token 失效(返回值,不是错误) + let unauthorized = AuthMock::start(vec![("/profile", vec![MockReply::status(401)])]).await; + assert!(!auth_for(&unauthorized.base()) + .validate(&fresh_session()) + .await + .expect("失效")); + unauthorized.stop(); + + // 404 + 没有 game_minecraft → 「没买游戏」是真实错误,不当 token 失效 + let no_license = AuthMock::start(vec![ + ("/profile", vec![MockReply::status(404)]), + ( + "/entitlements/license", + vec![MockReply::json( + json!({ "items": [{ "name": "product_minecraft" }] }), + )], + ), + ]) + .await; + let error = auth_for(&no_license.base()) + .validate(&fresh_session()) + .await + .expect_err("应上抛"); + assert_eq!(error.code(), "denied"); + assert!(error.message().contains("没有购买"), "{}", error.message()); + no_license.stop(); + + // 404 + 有 game_minecraft → 「买了没建档案」 + let no_profile = AuthMock::start(vec![ + ("/profile", vec![MockReply::status(404)]), + ( + "/entitlements/license", + vec![MockReply::json( + json!({ "items": [{ "name": "game_minecraft" }] }), + )], + ), + ]) + .await; + let error = auth_for(&no_profile.base()) + .validate(&fresh_session()) + .await + .expect_err("应上抛"); + assert!( + error.message().contains("还没有创建档案"), + "{}", + error.message() + ); + no_profile.stop(); +} + +#[tokio::test] +async fn missing_entitlement_fails_the_chain_early() { + let mut routes = refresh_routes(PROFILE_ID, true); + routes[4].1 = vec![MockReply::status(404)]; + let mock = AuthMock::start(routes).await; + let auth = auth_for(&mock.base()); + let error = auth.refresh(&expired_session()).await.expect_err("应失败"); + match error { + unlucky_core::auth::AuthError::Denied { code, .. } => assert_eq!(code, "no_entitlement"), + other => panic!("应是 Denied,实际 {other:?}"), + } + mock.stop(); +} + +#[tokio::test] +async fn uhs_mismatch_between_xbl_and_xsts_is_malformed() { + let mut routes = refresh_routes(PROFILE_ID, true); + routes[2].1 = vec![MockReply::json(json!({ + "Token": "xsts-token-2", + "DisplayClaims": { "xui": [{ "uhs": "uhs-diFFERENT" }] } + }))]; + let mock = AuthMock::start(routes).await; + let auth = auth_for(&mock.base()); + let error = auth.refresh(&expired_session()).await.expect_err("应失败"); + assert_eq!(error.code(), "malformed"); + assert!(error.message().contains("userhash"), "{}", error.message()); + mock.stop(); +} diff --git a/crates/core/tests/auth/offline_uuid.rs b/crates/core/tests/auth/offline_uuid.rs new file mode 100644 index 0000000..eda7f7e --- /dev/null +++ b/crates/core/tests/auth/offline_uuid.rs @@ -0,0 +1,105 @@ +//! 离线 UUID 与 **JDK 对拍**(这就是「HMCL 兼容」的定义)。 +//! +//! 期望值不是我们算出来再抄回来的,而是用 JDK 25 跑 +//! `UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(UTF_8))` +//! 生成的(驱动源码见下方注释里的等价 Java)。HMCL 的 +//! `OfflineAccountFactory.getUUIDFromUserName` 就是这一行,因此对拍 JDK 即对拍 HMCL。 +//! +//! ```java +//! UUID id = UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8)); +//! System.out.println(id.toString().replace("-", "")); +//! ``` + +use unlucky_core::auth::offline::{offline_login, offline_meta, offline_uuid, offline_uuid_dashed}; +use unlucky_core::auth::AccountKind; + +/// `(名字, JDK 输出 32 位无连字符)`。 +const JDK_VECTORS: [(&str, &str); 7] = [ + ("Notch", "b50ad385829d3141a2167e7d7539ba7f"), + ("jeb_", "a762f5604fce3236812ab80efff0b62b"), + ("Player", "a01e3843e5213998958af459800e4d11"), + ("Steve", "5627dd98e6be3c21b8a8e92344183641"), + // 非 ASCII:UTF-8 字节参与 MD5(中文名离线服常见)。 + ("小明", "e1fa999fd9e93419bc012f51157a00e5"), + ("name with space", "46c02d33ff53313b813aa95508518305"), + ("AskaEth", "5c5918fb61303d6eb8d8eb2d1da6e439"), +]; + +#[test] +fn matches_jdk_name_uuid_from_bytes() { + for (name, expected) in JDK_VECTORS { + assert_eq!( + offline_uuid(name), + expected, + "离线 UUID 与 JDK/HMCL 不一致:{name}" + ); + } +} + +#[test] +fn dashed_form_is_the_same_uuid() { + for (name, expected) in JDK_VECTORS { + let dashed = offline_uuid_dashed(name); + assert_eq!(dashed.replace('-', ""), expected, "{name} 的连字符形式"); + assert_eq!(dashed.len(), 36); + assert_eq!(dashed.matches('-').count(), 4); + } +} + +#[test] +fn version_is_3_and_variant_is_ietf() { + for (name, raw) in JDK_VECTORS { + let version_nibble = &raw[12..13]; + assert_eq!(version_nibble, "3", "{name} 必须是 v3"); + let variant = u8::from_str_radix(&raw[16..17], 16).expect("变体位"); + assert!((0x8..=0xb).contains(&variant), "{name} 变体必须是 10xx"); + } +} + +#[test] +fn uuid_depends_on_exact_bytes_not_case() { + // 大小写不同 → 不同身份(服务端就是按字节算的,别做「友好」归一化)。 + assert_ne!(offline_uuid("Steve"), offline_uuid("steve")); + // 前缀也参与哈希:直接哈希名字会得到另一个 UUID。 + assert_ne!(offline_uuid("Notch"), offline_uuid("OfflinePlayer:Notch")); +} + +#[test] +fn offline_login_matches_hmcl_behaviour() { + let meta = offline_meta("小明", 1).expect("建元数据"); + assert_eq!(meta.kind, AccountKind::Offline); + assert_eq!(meta.profile_id, "e1fa999fd9e93419bc012f51157a00e5"); + assert_eq!(meta.profile_name, "小明"); + + let placeholders = offline_login(&meta).expect("占位符"); + // uuid 必须是**无连字符** 32 位(HMCL 的 UUIDs.toCompactString)。 + assert_eq!(placeholders.uuid, meta.profile_id); + assert_eq!(placeholders.uuid.len(), 32); + assert!(!placeholders.uuid.contains('-')); + // accessToken = 随机 32 位十六进制(每次不同、非空:游戏不接受空 token)。 + assert_eq!(placeholders.access_token.len(), 32); + assert!(placeholders + .access_token + .chars() + .all(|ch| ch.is_ascii_hexdigit())); + assert_ne!( + placeholders.access_token, + offline_login(&meta).expect("再来一次").access_token + ); + // userType=msa、userProperties={}(承 OfflineAccount.logInWithoutSkin)。 + assert_eq!(placeholders.user_type, "msa"); + assert_eq!(placeholders.user_properties, "{}"); +} + +#[test] +fn names_are_validated_before_hashing() { + assert!(offline_meta(" ", 1).is_err()); + assert!(offline_meta("0123456789abcdefg", 1).is_err()); + // 16 字符刚好合法。 + assert!(offline_meta("0123456789abcdef", 1).is_ok()); + // 首尾空白被去掉后哈希(避免「名字带空格」算出两套 UUID)。 + assert_eq!( + offline_meta(" Steve ", 1).expect("合法").profile_id, + offline_uuid("Steve") + ); +} diff --git a/crates/core/tests/auth/store_separation.rs b/crates/core/tests/auth/store_separation.rs new file mode 100644 index 0000000..cef9a06 --- /dev/null +++ b/crates/core/tests/auth/store_separation.rs @@ -0,0 +1,240 @@ +//! 存储契约:**分文件 / 原子写 / 写序 / 容错 / 孤儿清理**。 +//! +//! 这些是「凭据红线」的落盘侧证据:元数据文件里永远搜不到 token(可安全备份/分享), +//! 凭据文件只在需要发请求时才读,且写坏了也不会让账户列表整体打不开。 + +use std::path::Path; + +use unlucky_core::auth::account::{AccountKind, AccountMeta, Credentials}; +use unlucky_core::auth::microsoft::MicrosoftSession; +use unlucky_core::auth::offline::offline_meta; +use unlucky_core::auth::{AccountStore, Secret}; + +use crate::support::temp_dir; + +const MC_TOKEN: &str = "mc-access-token-value"; +const LIVE_REFRESH: &str = "live-refresh-token-value"; + +fn microsoft_entry(created_at_ms: u64) -> (AccountMeta, Credentials) { + let meta = AccountMeta::new( + AccountKind::Microsoft, + "0f8fad5bd9cb469fa16570867728950e", + "Steve", + created_at_ms, + ); + let session = MicrosoftSession::new( + "0f8fad5bd9cb469fa16570867728950e", + "Steve", + Secret::new(MC_TOKEN), + Secret::new(LIVE_REFRESH), + 86_400, + 1_000, + None, + ); + (meta, Credentials::Microsoft(session)) +} + +fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("读文件") +} + +fn tmp_leftovers(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .expect("列目录") + .filter_map(|entry| entry.ok()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.contains(".tmp")) + .collect() +} + +#[test] +fn metadata_and_credentials_live_in_separate_files() { + let dir = temp_dir("sep"); + let mut store = AccountStore::open(&dir); + let (meta, credentials) = microsoft_entry(1); + store.insert(meta, credentials).expect("插入"); + store.save().expect("落盘"); + + let accounts_text = read(&store.paths().accounts_file()); + let credentials_text = read(&store.paths().credentials_file()); + + // 元数据:有身份、有轨道,但**没有任何 token**。 + assert!(accounts_text.contains("0f8fad5bd9cb469fa16570867728950e")); + assert!(accounts_text.contains("\"kind\": \"microsoft\"")); + assert!(!accounts_text.contains(MC_TOKEN), "{accounts_text}"); + assert!(!accounts_text.contains(LIVE_REFRESH), "{accounts_text}"); + // 凭据:有 token、有保护级别标记,但**没有**可公开的展示字段。 + assert!(credentials_text.contains(MC_TOKEN)); + assert!(credentials_text.contains(LIVE_REFRESH)); + assert!(credentials_text.contains("\"protection\": \"plain-v1\"")); + // 凭据文件里没有元数据那份「账户列表」结构(两个文件的 schema 各管各的)。 + assert!( + !credentials_text.contains("\"accounts\""), + "{credentials_text}" + ); + + // 原子写不留 tmp 垃圾。 + assert!(tmp_leftovers(&dir).is_empty(), "残留临时文件"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn roundtrip_keeps_credentials_intact() { + let dir = temp_dir("roundtrip"); + let mut store = AccountStore::open(&dir); + let (meta, credentials) = microsoft_entry(1); + let id = meta.id.clone(); + store.insert(meta, credentials.clone()).expect("插入"); + store.save().expect("落盘"); + + let reopened = AccountStore::open(&dir); + assert_eq!(reopened.accounts().len(), 1); + assert!(reopened.warnings().is_empty(), "{:?}", reopened.warnings()); + assert_eq!(reopened.credentials(&id), Some(&credentials)); + let placeholders = match reopened.credentials(&id).expect("凭据") { + Credentials::Microsoft(session) => session.to_placeholders().expect("占位符"), + other => panic!("应是微软凭据,实际 {other:?}"), + }; + assert_eq!(placeholders.player_name, "Steve"); + assert_eq!(placeholders.access_token, MC_TOKEN); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn credentials_are_written_before_metadata() { + let dir = temp_dir("order"); + let mut store = AccountStore::open(&dir); + store + .insert(offline_meta("First", 1).expect("甲"), Credentials::Offline) + .expect("插入甲"); + store.save().expect("首次落盘"); + + // 把凭据文件的位置换成目录 → 凭据写必定失败。 + let credentials_path = store.paths().credentials_file(); + std::fs::remove_file(&credentials_path).expect("删凭据文件"); + std::fs::create_dir_all(&credentials_path).expect("建同名目录"); + + store + .insert(offline_meta("Second", 2).expect("乙"), Credentials::Offline) + .expect("插入乙"); + let error = store.save().expect_err("凭据写不进去时 save 必须失败"); + assert_eq!(error.code(), "store"); + + // 关键断言:元数据没有被先写(写序正确才有的结果)。 + let accounts_text = read(&store.paths().accounts_file()); + assert!(!accounts_text.contains("Second"), "{accounts_text}"); + assert!(tmp_leftovers(&dir).is_empty(), "残留临时文件"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn corrupt_record_is_skipped_not_fatal() { + let dir = temp_dir("corrupt"); + let store = AccountStore::open(&dir); + let good = offline_meta("Good", 1).expect("合法元数据"); + let payload = serde_json::json!({ + "schemaVersion": 1, + "accounts": [ + { "id": good.id.as_str(), "kind": "offline", + "profileId": good.profile_id, "profileName": "Good", "createdAtMs": 1 }, + { "id": "account:zzzz", "kind": "offline" }, + { "id": "account:0123456789abcdef0123456789abcdef", "kind": "unknown-track" } + ] + }); + std::fs::write( + store.paths().accounts_file(), + serde_json::to_string_pretty(&payload).expect("序列化"), + ) + .expect("写元数据"); + + let reopened = AccountStore::open(&dir); + assert_eq!(reopened.accounts().len(), 1, "坏记录必须被跳过"); + assert_eq!(reopened.accounts()[0].display_name(), "Good"); + assert_eq!(reopened.warnings().len(), 2, "{:?}", reopened.warnings()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn broken_metadata_file_still_allows_empty_store() { + let dir = temp_dir("broken"); + let store = AccountStore::open(&dir); + std::fs::write(store.paths().accounts_file(), "{ this is not json").expect("写坏文件"); + let reopened = AccountStore::open(&dir); + assert!(reopened.is_empty()); + assert_eq!(reopened.warnings().len(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn orphan_credentials_are_ignored_and_cleaned_on_save() { + let dir = temp_dir("orphan"); + let store = AccountStore::open(&dir); + let orphan_id = "account:00000000000000000000000000000000"; + let payload = serde_json::json!({ + "schemaVersion": 1, + "protection": "plain-v1", + "entries": [{ "accountId": orphan_id, "data": { "kind": "offline" } }] + }); + std::fs::write( + store.paths().credentials_file(), + serde_json::to_string_pretty(&payload).expect("序列化"), + ) + .expect("写凭据"); + + let reopened = AccountStore::open(&dir); + assert!(reopened + .warnings() + .iter() + .any(|line| line.contains("没有对应账户"))); + let unknown = unlucky_core::auth::AccountId::parse(orphan_id).expect("id"); + assert!(reopened.credentials(&unknown).is_none()); + reopened.save().expect("落盘"); + let text = read(&reopened.paths().credentials_file()); + assert!(!text.contains(orphan_id), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn unknown_protection_level_is_reported() { + let dir = temp_dir("protection"); + let store = AccountStore::open(&dir); + std::fs::write( + store.paths().credentials_file(), + serde_json::to_string_pretty(&serde_json::json!({ + "schemaVersion": 1, + "protection": "hmcl-obfuscated-v1", + "entries": [] + })) + .expect("序列化"), + ) + .expect("写凭据"); + let reopened = AccountStore::open(&dir); + assert!(reopened + .warnings() + .iter() + .any(|line| line.contains("保护级别"))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn remove_drops_credential_entry_on_disk() { + let dir = temp_dir("remove"); + let mut store = AccountStore::open(&dir); + store + .insert(offline_meta("Keep", 1).expect("甲"), Credentials::Offline) + .expect("甲"); + let (meta, credentials) = microsoft_entry(2); + let id = meta.id.clone(); + store.insert(meta, credentials).expect("乙"); + store.save().expect("落盘"); + + store.remove("Steve").expect("删乙"); + store.save().expect("再落盘"); + let text = read(&store.paths().credentials_file()); + assert!(!text.contains(id.as_str()), "{text}"); + + let reopened = AccountStore::open(&dir); + assert_eq!(reopened.accounts().len(), 1); + assert_eq!(reopened.accounts()[0].display_name(), "Keep"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/core/tests/auth/support/microsoft.rs b/crates/core/tests/auth/support/microsoft.rs new file mode 100644 index 0000000..c57190a --- /dev/null +++ b/crates/core/tests/auth/support/microsoft.rs @@ -0,0 +1,136 @@ +//! 微软轨的共用夹具:`MicrosoftAuth` 构造、五步链路由表、诊断断言助手。 +//! +//! 从 `support/mod.rs` 拆出来(M38):mock 服务器本体与「业务路由/断言」各自长大, +//! 分文件后两边都不顶 400 行。路由表默认用 `[0]/[3]/[5]/[6]` 下标对应 +//! `xsts` / `login_with_xbox` / `entitlements/mcstore` / `profile`,用例按需替换。 + +use serde_json::json; +use unlucky_core::auth::microsoft::{ + MicrosoftAuth, MicrosoftEndpoints, MicrosoftSession, PollPolicy, +}; +use unlucky_core::auth::{AuthError, ResponseDetail, Secret}; + +use super::MockReply; + +/// 测试用档案 UUID(32 位无连字符)。 +pub const PROFILE_ID: &str = "0f8fad5bd9cb469fa16570867728950e"; +/// 测试用 client id。 +pub const CLIENT_ID: &str = "test-client-id"; +/// 网关/风控拦截页(HTML 403 的典型正文)。 +pub const HTML_BLOCK_PAGE: &str = "Service unavailableblocked by gateway"; + +/// 小间隔轮询策略(设备码轮询在测试里要秒级跑完)。 +pub fn fast_poll() -> PollPolicy { + PollPolicy { + min_interval: std::time::Duration::from_millis(1), + slow_down_step: std::time::Duration::from_millis(1), + max_wait: std::time::Duration::from_secs(5), + } +} + +/// 一台整表指向 mock 的 `MicrosoftAuth`。 +pub fn auth_for(base: &str) -> MicrosoftAuth { + MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(base)) + .with_poll_policy(fast_poll()) +} + +/// 一路通到 `login_with_xbox` 的路由表(后续步骤留给用例替换)。 +pub fn routes_until_login() -> Vec<(&'static str, Vec)> { + vec![ + ( + "/devicecode", + vec![MockReply::json(json!({ + "user_code": "ABCD-1234", + "device_code": "device-code-secret-value", + "verification_uri": "https://www.microsoft.com/link", + "expires_in": 900, + "interval": 0 + }))], + ), + ( + "/token", + vec![MockReply::json(json!({ + "access_token": "live-access-token-value", + "refresh_token": "live-refresh-token-value" + }))], + ), + ( + "/xbl", + vec![MockReply::json(json!({ + "Token": "xbl-token-value", + "DisplayClaims": { "xui": [{ "uhs": "uhs-1" }] } + }))], + ), + ( + "/xsts", + vec![MockReply::json(json!({ + "Token": "xsts-token-value", + "DisplayClaims": { "xui": [{ "uhs": "uhs-1" }] } + }))], + ), + ] +} + +/// 整链(含 entitlements 与 profile),便于替换中间任意一步。 +pub fn full_routes() -> Vec<(&'static str, Vec)> { + let mut routes = routes_until_login(); + routes.push(( + "/login_with_xbox", + vec![MockReply::json(json!({ + "username": "Steve", + "access_token": "mc-access-token-value", + "expires_in": 86_400 + }))], + )); + routes.push(( + "/entitlements/mcstore", + vec![MockReply::json(json!({ "items": [] }))], + )); + routes.push(( + "/profile", + vec![MockReply::json( + json!({ "id": PROFILE_ID, "name": "Steve" }), + )], + )); + routes +} + +/// 一个已过期的旧会话(刷新/校验用例用)。 +pub fn expired_session() -> MicrosoftSession { + MicrosoftSession::new( + PROFILE_ID, + "Steve", + Secret::new("mc-access-token-1"), + Secret::new("live-refresh-token-1"), + 3_600, + 0, + None, + ) +} + +/// 取 `AuthError::Response` 的诊断(不是这个变体就让用例炸掉,报出真实值)。 +pub fn response_of(error: &AuthError) -> &ResponseDetail { + match error { + AuthError::Response(detail) => detail, + other => panic!("应是 Response,实际 {other:?}"), + } +} + +/// 断言一段文案/事件里没有任何测试 token。 +pub fn assert_no_credentials(text: &str) { + for forbidden in [ + "xsts-token-value", + "xbl-token-value", + "live-access-token-value", + "live-refresh-token-value", + "device-code-secret-value", + "mc-access-token-value", + ] { + assert!( + !text.contains(forbidden), + "文案泄漏了凭据 {forbidden}:{text}" + ); + } +} diff --git a/crates/core/tests/auth/support/mod.rs b/crates/core/tests/auth/support/mod.rs new file mode 100644 index 0000000..7a32eb4 --- /dev/null +++ b/crates/core/tests/auth/support/mod.rs @@ -0,0 +1,279 @@ +//! `core::auth` 集成测试夹具:临时目录 + 进程内 HTTP 服务器(零额外依赖)。 +//! +//! 为什么要 mock 服务器:微软/authlib 两条轨的正确性全在**报文级**(`XBL3.0 x=;` +//! 的拼接、XSTS 401 带 `XErr`、`/minecraft/profile` 404 的二义复判、Yggdrasil 的 +//! `clientToken` 回传校验)——只有把整条链打到一个真实 socket 上,这些分支才被真的跑到。 +//! +//! 服务器按 **path + 顺序队列**回应答:同一 path 的第 N 次请求用第 N 个应答 +//! (设备码轮询要靠这个:`authorization_pending` → `slow_down` → 成功)。 +//! 微软轨的 `MicrosoftAuth` / 路由表 / 断言助手拆在 [`microsoft`] 子模块里(M38)。 + +#![allow(dead_code)] + +use std::collections::{HashMap, VecDeque}; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +pub mod microsoft; +pub use microsoft::{ + assert_no_credentials, auth_for, expired_session, full_routes, response_of, routes_until_login, + HTML_BLOCK_PAGE, +}; + +/// 唯一临时目录(进程内计数 + 时间戳,避免并行测试互踩)。 +pub fn temp_dir(tag: &str) -> std::path::PathBuf { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_nanos()); + let dir = std::env::temp_dir().join(format!("ull-auth-test-{tag}-{stamp}-{unique}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("创建临时目录失败"); + dir +} + +/// 一次 mock 应答。 +#[derive(Debug, Clone)] +pub struct MockReply { + /// HTTP 状态码。 + pub status: u16, + /// 响应体(JSON 文本或空)。 + pub body: String, + /// `Content-Type`(默认 `application/json`;拦截页场景可改成 `text/html`)。 + pub content_type: &'static str, +} + +impl MockReply { + /// 200 + JSON。 + pub fn json(value: serde_json::Value) -> Self { + Self { + status: 200, + body: value.to_string(), + content_type: "application/json", + } + } + + /// 指定状态码 + JSON。 + pub fn json_status(status: u16, value: serde_json::Value) -> Self { + Self { + status, + body: value.to_string(), + content_type: "application/json", + } + } + + /// 指定状态码 + 空体。 + pub fn status(status: u16) -> Self { + Self { + status, + body: String::new(), + content_type: "application/json", + } + } + + /// 指定状态码 + HTML 拦截页(`Content-Type: text/html`)——M38 的网络分流用。 + pub fn html(status: u16, body: &str) -> Self { + Self { + status, + body: body.to_string(), + content_type: "text/html; charset=utf-8", + } + } + + /// 指定状态码 + 纯文本(网关页的另一种常见形状)。 + pub fn text(status: u16, body: &str) -> Self { + Self { + status, + body: body.to_string(), + content_type: "text/plain", + } + } +} + +/// 收到的一次请求(断言报文形状用)。 +#[derive(Debug, Clone)] +pub struct Recorded { + /// 方法(大写)。 + pub method: String, + /// 路径(不含 query)。 + pub path: String, + /// 请求体(表单或 JSON 原文)。 + pub body: String, +} + +/// 进程内 HTTP/1.1 服务器(按 path 排队回应答)。 +pub struct AuthMock { + addr: SocketAddr, + requests: Arc>>, + handle: tokio::task::JoinHandle<()>, +} + +impl AuthMock { + /// 启动服务器。`routes` 是 `(path, 该 path 依次使用的应答队列)`。 + pub async fn start(routes: Vec<(&str, Vec)>) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("绑定端口"); + let addr = listener.local_addr().expect("取地址"); + let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let queues: Arc>>> = Arc::new(Mutex::new( + routes + .into_iter() + .map(|(path, replies)| (path.to_string(), replies.into())) + .collect(), + )); + + let recorded = Arc::clone(&requests); + let queues_for_task = Arc::clone(&queues); + let handle = tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + let recorded = Arc::clone(&recorded); + let queues = Arc::clone(&queues_for_task); + tokio::spawn(async move { + let _ = serve(stream, recorded, queues).await; + }); + } + }); + + Self { + addr, + requests, + handle, + } + } + + /// 基址(喂给 `MicrosoftEndpoints::all_on` / `AuthlibEndpoints` 的根)。 + pub fn base(&self) -> String { + format!("http://{}", self.addr) + } + + /// 端口。 + pub fn port(&self) -> u16 { + self.addr.port() + } + + /// 已收到的请求(按到达顺序)。 + pub fn requests(&self) -> Vec { + self.requests + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + } + + /// 找某 path 的请求体列表。 + pub fn bodies_on(&self, path: &str) -> Vec { + self.requests() + .into_iter() + .filter(|request| request.path == path) + .map(|request| request.body) + .collect() + } + + /// 关停。 + pub fn stop(self) { + self.handle.abort(); + } +} + +async fn serve( + mut stream: TcpStream, + recorded: Arc>>, + queues: Arc>>>, +) -> std::io::Result<()> { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 2048]; + // 读头 + let header_end = loop { + let read = stream.read(&mut chunk).await?; + if read == 0 { + return Ok(()); + } + buffer.extend_from_slice(&chunk[..read]); + if let Some(position) = find_headers_end(&buffer) { + break position; + } + }; + let head = String::from_utf8_lossy(&buffer[..header_end]).to_string(); + let content_length = head + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.trim().eq_ignore_ascii_case("content-length") { + value.trim().parse::().ok() + } else { + None + } + }) + .unwrap_or(0); + while buffer.len() < header_end + content_length { + let read = stream.read(&mut chunk).await?; + if read == 0 { + break; + } + buffer.extend_from_slice(&chunk[..read]); + } + let body = String::from_utf8_lossy( + &buffer[header_end..(header_end + content_length).min(buffer.len())], + ) + .to_string(); + + let mut first_line = head.lines().next().unwrap_or_default().split(' '); + let method = first_line.next().unwrap_or_default().to_ascii_uppercase(); + let raw_path = first_line.next().unwrap_or("/"); + let path = raw_path.split('?').next().unwrap_or("/").to_string(); + + recorded + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push(Recorded { + method, + path: path.clone(), + body, + }); + + let reply = queues + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get_mut(&path) + .and_then(VecDeque::pop_front) + .unwrap_or_else(|| MockReply::status(500)); + + let mut response = format!( + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + reply.status, + reason(reply.status), + reply.content_type, + reply.body.len() + ) + .into_bytes(); + response.extend_from_slice(reply.body.as_bytes()); + stream.write_all(&response).await?; + stream.flush().await?; + Ok(()) +} + +fn find_headers_end(buffer: &[u8]) -> Option { + buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| position + 4) +} + +fn reason(status: u16) -> &'static str { + match status { + 200 => "OK", + 400 => "Bad Request", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "Not Found", + 500 => "Internal Server Error", + _ => "Status", + } +} diff --git a/crates/core/tests/diagnose/collectors_probe.rs b/crates/core/tests/diagnose/collectors_probe.rs new file mode 100644 index 0000000..1c81c7e --- /dev/null +++ b/crates/core/tests/diagnose/collectors_probe.rs @@ -0,0 +1,233 @@ +//! 五类信号采集:环形缓冲语义 / 退出码分类 / 磁盘采集器 / 引擎输入拼装。 + +use std::fs; +use std::time::{Duration, SystemTime}; + +use crate::prelude::*; + +#[test] +fn log_buffer_keeps_key_lines_when_evicted() { + let mut buffer = LogBuffer::new(2); + buffer.push(SignalOrigin::Stdout, "java.lang.Exception: boom"); + buffer.push(SignalOrigin::Stdout, "line 2"); + buffer.push(SignalOrigin::Stdout, "line 3"); + buffer.push(SignalOrigin::Stderr, "line 4"); + + assert_eq!(buffer.pushed(), 4); + assert_eq!(buffer.dropped(), 2); + assert_eq!(buffer.kept_key_lines(), 1, "关键行被淘汰后仍应保留"); + assert_eq!(buffer.len(), 3, "保留 1 行关键历史 + 环形区 2 行"); + let text = buffer.text(); + assert!(text.contains("boom"), "根因行不该被挤出缓冲区"); + assert!(text.contains("line 4")); + assert!(!text.contains("line 2")); + assert!(!buffer.is_empty()); +} + +#[test] +fn exit_types_match_launch_contract() { + // 字符串值与 launch-接口约定.md §3 exit.exit_type 完全一致 + let kill = ExitSignal::classify(Some(137), Some(60_000), 0, ""); + assert_eq!(kill.exit_type, ExitType::Sigkill); + assert_eq!(kill.exit_type.as_str(), "sigkill"); + assert_eq!(ExitType::from_name("sigkill"), ExitType::Sigkill); + + let term = ExitSignal::classify(Some(143), Some(60_000), 1, ""); + assert_eq!(term.exit_type, ExitType::Sigterm); + + let jvm = ExitSignal::classify( + Some(1), + Some(800), + 2, + "Error occurred during initialization of VM\nCould not create the Java Virtual Machine", + ); + assert_eq!(jvm.exit_type, ExitType::JvmError); + assert!(!jvm.silent_failure); + + let normal = ExitSignal::classify(Some(0), Some(600_000), 0, "Game started fine"); + assert_eq!(normal.exit_type, ExitType::Normal); + assert!(!normal.silent_failure); + assert!(normal.status_hex().is_none()); + + // exit 0 + 短命 + 有错误行 → 静默失败(HMCL 会判 NORMAL 放过) + let silent = ExitSignal::classify(Some(0), Some(5_000), 4, "java.lang.Exception: boom"); + assert_eq!(silent.exit_type, ExitType::Normal); + assert!(silent.silent_failure); + + // Windows 访问违例:负的 i32 → 十六进制状态码 + let access = ExitSignal::classify(Some(-1_073_741_819), Some(90_000), 0, ""); + assert!(access.is_access_violation()); + assert_eq!(access.status_hex().as_deref(), Some("0xc0000005")); + + for exit_type in [ + ExitType::Normal, + ExitType::ApplicationError, + ExitType::JvmError, + ExitType::Sigkill, + ExitType::Sigterm, + ExitType::Killed, + ExitType::Interrupted, + ] { + assert_eq!(ExitType::from_name(exit_type.as_str()), exit_type); + } +} + +#[test] +fn crash_report_collector_reads_newest_and_honours_started_at() { + let run_dir = temp_dir("collect-crash"); + let reports = run_dir.join("crash-reports"); + fs::create_dir_all(&reports).expect("建目录"); + fs::write( + reports.join("crash-2026-10-29_12.00.00-client.txt"), + "---- Minecraft Crash Report ----\nDescription: Example\n", + ) + .expect("写崩溃报告"); + + let collector = CrashReportCollector::new(); + let collectors: [&dyn SignalCollector; 1] = [&collector]; + let (bundle, errors) = collect_all(&collectors, &CollectionContext::new(&run_dir)); + assert!(errors.is_empty()); + let captured = bundle.crash_report.expect("应采到崩溃报告"); + assert!(captured.text.contains("Description: Example")); + assert_eq!(bundle.sources.len(), 1); + + // 只要「本次会话之后」的文件:把下界设到未来 → 不采 + let future = + CollectionContext::new(&run_dir).since(SystemTime::now() + Duration::from_secs(3_600)); + let (bundle, errors) = collect_all(&collectors, &future); + assert!(errors.is_empty()); + assert!(bundle.crash_report.is_none(), "历史崩溃不该算进本次"); +} + +#[test] +fn hs_err_collector_parses_newest_file() { + let run_dir = temp_dir("collect-hs-err"); + fs::write( + run_dir.join("hs_err_pid4242.log"), + "#\n# A fatal error has been detected by the Java Runtime Environment:\n#\n# EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x1, pid=4242, tid=0x2\n# Problematic frame:\n# C [nvoglv64.dll+0x10]\n", + ) + .expect("写 hs_err"); + + let collector = HsErrCollector::new(); + let collectors: [&dyn SignalCollector; 1] = [&collector]; + let (bundle, errors) = collect_all(&collectors, &CollectionContext::new(&run_dir)); + assert!(errors.is_empty()); + let report = bundle.hs_err.expect("应解析 hs_err"); + assert_eq!(report.kind, HsErrKind::AccessViolation); + assert_eq!(report.pid, Some(4242)); + assert_eq!(bundle.sources.len(), 1); +} + +#[test] +fn latest_log_collector_tails_n_lines() { + let run_dir = temp_dir("collect-latest"); + let logs = run_dir.join("logs"); + fs::create_dir_all(&logs).expect("建目录"); + let mut text = String::new(); + for index in 0..10 { + text.push_str(&format!("line {index}\n")); + } + fs::write(logs.join("latest.log"), text).expect("写日志"); + + let collector = LatestLogCollector::new().with_max_lines(4); + let collectors: [&dyn SignalCollector; 1] = [&collector]; + let (bundle, errors) = collect_all(&collectors, &CollectionContext::new(&run_dir)); + assert!(errors.is_empty()); + assert_eq!(bundle.log.pushed(), 4); + assert!(bundle.log.text().contains("line 9")); + assert!(!bundle.log.text().contains("line 5")); +} + +#[test] +fn collect_all_aggregates_pushed_and_disk_signals() { + let run_dir = temp_dir("collect-all"); + let reports = run_dir.join("crash-reports"); + fs::create_dir_all(&reports).expect("建目录"); + fs::write( + reports.join("crash-a.txt"), + "---- Minecraft Crash Report ----\n", + ) + .expect("写报告"); + + let snapshot = EnvironmentSnapshot { + java_major: Some(17), + java_bits: Some(64), + xmx_mb: Some(4096), + physical_memory_mb: Some(16_384), + mods: vec![ModRecord::from_file_name("sodium-fabric-0.5.8.jar")], + components: vec![unlucky_core::diagnose::collectors::ComponentVersion::new( + "fabric", "0.15.0", + )], + ..EnvironmentSnapshot::default() + }; + + let exit = ExitCollector::new(ExitSignal::classify(Some(1), Some(9_000), 1, "boom")); + let environment = EnvironmentCollector::new(snapshot); + let crash = CrashReportCollector::new(); + let hs_err = HsErrCollector::new(); + let collectors: [&dyn SignalCollector; 4] = [&exit, &environment, &crash, &hs_err]; + + let (bundle, errors) = collect_all(&collectors, &CollectionContext::new(&run_dir)); + assert!(errors.is_empty(), "单源缺失不应报错:{errors:?}"); + assert!(bundle.exit.is_some()); + assert!(bundle.crash_report.is_some()); + assert!( + bundle.hs_err.is_none(), + "没有 hs_err 文件时是 None(不是错误)" + ); + let snapshot = bundle.environment.as_ref().expect("环境快照"); + assert_eq!(snapshot.java_major, Some(17)); + assert!(snapshot.summary().contains("Java 17")); + assert_eq!(bundle.mods.len(), 1); + assert!(bundle.mods[0].enabled); + assert!(!bundle.is_empty()); +} + +#[test] +fn mod_record_parses_file_name() { + let record = ModRecord::from_file_name("jei-1.20.1-15.2.0.27.jar"); + assert_eq!(record.id, "jei-1.20.1"); + assert_eq!(record.version.as_deref(), Some("15.2.0.27")); + assert!(record.enabled); + + let disabled = ModRecord::from_file_name("optifine.jar.disabled"); + assert!(!disabled.enabled); + assert_eq!(disabled.id, "optifine"); +} + +#[test] +fn bundle_input_groups_log_by_origin_and_carries_hs_err() { + let mut bundle = SignalBundle::new(); + bundle.log.push(SignalOrigin::Stdout, "stdout line"); + bundle.log.push(SignalOrigin::Stderr, "stderr line"); + bundle.exit = Some(ExitSignal::classify(Some(1), Some(1_000), 1, "boom")); + + let input = bundle.diagnosis_input(); + assert!(input.exit.is_some()); + let origins = input + .haystacks + .iter() + .map(|haystack| haystack.origin) + .collect::>(); + assert!(origins.contains(&SignalOrigin::Stdout)); + assert!(origins.contains(&SignalOrigin::Stderr)); + assert!(!input.is_empty()); + + let mut with_hs_err = SignalBundle::new(); + with_hs_err.hs_err = Some(hs_err::parse(&read_sample( + "samples/hs_err/access_violation.log", + ))); + let input = with_hs_err.diagnosis_input(); + assert!(input.hs_err.is_some()); + assert!(input + .haystacks + .iter() + .any(|haystack| haystack.origin == SignalOrigin::HsErr)); +} + +#[test] +fn empty_bundle_produces_empty_input() { + let bundle = SignalBundle::new(); + assert!(bundle.is_empty()); + assert!(bundle.diagnosis_input().is_empty()); +} diff --git a/crates/core/tests/diagnose/engine_rank.rs b/crates/core/tests/diagnose/engine_rank.rs new file mode 100644 index 0000000..636e63f --- /dev/null +++ b/crates/core/tests/diagnose/engine_rank.rs @@ -0,0 +1,233 @@ +//! 引擎行为:主因排序 / 抑制关系 / 去重 / 内置结论 / 动作物化。 + +use crate::prelude::*; + +#[test] +fn root_cause_outranks_secondary_error() { + // 一条日志里既 OOM 又 NoClassDefFoundError:OOM 是根因(priority 80 vs 40)。 + let text = "\ +java.lang.OutOfMemoryError: Java heap space +java.lang.NoClassDefFoundError: net/minecraftforge/fml/loading/FMLEnvironment +"; + let report = analyze_text(SignalOrigin::Stdout, text); + assert_eq!( + report.primary().expect("命中").rule_id, + "memory.out_of_memory" + ); + assert!( + ids(&report).contains(&"integrity.no_class_def_found"), + "低级错误也应给出(哪怕是次生):{:?}", + ids(&report) + ); +} + +#[test] +fn suppress_moves_rule_into_secondary() { + // 32 位 Java 分配失败 + 次生 OutOfMemoryError + let text = "\ +Could not reserve enough space for 4194304KB object heap +java.lang.OutOfMemoryError: Java heap space +"; + let report = analyze_text(SignalOrigin::Stdout, text); + assert_eq!(report.primary().expect("命中").rule_id, "jvm.32bit"); + let secondary = report + .secondary + .iter() + .find(|finding| finding.rule_id == "memory.out_of_memory") + .expect("OOM 应被降级为次生结论而不是丢弃"); + assert_eq!(secondary.suppressed_by.as_deref(), Some("jvm.32bit")); + assert!( + !ids(&report).contains(&"memory.out_of_memory"), + "被抑制的结论不应出现在主因列表" + ); +} + +#[test] +fn same_rule_keeps_best_evidence_source() { + // 同一条文本在两个来源出现:只留一条结论,且优先崩溃报告。 + let mut input = DiagnosisInput::new(); + input.push_text( + SignalOrigin::Stdout, + "java.lang.OutOfMemoryError: Java heap space", + ); + input.push_text( + SignalOrigin::CrashReport, + "---- Minecraft Crash Report ----\njava.lang.OutOfMemoryError: Java heap space", + ); + let report = diagnoser().analyze(&input); + let hits = report + .findings + .iter() + .filter(|finding| finding.rule_id == "memory.out_of_memory") + .count(); + assert_eq!(hits, 1, "同一规则只应报一次"); + let finding = &report.findings[0]; + assert_eq!(finding.evidence[0].origin, SignalOrigin::CrashReport); +} + +#[test] +fn access_violation_exit_is_diagnosed() { + let exit = ExitSignal::classify(Some(-1_073_741_819), Some(90_000), 0, ""); + let mut input = DiagnosisInput::new(); + input.exit = Some(exit); + let report = diagnoser().analyze(&input); + let finding = report + .findings + .iter() + .find(|finding| finding.rule_id == "signal.access_violation") + .expect("0xC0000005 应产出内置结论"); + assert_eq!(finding.category, RuleCategory::Graphics); + let action = &finding.materialized_actions()[0]; + assert_eq!(action.kind, FixActionKind::ChangeGraphics); + assert_eq!(action.tool, "graphics.set"); + assert_eq!( + action.args.get("update_driver").map(String::as_str), + Some("true") + ); +} + +#[test] +fn silent_failure_is_marked_on_exit_zero() { + // analyse/02 §7.10-10:exit 0 + 存活 <30s + 有错误行 → 静默失败,HMCL 会判 NORMAL 放过。 + let exit = ExitSignal::classify(Some(0), Some(4_200), 3, "java.lang.Exception: boom"); + assert_eq!(exit.exit_type, ExitType::Normal); + assert!(exit.silent_failure); + let mut input = DiagnosisInput::new(); + input.exit = Some(exit); + let report = diagnoser().analyze(&input); + assert!( + ids(&report).contains(&"signal.silent_failure"), + "静默失败应单独打标:{:?}", + ids(&report) + ); + // 低置信 → 交 AI 深化(PlanDoc/06 §2.3) + assert!(report.needs_more_info()); +} + +#[test] +fn empty_input_asks_for_more_information() { + let report = diagnoser().analyze(&DiagnosisInput::new()); + assert!(report.findings.is_empty()); + assert!(report.needs_more_info()); +} + +#[test] +fn conflict_rule_materializes_capture_groups() { + let text = read_sample("samples/fabric/mod_resolution_conflict.log"); + let report = analyze_text(SignalOrigin::Stdout, &text); + let primary = report.primary().expect("命中"); + assert_eq!(primary.rule_id, "fabric.mod_resolution_conflict"); + let action = &primary.materialized_actions()[0]; + assert_eq!(action.kind, FixActionKind::RemoveMod); + assert_eq!(action.tool, "mod.remove"); + assert_eq!( + action.args.get("mod_ids").map(String::as_str), + Some("sodium,sodium-extra"), + "mod_ids 应已代入命名捕获组" + ); +} + +/// 回归(review P2-1):OptiFine/Forge 兼容规则最后一个 alternative 必须是**前缀语义**, +/// 既收裸 `(Reflector.java)` 也收真实的带行号形态 `(Reflector.java:455)`。 +#[test] +fn optifine_forge_matches_both_frame_forms() { + for frame in [ + "\tTRANSFORMER/net.optifine/net.optifine.reflect.Reflector.(Reflector.java:455)", + "\tTRANSFORMER/net.optifine/net.optifine.reflect.Reflector.(Reflector.java)", + ] { + let report = analyze_text(SignalOrigin::Stdout, frame); + assert!( + ids(&report).contains(&"conflict.optifine_forge"), + "真实栈帧形态应命中:{frame}(实际命中:{:?})", + ids(&report) + ); + } +} + +#[test] +fn too_old_java_converts_class_file_version() { + let text = read_sample("samples/jvm/too_old_java.log"); + let report = analyze_text(SignalOrigin::Stdout, &text); + let primary = report.primary().expect("命中"); + assert_eq!(primary.rule_id, "jvm.too_old_java"); + let action = &primary.materialized_actions()[0]; + assert_eq!( + action.args.get("required_major").map(String::as_str), + Some("17"), + "class file version 61 应换算成 Java 17" + ); + assert_eq!(class_file_to_java_major(61), Some(17)); + assert_eq!(class_file_to_java_major(52), Some(8)); + assert_eq!(class_file_to_java_major(40), None); +} + +#[test] +fn fix_actions_are_deduplicated_across_findings() { + // 一条日志同时命中 OOM(adjust_memory)与 32bit(adjust_memory + change_java) + let text = "\ +Could not reserve enough space for 4194304KB object heap +java.lang.OutOfMemoryError: Java heap space +"; + let report = analyze_text(SignalOrigin::Stdout, text); + let actions = report.fix_actions(); + assert!(!actions.is_empty()); + let mut unique = actions.clone(); + unique.sort_by(|a, b| a.tool.cmp(b.tool).then(a.args.len().cmp(&b.args.len()))); + unique.dedup(); + assert_eq!(unique.len(), actions.len(), "fix_actions 应当已去重"); +} + +#[test] +fn record_matches_launch_diagnosis_payload() { + let text = read_sample("samples/memory/out_of_memory.log"); + let report = analyze_text(SignalOrigin::Stdout, &text); + let record = report.to_record(); + assert_eq!(record.source, "rules"); + assert!(!record.candidates.is_empty()); + let candidate = &record.candidates[0]; + assert_eq!(candidate.id, "memory.out_of_memory"); + assert_eq!(candidate.severity, "critical"); + assert_eq!(candidate.category, "memory"); + assert_eq!(candidate.message_key, "diagnose.cause.memory.out_of_memory"); + assert!(!candidate.message.is_empty(), "M16 需要渲染后的中文结论"); + assert!(!candidate.evidence.is_empty()); + assert_eq!(candidate.fix_actions[0].tool, "memory.set"); +} + +#[test] +fn hs_err_report_feeds_builtin_findings() { + let report = hs_err::parse(&read_sample("samples/hs_err/access_violation.log")); + let mut input = DiagnosisInput::new(); + input.push_hs_err(report); + let analysis = diagnoser().analyze(&input); + assert_eq!( + analysis.primary().expect("命中").rule_id, + "hs_err.access_violation" + ); + // graphics.graphics_driver 会被内置结论 suppress 成次生(同一条证据、不同粒度) + assert!( + analysis + .secondary + .iter() + .any(|finding| finding.rule_id == "graphics.graphics_driver"), + "hs_err 原文也应命中图形规则,但被降级:{:?}", + analysis + .secondary + .iter() + .map(|finding| finding.rule_id.as_str()) + .collect::>() + ); +} + +#[test] +fn file_scope_rules_survive_very_long_logs() { + // >1 MiB:首尾窗口扫描,尾部窗口里的签名仍应命中。 + let mut text = String::from("noise line\n").repeat(120_000); + text.push_str("java.lang.OutOfMemoryError: Java heap space\n"); + assert!(text.len() > 1024 * 1024); + let report = analyze_text(SignalOrigin::LatestLog, &text); + assert!( + ids(&report).contains(&"memory.out_of_memory"), + "超长日志的尾部窗口应被扫描" + ); +} diff --git a/crates/core/tests/diagnose/hs_err_parse.rs b/crates/core/tests/diagnose/hs_err_parse.rs new file mode 100644 index 0000000..a6df686 --- /dev/null +++ b/crates/core/tests/diagnose/hs_err_parse.rs @@ -0,0 +1,143 @@ +//! `hs_err_pid*.log` 解析器:标题行 / 线程段 / 内存段 / 失败帧。 + +use crate::prelude::*; + +#[test] +fn parses_access_violation_report() { + let text = read_sample("samples/hs_err/access_violation.log"); + let report = hs_err::parse(&text); + + assert_eq!(report.kind, HsErrKind::AccessViolation); + assert_eq!( + report.header.as_deref(), + Some("A fatal error has been detected by the Java Runtime Environment:") + ); + assert_eq!( + report.exception.as_deref(), + Some("EXCEPTION_ACCESS_VIOLATION (0xc0000005)") + ); + assert_eq!(report.pc.as_deref(), Some("0x00007ffb1a2b3c4d")); + assert_eq!(report.pid, Some(12344)); + assert_eq!(report.tid.as_deref(), Some("0x0000000000002a1c")); + + let frame = report.failed_frame.as_ref().expect("失败帧"); + assert_eq!(frame.kind, FrameKind::Native); + assert_eq!(frame.module.as_deref(), Some("ig8d64.dll")); + assert_eq!(frame.offset.as_deref(), Some("0x9b3c4d")); + + let thread = report.current_thread.as_deref().expect("当前线程"); + assert!(thread.contains("Render thread"), "{thread}"); + assert!(report + .jre_version + .as_deref() + .is_some_and(|line| line.contains("8.0_51"))); + assert!(report + .java_vm + .as_deref() + .is_some_and(|line| line.contains("HotSpot"))); + + let memory = report.memory.as_ref().expect("内存段"); + assert_eq!(memory.page_size.as_deref(), Some("4k")); + assert_eq!(memory.physical_kb, Some(16_741_480)); + assert_eq!(memory.physical_free_kb, Some(4_716_856)); + assert_eq!(memory.swap_kb, Some(18_672_384)); + assert_eq!(memory.swap_free_kb, Some(9_433_608)); + assert!(memory.summary().contains("GB")); + + assert!(!report.heap.is_empty(), "Heap 段应被抽取"); + assert!(report + .vm_arguments + .iter() + .any(|arg| arg.contains("-Xmx4096m"))); + assert!(report + .command_line + .as_deref() + .is_some_and(|line| line.contains("net.minecraft.launchwrapper.Launch"))); +} + +#[test] +fn digest_keeps_header_block_for_cross_line_rules() { + let report = hs_err::parse(&read_sample("samples/hs_err/access_violation.log")); + let digest = report.digest(); + // 跨行图形规则依赖「EXCEPTION_ACCESS_VIOLATION … # C [ig…」的相对位置 + assert!(digest.contains("EXCEPTION_ACCESS_VIOLATION")); + assert!(digest.contains("# C [ig8d64.dll")); + assert!(digest.contains("Current thread")); + assert!(digest.contains("Memory:")); +} + +#[test] +fn parses_out_of_memory_report() { + let report = hs_err::parse(&read_sample("samples/hs_err/out_of_memory.log")); + assert_eq!(report.kind, HsErrKind::OutOfMemory); + assert_eq!(report.pid, Some(9876)); + assert_eq!( + report.exception, None, + "OOM 型 hs_err 没有 EXCEPTION_* 行,不该瞎猜" + ); + assert!(report + .header + .as_deref() + .is_some_and(|header| header.contains("insufficient memory"))); + let memory = report.memory.as_ref().expect("内存段"); + assert_eq!(memory.physical_kb, Some(8_134_564)); + assert!(report.summary().contains("out_of_memory")); +} + +#[test] +fn parse_never_fails_on_garbage() { + for text in ["", "\n\n", "随便一段中文", "# EXCEPTION_ACCESS_VIOLATION"] { + let report = hs_err::parse(text); + // 不 panic 即可;字段可以全空 + let _ = report.digest(); + let _ = report.summary(); + } + assert_eq!(hs_err::parse("").kind, HsErrKind::Other); +} + +#[test] +fn parse_file_attaches_path() { + let path = rules_dir().join("samples/hs_err/access_violation.log"); + let report = hs_err::parse_file(&path).expect("读文件"); + assert_eq!(report.path.as_deref(), Some(path.as_path())); + assert_eq!(report.kind, HsErrKind::AccessViolation); +} + +#[test] +fn missing_file_is_an_io_error() { + let path = temp_dir("hs-err-missing").join("hs_err_pid1.log"); + assert!(hs_err::parse_file(&path).is_err()); +} + +#[test] +fn java_frame_is_classified() { + let text = "\ +# EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x0000000000000000, pid=1, tid=0x2 +# Problematic frame: +# J 123 c1 com.example.mod.Foo.bar(Ljava/lang/String;)V (12 bytes) @ 0x00007ffb +"; + let report = hs_err::parse(text); + let frame = report.failed_frame.as_ref().expect("失败帧"); + assert_eq!(frame.kind, FrameKind::Java); + assert_eq!(frame.module, None); +} + +#[test] +fn engine_reports_hs_err_out_of_memory() { + let report = hs_err::parse(&read_sample("samples/hs_err/out_of_memory.log")); + let mut input = DiagnosisInput::new(); + input.push_hs_err(report); + let analysis = diagnoser().analyze(&input); + let finding = analysis + .findings + .iter() + .find(|finding| finding.rule_id == "hs_err.out_of_memory") + .expect("hs_err OOM 应产出内置结论"); + assert_eq!(finding.category, RuleCategory::Memory); + assert!(finding.message.contains("内存不足")); + assert_eq!( + finding.materialized_actions()[0].tool, + "memory.set", + "M16 需要能直接执行的工具 id" + ); +} diff --git a/crates/core/tests/diagnose/loader_reload.rs b/crates/core/tests/diagnose/loader_reload.rs new file mode 100644 index 0000000..f402a34 --- /dev/null +++ b/crates/core/tests/diagnose/loader_reload.rs @@ -0,0 +1,171 @@ +//! 规则库加载 / 热重载 / 坏文件的降级行为。 + +use std::fs; + +use crate::prelude::*; + +const CUSTOM_MEMORY: &str = "\ +version: 1 +category: memory +rules: + - id: memory.custom_only + severity: warning + pattern: 'CUSTOM TEST SIGNATURE' + confidence: 0.9 + priority: 70 + cause: + key: diagnose.cause.memory.custom_only + summary: '测试用自定义规则' + fix_action: + - kind: seek_help + sample: samples/memory/out_of_memory.log +"; + +#[test] +fn embedded_library_is_complete() { + let loader = RuleLoader::embedded(); + assert_eq!(EMBEDDED_RULES.len(), 10, "10 个根因分组各一个 YAML"); + // 64 = HMCL 59 条全量平移 + 5 条 Vulkan 组(graphics.yaml 末尾的 4 个原因码 + // + no_graphics_backend)。加规则要同时加样本并同步这个数。 + assert_eq!(loader.rules().len(), 64); + assert!(loader.last_errors().is_empty()); + assert!(loader.dir().is_none()); +} + +#[test] +fn missing_directory_falls_back_to_embedded() { + let dir = temp_dir("loader-missing").join("nope"); + let loader = RuleLoader::from_dir(&dir); + assert_eq!(loader.rules().len(), 64); + assert!(loader.last_errors().is_empty()); +} + +#[test] +fn disk_file_replaces_embedded_group() { + let dir = temp_dir("loader-override"); + fs::write(dir.join("memory.yaml"), CUSTOM_MEMORY).expect("写规则文件"); + let loader = RuleLoader::from_dir(&dir); + + assert_eq!(loader.rules().len(), 63, "memory.yaml 的 2 条被 1 条替换"); + assert!(loader.last_errors().is_empty()); + assert!(loader.rule_set().get("memory.custom_only").is_some()); + assert!( + loader.rule_set().get("memory.out_of_memory").is_none(), + "同组文件被整份替换" + ); +} + +#[test] +fn refresh_picks_up_edits() { + let dir = temp_dir("loader-refresh"); + let mut loader = RuleLoader::from_dir(&dir); + assert_eq!( + loader.refresh(), + ReloadOutcome::Unchanged, + "没变就不该重解析" + ); + + fs::write(dir.join("memory.yaml"), CUSTOM_MEMORY).expect("写规则文件"); + assert_eq!( + loader.refresh(), + ReloadOutcome::Reloaded { rules: 63 }, + "指纹变了应重新加载" + ); + assert!(loader.rule_set().get("memory.custom_only").is_some()); +} + +#[test] +fn broken_yaml_keeps_last_good_rules() { + let dir = temp_dir("loader-broken"); + fs::write(dir.join("memory.yaml"), CUSTOM_MEMORY).expect("写规则文件"); + let mut loader = RuleLoader::from_dir(&dir); + assert_eq!(loader.rules().len(), 63); + + // 用户手改坏了 YAML:保留上一份好规则 + 报错,而不是让诊断能力归零 + fs::write( + dir.join("memory.yaml"), + "version: 1\ncategory: memory\nrules: [ oops\n", + ) + .expect("写坏文件"); + match loader.refresh() { + ReloadOutcome::Rejected { errors } => { + assert!(!errors.is_empty()); + assert_eq!( + errors[0].kind, + unlucky_core::diagnose::loader::LoadErrorKind::Yaml + ); + } + other => panic!("应被拒绝:{other:?}"), + } + assert_eq!(loader.rules().len(), 63, "坏文件不该清空规则库"); + assert!(loader.rule_set().get("memory.custom_only").is_some()); + assert!(!loader.last_errors().is_empty()); +} + +#[test] +fn duplicate_rule_id_across_files_is_rejected() { + let dir = temp_dir("loader-duplicate"); + let duplicated = "\ +version: 1 +category: other +rules: + - id: memory.out_of_memory + pattern: 'dup' + cause: + key: diagnose.cause.dup + summary: '重复 id' +"; + fs::write(dir.join("other.yaml"), duplicated).expect("写规则文件"); + let loader = RuleLoader::from_dir(&dir); + + assert_eq!(loader.rules().len(), 64, "整体失败后回退内嵌规则集"); + assert!(loader + .last_errors() + .iter() + .any(|error| error.kind == unlucky_core::diagnose::loader::LoadErrorKind::DuplicateId)); +} + +#[test] +fn unknown_field_is_rejected() { + let dir = temp_dir("loader-typo"); + let typo = "\ +version: 1 +category: other +rules: + - id: other.typo + patternn: 'typo in field name' + cause: + key: diagnose.cause.typo + summary: '字段拼错' +"; + fs::write(dir.join("other.yaml"), typo).expect("写规则文件"); + let loader = RuleLoader::from_dir(&dir); + assert_eq!(loader.rules().len(), 64); + assert!( + !loader.last_errors().is_empty(), + "deny_unknown_fields 应把拼错的字段报出来" + ); +} + +#[test] +fn rule_set_rejects_invalid_regex() { + let sources = vec![( + "bad.yaml".to_owned(), + "\ +version: 1 +category: other +rules: + - id: other.bad_regex + pattern: '(unclosed' + cause: + key: diagnose.cause.bad + summary: '坏正则' +" + .to_owned(), + )]; + let errors = RuleSet::from_sources(&sources).expect_err("坏正则应失败"); + assert_eq!( + errors[0].kind, + unlucky_core::diagnose::loader::LoadErrorKind::Regex + ); +} diff --git a/crates/core/tests/diagnose/main.rs b/crates/core/tests/diagnose/main.rs new file mode 100644 index 0000000..8f423c1 --- /dev/null +++ b/crates/core/tests/diagnose/main.rs @@ -0,0 +1,83 @@ +//! `core::diagnose` 集成测试。 +//! +//! 结构对齐 HMCL 的 `CrashReportAnalyzerTest` + `resources/logs`(analyse/02 §7.9): +//! **每条规则一个样本**(`src/diagnose/rules.d/samples/`),断言「样本命中它自己」。 +//! 规则库是数据不是代码,样本就是规则的验收标准——新增规则必须同时提交样本。 +//! +//! 跑法:`cargo test -p unlucky-core --test diagnose` + +mod collectors_probe; +mod engine_rank; +mod hs_err_parse; +mod loader_reload; +mod rules_samples; + +/// 测试共用导入与辅助函数。 +pub mod prelude { + use std::path::{Path, PathBuf}; + + pub use unlucky_core::diagnose::collectors::{ + collect_all, CollectionContext, CrashReportCollector, EnvironmentCollector, + EnvironmentSnapshot, ExitCollector, ExitSignal, ExitType, HsErrCollector, + LatestLogCollector, LogBuffer, ModRecord, SignalBundle, SignalCollector, SignalKind, + SignalOrigin, + }; + pub use unlucky_core::diagnose::engine::{ + Diagnoser, DiagnosisInput, DiagnosisReport, Evidence, Finding, CONCLUSIVE_CONFIDENCE, + }; + pub use unlucky_core::diagnose::hs_err::{self, FrameKind, HsErrKind, HsErrReport}; + pub use unlucky_core::diagnose::loader::{ReloadOutcome, RuleLoader, RuleSet, EMBEDDED_RULES}; + pub use unlucky_core::diagnose::rules::{ + class_file_to_java_major, ComponentKind, FixAction, FixActionKind, GraphicsSetting, + JavaVendor, ModAction, RuleCategory, Severity, + }; + + /// 规则目录(`crates/core/src/diagnose/rules.d`)。 + #[must_use] + pub fn rules_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("src/diagnose/rules.d") + } + + /// 读样本(路径相对 `rules.d/`)。 + pub fn read_sample(relative: &str) -> String { + let path = rules_dir().join(relative); + std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("样本 {} 读不到:{error}", path.display())) + } + + /// 用内嵌规则库跑一段文本。 + pub fn analyze_text(origin: SignalOrigin, text: &str) -> DiagnosisReport { + let mut input = DiagnosisInput::new(); + input.push_text(origin, text); + diagnoser().analyze(&input) + } + + /// 内嵌规则库构造的引擎。 + #[must_use] + pub fn diagnoser() -> Diagnoser { + Diagnoser::new(RuleLoader::embedded().rules().to_vec()) + } + + /// 结论 id 列表(断言失败时打印用)。 + #[must_use] + pub fn ids(report: &DiagnosisReport) -> Vec<&str> { + report + .findings + .iter() + .map(|finding| finding.rule_id.as_str()) + .collect() + } + + /// 临时目录(进程 id + 计数器,不引 tempfile 依赖)。 + #[must_use] + pub fn temp_dir(tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + let index = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = + std::env::temp_dir().join(format!("ull-diagnose-{}-{tag}-{index}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("创建临时目录"); + dir + } +} diff --git a/crates/core/tests/diagnose/rules_samples.rs b/crates/core/tests/diagnose/rules_samples.rs new file mode 100644 index 0000000..7573868 --- /dev/null +++ b/crates/core/tests/diagnose/rules_samples.rs @@ -0,0 +1,267 @@ +//! 规则库完整性 + 每条规则的样本回归(HMCL `CrashReportAnalyzerTest` 同构)。 + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use crate::prelude::*; + +#[test] +fn every_rule_has_a_sample_that_hits_itself() { + let loader = RuleLoader::embedded(); + let rules = loader.rules(); + // 64 = HMCL 59 条全量平移 + 5 条 Vulkan 组(rules.d/graphics.yaml 末尾: + // vulkan_loader_missing / vulkan_device_version_too_low / vulkan_missing_extension / + // vulkan_missing_feature / no_graphics_backend)。加规则记得同时加样本并改这个数。 + assert_eq!(rules.len(), 64, "HMCL 59 条 + Vulkan 5 条规则应全量在位"); + for rule in rules { + let sample = rule + .rule() + .sample + .as_deref() + .unwrap_or_else(|| panic!("规则 {} 没有 sample 字段", rule.id())); + let text = read_sample(sample); + let report = analyze_text(SignalOrigin::Stdout, &text); + let hit = report + .findings + .iter() + .chain(report.secondary.iter()) + .any(|finding| finding.rule_id == rule.id()); + assert!( + hit, + "规则 {} 的样本 {} 没有命中它自己(实际命中:{:?})", + rule.id(), + sample, + ids(&report) + ); + } +} + +#[test] +fn rule_ids_are_unique_and_namespaced() { + let loader = RuleLoader::embedded(); + let mut seen = BTreeSet::new(); + for rule in loader.rules() { + assert!(seen.insert(rule.id()), "规则 id 重复:{}", rule.id()); + let (namespace, name) = rule + .id() + .split_once('.') + .unwrap_or_else(|| panic!("规则 id 必须带命名空间:{}", rule.id())); + assert!( + !namespace.is_empty() && !name.is_empty(), + "规则 id 不合法:{}", + rule.id() + ); + } +} + +#[test] +fn rules_carry_cause_actions_and_provenance() { + for rule in RuleLoader::embedded().rules() { + let rule = rule.rule(); + assert!( + rule.cause.key.starts_with("diagnose.cause."), + "规则 {} 的 cause.key 应以 diagnose.cause. 开头", + rule.id + ); + assert!( + !rule.cause.summary.is_empty(), + "规则 {} 缺结论文案", + rule.id + ); + assert!( + !rule.fix_action.is_empty(), + "规则 {} 没有绑定六维修复动作(M16 一键修复要靠它)", + rule.id + ); + assert!( + rule.source.is_some(), + "规则 {} 缺 source(HMCL 出处)", + rule.id + ); + assert!( + (0.0..=1.0).contains(&rule.confidence), + "规则 {} 的 confidence 超出 0~1", + rule.id + ); + } +} + +#[test] +fn every_reported_category_has_rules() { + let mut categories = BTreeSet::new(); + for rule in RuleLoader::embedded().rules() { + categories.insert(rule.category()); + } + for category in RuleCategory::all() { + assert!( + categories.contains(&category), + "分类 {category} 一条规则都没有(rules.d 里缺这个文件或为空)" + ); + } +} + +#[test] +fn no_orphan_samples() { + let samples_root = rules_dir().join("samples"); + let declared = RuleLoader::embedded() + .rules() + .iter() + .filter_map(|rule| rule.rule().sample.clone()) + .collect::>(); + let mut on_disk = BTreeSet::new(); + for group in std::fs::read_dir(&samples_root).expect("samples 目录") { + let group = group.expect("目录项").path(); + if !group.is_dir() { + continue; + } + collect(&samples_root, &group, &mut on_disk); + } + for path in &on_disk { + assert!( + declared.contains(path), + "样本 {path} 没有规则引用它(孤儿样本,或 sample: 路径写错)" + ); + } +} + +/// 递归收集 `.log` 样本,返回相对 `rules.d/` 的路径。 +fn collect(root: &Path, dir: &Path, out: &mut BTreeSet) { + for entry in std::fs::read_dir(dir).expect("目录项") { + let path = entry.expect("目录项").path(); + if path.is_dir() { + collect(root, &path, out); + } else if path.extension().is_some_and(|ext| ext == "log") { + let relative = path + .strip_prefix(root) + .expect("相对路径") + .to_string_lossy() + .replace('\\', "/"); + // hs_err 样本是解析器测试素材,不对应规则。 + if !relative.starts_with("hs_err/") { + out.insert(format!("samples/{relative}")); + } + } + } +} + +#[test] +fn decisive_rule_gives_evidence_message_and_actions() { + let text = read_sample("samples/integrity/mod_files_decompressed.log"); + let report = analyze_text(SignalOrigin::Stdout, &text); + let primary = report.primary().expect("应当命中"); + assert_eq!(primary.rule_id, "integrity.mod_files_decompressed"); + assert!(!primary.message.is_empty()); + assert_eq!(primary.severity, Severity::Critical); + let evidence = primary.evidence.first().expect("证据"); + assert_eq!(evidence.origin, SignalOrigin::Stdout); + assert!(evidence.render().starts_with("stdout:")); + assert!(!primary.fix_actions.is_empty()); + + // 高置信结论不该再要求 AI 深化 + assert!( + !report.needs_more_info(), + "0.95 置信度的结论被判成「需要更多信息」" + ); + assert_eq!(report.rules_evaluated, 64); +} + +/// Vulkan 组:语义是「Vulkan 后端起不来」,所以**成功的** Vulkan 日志不该命中任何一条。 +#[test] +fn healthy_vulkan_startup_hits_no_vulkan_rule() { + let text = "\ +[12:00:21] [Render thread/INFO]: Backend library: LWJGL version 3.4.3+13 +[12:00:21] [Render thread/INFO]: Using graphics backend VULKAN, using drivers: AMD proprietary driver +[12:00:21] [Render thread/INFO]: OpenGL debug message callback registered +"; + let report = analyze_text(SignalOrigin::Stdout, text); + let hit = report + .findings + .iter() + .chain(report.secondary.iter()) + .map(|finding| finding.rule_id.as_str()) + .filter(|id| id.starts_with("graphics.vulkan") || *id == "graphics.no_graphics_backend") + .collect::>(); + assert!(hit.is_empty(), "正常的 Vulkan 启动日志被判成故障:{hit:?}"); +} + +/// Vulkan 规则必须落在 `graphics_api` 这个新维度上(否则 agent 侧不知道该改哪一项)。 +#[test] +fn vulkan_rules_point_at_the_graphics_api_setting() { + let loader = RuleLoader::embedded(); + for id in [ + "graphics.vulkan_loader_missing", + "graphics.vulkan_device_version_too_low", + "graphics.vulkan_missing_extension", + "graphics.vulkan_missing_feature", + "graphics.no_graphics_backend", + ] { + let compiled = loader + .rule_set() + .get(id) + .unwrap_or_else(|| panic!("规则 {id} 不存在")); + let rule = compiled.rule(); + let settings = rule + .fix_action + .iter() + .map(|action| action.materialize(&BTreeMap::new())) + .filter(|action| { + action + .args + .get("setting") + .is_some_and(|setting| setting == "graphics_api") + }) + .count(); + assert!( + settings > 0, + "规则 {id} 没有 change_graphics/graphics_api 动作" + ); + assert!( + rule.fix_action + .iter() + .any(|action| action.kind().as_str() == "change_graphics"), + "规则 {id} 的修复动作应属于 change_graphics 维度" + ); + } +} + +/// 后端全挂(两个都失败)应当压过单条 vulkan_* 原因,避免同一个故障报五遍。 +#[test] +fn no_graphics_backend_suppresses_single_reason_rules() { + let text = read_sample("samples/graphics/no_graphics_backend.log"); + let report = analyze_text(SignalOrigin::Stdout, &text); + assert_eq!( + report.primary().expect("命中").rule_id, + "graphics.no_graphics_backend" + ); +} + +/// SDL 变体必须保持 macOS 特异性:`cocoa` 是 SDL 在 macOS 的视频驱动名,两者同现才算 +/// 本规则命中。否则 Windows/Linux 的 SDL_Init 失败会被误归因成「macOS 显示器问题」。 +#[test] +fn sdl_display_failure_is_attributed_to_macos_only_with_cocoa() { + let hit = |text: &str| { + let report = analyze_text(SignalOrigin::Stdout, text); + report + .findings + .iter() + .chain(report.secondary.iter()) + .any(|finding| finding.rule_id == "graphics.macos_display_port") + }; + + let macos = "\ +[12:00:21] [Render thread/ERROR]: java.lang.IllegalStateException: SDL_Init(SDL_INIT_VIDEO) failed: cocoa: Failed to create window +"; + assert!(hit(macos), "macOS(cocoa)的 SDL 初始化失败应命中原规则"); + + for other in [ + // Windows:SDL 的 windows 驱动,文本里没有 cocoa。 + "[12:00:21] [Render thread/ERROR]: SDL_Init(SDL_INIT_VIDEO) failed: No available video device\n", + // Linux:x11/wayland 驱动。 + "[12:00:21] [Render thread/ERROR]: SDL_Init(SDL_INIT_VIDEO) failed: x11: Could not connect to display\n", + ] { + assert!( + !hit(other), + "非 macOS 的 SDL 初始化失败不该归因到 macOS 显示器规则:{other}" + ); + } +} diff --git a/crates/core/tests/download/backoff_test.rs b/crates/core/tests/download/backoff_test.rs new file mode 100644 index 0000000..fb4b5b0 --- /dev/null +++ b/crates/core/tests/download/backoff_test.rs @@ -0,0 +1,68 @@ +//! 指数退避与抖动的单测(纯函数,无网络)。 + +use std::time::Duration; + +use unlucky_core::download::backoff::{BackoffPolicy, Jitter}; + +#[test] +fn exponential_growth_doubles_and_caps() { + let policy = BackoffPolicy::default(); + assert_eq!(policy.delay(0), Duration::from_millis(1_000)); + assert_eq!(policy.delay(1), Duration::from_millis(2_000)); + assert_eq!(policy.delay(2), Duration::from_millis(4_000)); + assert_eq!(policy.delay(3), Duration::from_millis(8_000)); + // 上限封顶,不会涨到分钟级。 + assert_eq!(policy.delay(10), Duration::from_millis(30_000)); + assert_eq!(policy.delay(u32::MAX), Duration::from_millis(30_000)); +} + +#[test] +fn no_retry_policy_waits_nothing() { + let policy = BackoffPolicy::none(); + assert_eq!(policy.delay(0), Duration::ZERO); + assert_eq!(policy.max_attempts, 1); + assert_eq!(policy.delay_jittered(0, 0.5), Duration::ZERO); +} + +#[test] +fn jitter_stays_within_ratio_bounds() { + let policy = BackoffPolicy::default(); + // base 1000ms ± 30% → [700, 1300] + let low = policy.delay_jittered(0, 0.0); + let high = policy.delay_jittered(0, 1.0); + assert_eq!(low, Duration::from_millis(700)); + assert_eq!(high, Duration::from_millis(1_300)); + // 抖动永不超过 max_ms。 + for rng in [0.0, 0.25, 0.5, 0.75, 1.0] { + assert!(policy.delay_jittered(9, rng) <= Duration::from_millis(30_000)); + } +} + +#[test] +fn jitter_without_ratio_is_deterministic() { + let policy = BackoffPolicy { + jitter_ratio: 0.0, + ..BackoffPolicy::default() + }; + assert_eq!( + policy.delay_jittered(2, 0.0), + policy.delay_jittered(2, 0.99) + ); +} + +#[test] +fn jitter_source_is_reproducible_and_in_range() { + let mut first = Jitter::new(42); + let mut second = Jitter::new(42); + for _ in 0..32 { + let a = first.next_fraction(); + let b = second.next_fraction(); + assert_eq!(a, b, "同种子的抖动序列必须一致"); + assert!((0.0..1.0).contains(&a)); + } + + let mut other = Jitter::new(7); + assert_ne!(Jitter::new(1).next_u64(), other.next_u64()); + // 种子 0 会被替换成固定非零种子,但仍然可用。 + assert!((0.0..1.0).contains(&Jitter::new(0).next_fraction())); +} diff --git a/crates/core/tests/download/cache_test.rs b/crates/core/tests/download/cache_test.rs new file mode 100644 index 0000000..5420372 --- /dev/null +++ b/crates/core/tests/download/cache_test.rs @@ -0,0 +1,149 @@ +//! 内容寻址缓存的单测:两级分片、记录信任(命中不重算)、损坏检测、索引持久化。 + +mod support; + +use std::time::Duration; + +use unlucky_core::download::cache::ContentCache; +use unlucky_core::download::types::{HashAlgo, Integrity}; + +const NOW: u64 = 5_000_000; + +fn cache_dir(tag: &str) -> std::path::PathBuf { + support::temp_dir(tag) +} + +#[test] +fn sharded_path_follows_layout() { + let dir = cache_dir("cache-path"); + let cache = ContentCache::open(&dir); + let integrity = Integrity::sha1("abcdef0123456789abcdef0123456789abcdef01"); + let path = cache.path_for(integrity.algo, &integrity.hex); + assert!(path.ends_with("sha1/ab/abcdef0123456789abcdef0123456789abcdef01")); + assert!(path.starts_with(cache.root())); + // SHA-256 单独一棵子树。 + let sha256_path = cache.path_for(HashAlgo::Sha256, "00112233445566778899aabbccddeeff"); + assert!(sha256_path.ends_with("sha256/00/00112233445566778899aabbccddeeff")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn insert_then_lookup_hits_without_rehashing() { + let dir = cache_dir("cache-hit"); + let cache = ContentCache::open(&dir); + + let payload = b"hello unlucky launcher".to_vec(); + let integrity = Integrity::sha1(support::sha1_hex(&payload)); + let source = dir.join("download.bin"); + std::fs::write(&source, &payload).unwrap(); + + let cached = cache + .insert(&source, &integrity, NOW) + .expect("写入缓存失败"); + assert!(cached.is_file()); + assert_eq!(cache.len(), 1); + + // 第二次命中:size/mtime 未变 → 记录信任,直接返回(不重算摘要)。 + let hit = cache.lookup(&integrity, NOW + 10).expect("应命中缓存"); + assert_eq!(hit, cached); + + // 文件被删 → 记录作废。 + std::fs::remove_file(&cached).unwrap(); + assert!(cache.lookup(&integrity, NOW + 20).is_none()); + assert_eq!(cache.len(), 0); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn truncated_cache_file_is_a_miss() { + let dir = cache_dir("cache-truncate"); + let cache = ContentCache::open(&dir); + + let payload = vec![7_u8; 256]; + let integrity = Integrity::sha1(support::sha1_hex(&payload)); + let source = dir.join("payload.bin"); + std::fs::write(&source, &payload).unwrap(); + let cached = cache.insert(&source, &integrity, NOW).unwrap(); + + // 半损坏(长度变了)必须立刻视为未命中。 + std::fs::write(&cached, &payload[..128]).unwrap(); + assert!(cache.lookup(&integrity, NOW + 1).is_none()); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn metadata_change_forces_a_rehash() { + let dir = cache_dir("cache-rehash"); + let cache = ContentCache::open(&dir); + + let payload = vec![3_u8; 128]; + let integrity = Integrity::sha1(support::sha1_hex(&payload)); + let source = dir.join("data.bin"); + std::fs::write(&source, &payload).unwrap(); + let cached = cache.insert(&source, &integrity, NOW).unwrap(); + // 硬链接/复制都可能让源文件与缓存同 inode,先断开再改缓存内容。 + let _ = std::fs::remove_file(&source); + + // 等 mtime 变化(部分文件系统时间戳精度 10ms)。 + std::thread::sleep(Duration::from_millis(30)); + let mut tampered = payload.clone(); + tampered[0] = 9; + std::fs::write(&cached, &tampered).unwrap(); + + // 内容变了 → 摘要不符 → 记录作废。 + assert!(cache.lookup(&integrity, NOW + 100).is_none()); + assert_eq!(cache.len(), 0); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn lookup_verified_rejects_wrong_content_with_same_size() { + let dir = cache_dir("cache-verified"); + let cache = ContentCache::open(&dir); + + let payload = vec![1_u8; 64]; + let integrity = Integrity::sha1(support::sha1_hex(&payload)); + // 直接放到缓存位但内容是错的(模拟外部篡改 / 镜像写坏)。 + let path = cache.path_for(integrity.algo, &integrity.hex); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, vec![2_u8; 64]).unwrap(); + cache.register(&path, &integrity, NOW).unwrap(); + + assert!( + cache.lookup_verified(&integrity, NOW + 1).is_none(), + "强制校验必须发现内容不符" + ); + + // 内容对了就能通过。 + std::fs::write(&path, &payload).unwrap(); + cache.register(&path, &integrity, NOW).unwrap(); + assert!(cache.lookup_verified(&integrity, NOW + 2).is_some()); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn index_persists_across_reopen() { + let dir = cache_dir("cache-persist"); + let payload = vec![5_u8; 32]; + let integrity = Integrity::sha1(support::sha1_hex(&payload)); + + { + let cache = ContentCache::open(&dir); + let source = dir.join("src.bin"); + std::fs::write(&source, &payload).unwrap(); + cache.insert(&source, &integrity, NOW).unwrap(); + cache.persist().expect("持久化索引失败"); + assert_eq!(cache.stats().entries, 1); + assert_eq!(cache.stats().total_bytes, 32); + } + + let reopened = ContentCache::open(&dir); + assert_eq!(reopened.len(), 1); + assert!(reopened.lookup(&integrity, NOW + 1).is_some()); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/core/tests/download/chunked_test.rs b/crates/core/tests/download/chunked_test.rs new file mode 100644 index 0000000..906baf3 --- /dev/null +++ b/crates/core/tests/download/chunked_test.rs @@ -0,0 +1,375 @@ +//! 分块规划与 Range 语义的单测(纯函数)+ 真跑 4 连接的端到端用例(mock 源)。 + +mod support; + +use std::sync::Arc; +use std::time::Duration; + +use unlucky_core::download::backoff::BackoffPolicy; +use unlucky_core::download::cache::ContentCache; +use unlucky_core::download::chunked::{ + content_range_matches, parse_content_range, plan, range_support, ByteRange, ChunkedConfig, + ContentRange, RangeSupport, MAX_CONNECTIONS, +}; +use unlucky_core::download::config::EngineConfig; +use unlucky_core::download::engine::DownloadEngine; +use unlucky_core::download::health::HealthRegistry; +use unlucky_core::download::mirrors::MirrorTable; +use unlucky_core::download::region::RegionProfile; +use unlucky_core::download::types::{DownloadRequest, ResourceKind}; + +use support::{sha1_hex, temp_dir, MockResponse, MockServer}; + +#[test] +fn plan_partitions_whole_file_without_gaps() { + let cases = [ + (1_u64, 4_usize), + (3, 4), + (100, 4), + (1000, 7), + (123_456_789, 4), + ]; + for (total, connections) in cases { + let ranges = plan(total, connections); + assert!(!ranges.is_empty(), "total={total} 不能切出空集"); + assert_eq!(ranges[0].start, 0, "必须从 0 开始"); + assert_eq!(ranges.last().unwrap().end, total - 1, "必须覆盖到末尾"); + let mut next = 0_u64; + for range in &ranges { + assert_eq!(range.start, next, "段之间不能有空洞或重叠"); + assert!(!range.is_empty()); + next = range.end + 1; + } + assert_eq!(next, total); + assert_eq!(ranges.iter().map(ByteRange::len).sum::(), total); + } +} + +#[test] +fn plan_caps_connections_and_handles_edge_cases() { + assert!(plan(0, 4).is_empty()); + // 文件比连接数还小:每段 1 字节。 + let tiny = plan(3, 4); + assert_eq!(tiny.len(), 3); + assert!(tiny.iter().all(|range| range.len() == 1)); + // 连接数被夹到上限。 + let many = plan(1_000_000, 100); + assert_eq!(many.len(), MAX_CONNECTIONS); + // 单连接。 + assert_eq!(plan(120, 1), vec![ByteRange::new(0, 119)]); +} + +#[test] +fn byte_range_headers_are_closed_intervals() { + let range = ByteRange::new(100, 199); + assert_eq!(range.len(), 100); + assert_eq!(range.header_value(), "bytes=100-199"); + assert!(ByteRange::new(10, 9).is_empty()); + assert_eq!(ByteRange::new(10, 9).len(), 0); +} + +#[test] +fn parse_content_range_accepts_servers_forms() { + assert_eq!( + parse_content_range("bytes 0-99/1000"), + Some(ContentRange { + start: 0, + end: 99, + total: Some(1000) + }) + ); + assert_eq!( + parse_content_range(" bytes 100-199/* "), + Some(ContentRange { + start: 100, + end: 199, + total: None + }) + ); + // 畸形一律 None:调用方据此退化。 + assert_eq!(parse_content_range("bytes 100-99/1000"), None); + assert_eq!(parse_content_range("items 0-9/10"), None); + assert_eq!(parse_content_range("bytes 0-9"), None); + assert_eq!(parse_content_range(""), None); +} + +#[test] +fn range_support_matrix() { + assert_eq!(range_support(Some("bytes"), 200), RangeSupport::Supported); + assert_eq!(range_support(None, 206), RangeSupport::Supported); + assert_eq!(range_support(Some("none"), 200), RangeSupport::Unsupported); + assert_eq!( + range_support(Some("crumbs"), 200), + RangeSupport::Unsupported + ); + assert_eq!(range_support(None, 200), RangeSupport::Unknown); +} + +#[test] +fn chunked_config_gate() { + let config = ChunkedConfig::default(); + assert!(config.enabled); + assert_eq!(config.connections, 4); + // 只有大文件 + 大资源类型才分块。 + assert!(config.applies_to(ResourceKind::JavaRuntime, Some(200 * 1024 * 1024))); + assert!(!config.applies_to(ResourceKind::JavaRuntime, Some(1024))); + assert!(!config.applies_to(ResourceKind::ClientJar, Some(200 * 1024 * 1024))); + assert!(!config.applies_to(ResourceKind::JavaRuntime, None)); + // 设置项关掉后一律不分块。 + assert!(!ChunkedConfig::disabled().applies_to(ResourceKind::JavaRuntime, Some(u64::MAX))); + assert_eq!( + ChunkedConfig { + connections: 999, + ..ChunkedConfig::default() + } + .effective_connections(), + MAX_CONNECTIONS + ); +} + +/// 端到端:`min_size` 调到 1KiB 后,20KiB 的 JDK 类资源真的被切成 4 段并行取。 +/// +/// 断言三件事:①mock 源收到 4 次带 `Range` 的请求(不是 1 次)②拼装后的文件逐字节正确 +/// ③流式 sha1 校验通过(证明分块乱序写没有错位)。 +#[tokio::test] +async fn downloads_large_file_over_four_parallel_ranges() { + let payload: Vec = (0..20 * 1024).map(|index| (index % 251) as u8).collect(); + let server = MockServer::start(vec![ + MockResponse::ok(payload.clone()), + MockResponse::ok(payload.clone()), + MockResponse::ok(payload.clone()), + MockResponse::ok(payload.clone()), + ]) + .await; + let url = server.url("/jdk.zip"); + let dir = temp_dir("chunked-e2e"); + let engine = DownloadEngine::new( + EngineConfig { + max_attempts_per_source: 1, + backoff: BackoffPolicy { + base_ms: 1, + max_ms: 2, + jitter_ratio: 0.0, + max_attempts: 1, + }, + read_timeout: Duration::from_secs(5), + connect_timeout: Duration::from_secs(5), + // 真开分块:4 条连接,1KiB 以上就切。 + chunked: ChunkedConfig { + enabled: true, + connections: 4, + min_size: 1024, + }, + ..EngineConfig::default() + }, + Arc::new(MirrorTable::builtin()), + Arc::new(RegionProfile::direct(0)), + Arc::new(HealthRegistry::in_memory()), + Arc::new(ContentCache::open(dir.join("cache"))), + ); + + let dest = dir.join("jdk.zip"); + let outcome = engine + .download( + DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)) + .with_size(payload.len() as u64), + ) + .await + .expect("分块下载失败"); + + assert_eq!(outcome.bytes, payload.len() as u64); + assert_eq!(outcome.source, "official"); + assert_eq!( + server.hits(), + 4, + "4 条连接应各发一次带 Range 的请求(退化单连接会只有 1 次)" + ); + assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload); + assert!( + support::part_files(&dir).is_empty(), + "中间文件必须在提交后消失:{:?}", + support::part_files(&dir) + ); + server.stop(); +} + +/// 端到端:目标目录**还不存在**时分块下载也要能落盘。 +/// +/// 回归用例。分块路径先 `OpenOptions` 预分配整文件(不走 `PartFile::open_append` +/// 那层建目录),所以「下到新建目录」曾让所有候选源瞬间全部失败 +/// (现场:`data/java-managed/.cache/` 未创建时装 JDK 六个源全挂)。 +#[tokio::test] +async fn chunked_download_creates_the_destination_directory() { + let payload: Vec = (0..8 * 1024).map(|index| (index % 31) as u8).collect(); + let responses = vec![ + MockResponse::ok(payload.clone()), + MockResponse::ok(payload.clone()), + MockResponse::ok(payload.clone()), + MockResponse::ok(payload.clone()), + ]; + let server = MockServer::start(responses).await; + let url = server.url("/jdk.zip"); + let dir = temp_dir("chunked-mkdir"); + let engine = DownloadEngine::new( + EngineConfig { + max_attempts_per_source: 1, + backoff: BackoffPolicy { + base_ms: 1, + max_ms: 2, + jitter_ratio: 0.0, + max_attempts: 1, + }, + read_timeout: Duration::from_secs(5), + connect_timeout: Duration::from_secs(5), + chunked: ChunkedConfig { + enabled: true, + connections: 4, + min_size: 1024, + }, + ..EngineConfig::default() + }, + Arc::new(MirrorTable::builtin()), + Arc::new(RegionProfile::direct(0)), + Arc::new(HealthRegistry::in_memory()), + Arc::new(ContentCache::open(dir.join("cache"))), + ); + + let dest = dir.join("java-managed/.cache/jdk.zip"); + assert!( + !dest.parent().expect("父目录").exists(), + "前置条件:目录不存在" + ); + let outcome = engine + .download( + DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)) + .with_size(payload.len() as u64), + ) + .await + .expect("分块下载应成功"); + assert_eq!(outcome.bytes, payload.len() as u64); + assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload); + server.stop(); +} + +/// 端到端:服务器声明支持 Range 但实际无视(返回 200)→ 自动退化单连接,结果仍然正确。 +#[tokio::test] +async fn falls_back_to_single_connection_when_server_ignores_range() { + let payload: Vec = (0..8 * 1024).map(|index| (index % 97) as u8).collect(); + // 每个请求都会拿到 200 + 整份内容(range_support = false)。 + // 响应条数给足:4 个分块请求可能都发出去才被 abort,退化后的整份请求是第 5 条。 + let no_range = || MockResponse::ok(payload.clone()).without_range(); + let server = MockServer::start(vec![ + no_range(), + no_range(), + no_range(), + no_range(), + no_range(), + no_range(), + ]) + .await; + let url = server.url("/client.jar"); + let dir = temp_dir("chunked-fallback"); + let engine = DownloadEngine::new( + EngineConfig { + max_attempts_per_source: 1, + backoff: BackoffPolicy::none(), + chunked: ChunkedConfig { + enabled: true, + connections: 4, + min_size: 1024, + }, + ..EngineConfig::default() + }, + Arc::new(MirrorTable::builtin()), + Arc::new(RegionProfile::direct(0)), + Arc::new(HealthRegistry::in_memory()), + Arc::new(ContentCache::open(dir.join("cache"))), + ); + + let dest = dir.join("client.jar"); + let outcome = engine + .download( + DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)) + .with_size(payload.len() as u64), + ) + .await + .expect("退化后应仍能下载成功"); + + assert_eq!(outcome.bytes, payload.len() as u64); + assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload); + server.stop(); +} + +#[test] +fn content_range_must_match_requested_segment() { + let requested = ByteRange::new(5120, 10239); + // 严格一致才放行。 + assert!(content_range_matches( + requested, + Some("bytes 5120-10239/20480") + )); + // 起止任一不符 → 判失败(否则「长度恰好但位置错」的响应会拼出坏文件)。 + assert!(!content_range_matches( + requested, + Some("bytes 0-5119/20480") + )); + assert!(!content_range_matches( + requested, + Some("bytes 5120-20479/20480") + )); + // 畸形头判失败。 + assert!(!content_range_matches(requested, Some("garbage"))); + // 缺头从宽(长度校验仍兜底)。 + assert!(content_range_matches(requested, None)); +} + +/// 端到端:服务器回 206 但 `Content-Range` 谎报段位置 → 分块路径判不符、丢弃中间文件、 +/// 退化单连接重下,最终文件仍逐字节正确(不会交付拼错的文件)。 +#[tokio::test] +async fn rejects_lying_content_range_and_falls_back() { + let payload: Vec = (0..8 * 1024).map(|index| (index % 89) as u8).collect(); + let lying = || MockResponse::ok(payload.clone()).with_wrong_content_range(); + let server = MockServer::start(vec![ + lying(), + lying(), + lying(), + lying(), + MockResponse::ok(payload.clone()), + ]) + .await; + let url = server.url("/jdk.zip"); + let dir = temp_dir("chunked-lying-range"); + let engine = DownloadEngine::new( + EngineConfig { + max_attempts_per_source: 1, + backoff: BackoffPolicy::none(), + chunked: ChunkedConfig { + enabled: true, + connections: 4, + min_size: 1024, + }, + ..EngineConfig::default() + }, + Arc::new(MirrorTable::builtin()), + Arc::new(RegionProfile::direct(0)), + Arc::new(HealthRegistry::in_memory()), + Arc::new(ContentCache::open(dir.join("cache"))), + ); + + let dest = dir.join("jdk.zip"); + let outcome = engine + .download( + DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)) + .with_size(payload.len() as u64), + ) + .await + .expect("谎报 Content-Range 后应退化单连接成功"); + + assert_eq!(outcome.bytes, payload.len() as u64); + assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload); + server.stop(); +} diff --git a/crates/core/tests/download/engine_mock_test.rs b/crates/core/tests/download/engine_mock_test.rs new file mode 100644 index 0000000..528e664 --- /dev/null +++ b/crates/core/tests/download/engine_mock_test.rs @@ -0,0 +1,350 @@ +//! 引擎端到端(mock 源):成功 / 退避重试 / 换源 / 校验失败 / 缓存命中 / 断点续传。 +//! +//! 用 `tests/download/support` 里的进程内 HTTP 服务器,不发真实网络请求。 + +mod support; + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use unlucky_core::download::backoff::BackoffPolicy; +use unlucky_core::download::cache::ContentCache; +use unlucky_core::download::chunked::ChunkedConfig; +use unlucky_core::download::config::EngineConfig; +use unlucky_core::download::engine::DownloadEngine; +use unlucky_core::download::error::DownloadErrorKind; +use unlucky_core::download::health::HealthRegistry; +use unlucky_core::download::mirrors::{MirrorTable, PrefixRule}; +use unlucky_core::download::progress::{ProgressEmitter, ProgressEvent}; +use unlucky_core::download::region::RegionProfile; +use unlucky_core::download::resume::ResumeMeta; +use unlucky_core::download::types::{DownloadRequest, HashAlgo, ResourceKind}; + +use support::{sha1_hex, temp_dir, MockResponse, MockServer}; + +fn fast_config(attempts: u32) -> EngineConfig { + EngineConfig { + max_attempts_per_source: attempts, + backoff: BackoffPolicy { + base_ms: 1, + max_ms: 2, + jitter_ratio: 0.0, + max_attempts: attempts, + }, + connect_timeout: Duration::from_secs(5), + read_timeout: Duration::from_secs(5), + chunked: ChunkedConfig::disabled(), + ..EngineConfig::default() + } +} + +struct Harness { + engine: DownloadEngine, + dir: PathBuf, +} + +fn harness(cfg: EngineConfig, table: MirrorTable, region: RegionProfile, tag: &str) -> Harness { + let dir = temp_dir(tag); + let cache = Arc::new(ContentCache::open(dir.join("cache"))); + let health = Arc::new(HealthRegistry::in_memory()); + let engine = DownloadEngine::new(cfg, Arc::new(table), Arc::new(region), health, cache); + Harness { engine, dir } +} + +fn direct_region() -> RegionProfile { + RegionProfile::direct(0) +} + +#[tokio::test] +async fn downloads_verifies_and_leaves_no_part_file() { + let payload = b"unlucky launcher payload".to_vec(); + let server = MockServer::start(vec![MockResponse::ok(payload.clone())]).await; + let url = server.url("/client.jar"); + let harness = harness( + fast_config(2), + MirrorTable::builtin(), + direct_region(), + "engine-ok", + ); + let dest = harness.dir.join("versions/1.20.1/client.jar"); + + let request = DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)) + .with_size(payload.len() as u64); + let outcome = harness.engine.download(request).await.expect("下载应成功"); + + assert_eq!(outcome.bytes, payload.len() as u64); + assert!(!outcome.from_cache); + assert_eq!(outcome.resumed_from, 0); + assert_eq!(std::fs::read(&dest).unwrap(), payload); + let version_dir = harness.dir.join("versions/1.20.1"); + assert!( + support::part_files(&version_dir).is_empty(), + "中间文件必须被提交掉:{:?}", + support::part_files(&version_dir) + ); + server.stop(); +} + +#[tokio::test] +async fn retries_same_source_after_server_error() { + let payload = b"retry me".to_vec(); + let server = MockServer::start(vec![ + MockResponse::status(503), + MockResponse::ok(payload.clone()), + ]) + .await; + let url = server.url("/lib.jar"); + let harness = harness( + fast_config(3), + MirrorTable::builtin(), + direct_region(), + "engine-retry", + ); + let dest = harness.dir.join("lib.jar"); + + let outcome = harness + .engine + .download( + DownloadRequest::new(ResourceKind::Library, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)), + ) + .await + .expect("退避后应成功"); + assert_eq!(outcome.bytes, payload.len() as u64); + assert_eq!(server.hits(), 2, "第一次 503 之后应重试一次"); + server.stop(); +} + +#[tokio::test] +async fn switches_to_official_when_mirror_keeps_failing() { + let payload = b"official wins".to_vec(); + let official = MockServer::start(vec![MockResponse::ok(payload.clone())]).await; + let mirror = MockServer::start(vec![ + MockResponse::status(500), + MockResponse::status(500), + MockResponse::status(500), + ]) + .await; + + let mut table = MirrorTable::builtin(); + let official_prefix = format!("http://127.0.0.1:{}", official.port()); + let mirror_prefix = format!("http://localhost:{}", mirror.port()); + table.add_custom_rule(PrefixRule::new( + "mock-mirror", + official_prefix, + mirror_prefix, + )); + + let harness = harness( + fast_config(2), + table, + RegionProfile::mirror_first(0), + "engine-switch", + ); + let dest = harness.dir.join("client.jar"); + let url = official.url("/client.jar"); + + let outcome = harness + .engine + .download( + DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)), + ) + .await + .expect("镜像全挂后应切到官方"); + assert_eq!(outcome.source, "official"); + assert_eq!(std::fs::read(&dest).unwrap(), payload); + assert_eq!(mirror.hits(), 2, "每个源最多尝试 max_attempts 次"); + official.stop(); + mirror.stop(); +} + +#[tokio::test] +async fn rejects_wrong_checksum_and_falls_back() { + let payload = b"the real jar".to_vec(); + let official = MockServer::start(vec![MockResponse::ok(payload.clone())]).await; + let mirror = MockServer::start(vec![MockResponse::ok(b"tampered by mirror".to_vec())]).await; + + let mut table = MirrorTable::builtin(); + table.add_custom_rule(PrefixRule::new( + "mock-mirror", + format!("http://127.0.0.1:{}", official.port()), + format!("http://localhost:{}", mirror.port()), + )); + + let harness = harness( + fast_config(1), + table, + RegionProfile::mirror_first(0), + "engine-checksum", + ); + let dest = harness.dir.join("client.jar"); + let url = official.url("/client.jar"); + + let outcome = harness + .engine + .download( + DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)) + .with_size(payload.len() as u64), + ) + .await + .expect("校验失败应换源而不是交付坏文件"); + assert_eq!(outcome.source, "official"); + assert_eq!(std::fs::read(&dest).unwrap(), payload); + assert!(mirror.hits() >= 1); + official.stop(); + mirror.stop(); +} + +#[tokio::test] +async fn second_download_hits_content_cache_without_network() { + let payload = b"cached payload".to_vec(); + let server = MockServer::start(vec![ + MockResponse::ok(payload.clone()), + MockResponse::ok(payload.clone()), + ]) + .await; + let url = server.url("/lib.jar"); + let harness = harness( + fast_config(2), + MirrorTable::builtin(), + direct_region(), + "engine-cache", + ); + let integrity = sha1_hex(&payload); + + let first = harness + .engine + .download( + DownloadRequest::new( + ResourceKind::Library, + url.as_str(), + harness.dir.join("a/lib.jar"), + ) + .with_sha1(integrity.as_str()), + ) + .await + .expect("首次下载失败"); + assert!(!first.from_cache); + + let hits_after_first = server.hits(); + let second = harness + .engine + .download( + DownloadRequest::new( + ResourceKind::Library, + url.as_str(), + harness.dir.join("b/lib.jar"), + ) + .with_sha1(integrity.as_str()), + ) + .await + .expect("缓存命中失败"); + assert!(second.from_cache); + assert_eq!(second.bytes, payload.len() as u64); + assert_eq!(server.hits(), hits_after_first, "缓存命中不能再发请求"); + assert_eq!( + std::fs::read(harness.dir.join("b/lib.jar")).unwrap(), + payload + ); + server.stop(); +} + +#[tokio::test] +async fn resumes_from_existing_part_file() { + let payload = b"0123456789abcdefghij".to_vec(); + let split = 8_usize; + let server = MockServer::start(vec![MockResponse::ok(payload.clone())]).await; + let url = server.url("/client.jar"); + let harness = harness( + fast_config(2), + MirrorTable::builtin(), + direct_region(), + "engine-resume", + ); + let dest = harness.dir.join("client.jar"); + let part = harness.dir.join("client.jar.ullpart"); + let meta_path = harness.dir.join("client.jar.ullpart.ullmeta.json"); + + std::fs::write(&part, &payload[..split]).unwrap(); + ResumeMeta { + url: url.clone(), + etag: Some("\"v1\"".to_string()), + last_modified: None, + total: Some(payload.len() as u64), + algo: HashAlgo::Sha1, + hash: sha1_hex(&payload), + saved_at_ms: 1, + } + .save(&meta_path) + .expect("写续传元数据失败"); + + let outcome = harness + .engine + .download( + DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)) + .with_size(payload.len() as u64), + ) + .await + .expect("续传失败"); + + assert_eq!(outcome.resumed_from, split as u64, "应从已有偏移续传"); + assert_eq!(std::fs::read(&dest).unwrap(), payload); + assert_eq!(server.hits(), 1); + server.stop(); +} + +#[tokio::test] +async fn reports_progress_events_and_errors_when_all_sources_fail() { + let server = MockServer::start(vec![ + MockResponse::status(500), + MockResponse::status(500), + MockResponse::status(500), + MockResponse::status(500), + ]) + .await; + let url = server.url("/dead.jar"); + let dir = temp_dir("engine-fail"); + let cache = Arc::new(ContentCache::open(dir.join("cache"))); + let (emitter, mut events) = ProgressEmitter::channel(); + let engine = DownloadEngine::new( + fast_config(1), + Arc::new(MirrorTable::builtin()), + Arc::new(direct_region()), + Arc::new(HealthRegistry::in_memory()), + cache, + ) + .with_progress(emitter); + + let error = engine + .download(DownloadRequest::new( + ResourceKind::Library, + url.as_str(), + dir.join("dead.jar"), + )) + .await + .expect_err("所有源都 500 时必须失败"); + assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed); + + let mut started = false; + let mut failed_source = false; + while let Ok(event) = events.try_recv() { + match event { + ProgressEvent::Started { candidates, .. } => { + started = true; + assert!(candidates >= 1); + } + ProgressEvent::SourceFailed { will_retry, .. } => { + failed_source = true; + assert!(!will_retry, "单次尝试用尽后不应再声明重试"); + } + _ => {} + } + } + assert!(started && failed_source, "进度事件缺失"); + server.stop(); +} diff --git a/crates/core/tests/download/health_test.rs b/crates/core/tests/download/health_test.rs new file mode 100644 index 0000000..3899cb9 --- /dev/null +++ b/crates/core/tests/download/health_test.rs @@ -0,0 +1,268 @@ +//! 健康度记忆的单测:冷却、恢复探测、排序、持久化 + 引擎驱动的落盘接线。 + +mod support; + +use std::sync::Arc; +use std::time::Duration; + +use unlucky_core::download::backoff::BackoffPolicy; +use unlucky_core::download::cache::{now_ms, ContentCache}; +use unlucky_core::download::config::EngineConfig; +use unlucky_core::download::engine::DownloadEngine; +use unlucky_core::download::error::DownloadErrorKind; +use unlucky_core::download::health::{ + HealthRegistry, SourceHealth, COOLDOWN_MS, FAILURE_THRESHOLD, +}; +use unlucky_core::download::mirrors::MirrorTable; +use unlucky_core::download::region::RegionProfile; +use unlucky_core::download::types::{Candidate, DownloadRequest, ResourceKind, SOURCE_OFFICIAL}; + +use support::{MockResponse, MockServer}; + +const NOW: u64 = 1_000_000; + +fn registry() -> HealthRegistry { + HealthRegistry::in_memory() +} + +#[test] +fn fresh_source_is_neutral_not_penalized() { + let health = SourceHealth::default(); + assert!(!health.is_cooling_down(NOW)); + assert_eq!(health.failure_rate(), 0.0); + // 未知源必须是中间分,不能比刚成功的源更优先。 + let healthy = SourceHealth { + samples: 3, + successes: 3, + ema_rtt_ms: 120.0, + ..SourceHealth::default() + }; + assert!(healthy.score(NOW) < health.score(NOW)); +} + +#[test] +fn three_consecutive_failures_trigger_cooldown() { + let mut health = SourceHealth::default(); + for _ in 0..FAILURE_THRESHOLD { + health.record_failure("HTTP 502", NOW); + } + assert!(health.is_cooling_down(NOW)); + assert!(health.is_cooling_down(NOW + COOLDOWN_MS - 1)); + // 冷却到期后不再是冷却态,而是「可以探测」。 + assert!(!health.is_cooling_down(NOW + COOLDOWN_MS)); + assert!(health.is_probe(NOW + COOLDOWN_MS)); + // 冷却中的源排序惩罚要明显高于健康源。 + assert!(health.score(NOW) >= 1_000.0); +} + +#[test] +fn missing_artifact_is_recorded_without_cooling_down() { + // 院校镜像只留最新补丁:请求旧补丁必然 404,不能把源判成「坏」。 + let mut health = SourceHealth::default(); + for _ in 0..(FAILURE_THRESHOLD * 2) { + health.record_missing("HTTP 404", NOW); + } + assert!(!health.is_cooling_down(NOW), "4xx 不进冷却"); + assert!(!health.is_probe(NOW), "4xx 不该产生恢复探测状态"); + assert_eq!(health.failures, 0, "4xx 不计失败次数"); + assert_eq!(health.not_found, u64::from(FAILURE_THRESHOLD * 2)); + assert_eq!(health.failure_rate(), 0.0); + assert!(health.last_error.is_some(), "诊断仍要留痕"); + // 打分不应被 404 拉高(仍是未知源的中位分)。 + assert_eq!(health.score(NOW), SourceHealth::default().score(NOW)); + assert_eq!( + health.score(NOW), + unlucky_core::download::health::SCORE_UNKNOWN + ); +} + +#[test] +fn not_found_ageing_does_not_persist_a_cooldown() { + let registry = HealthRegistry::in_memory(); + for _ in 0..10 { + registry.record_missing("tuna", ResourceKind::JavaRuntime, "HTTP 404", NOW); + } + let health = registry.health_of("tuna", ResourceKind::JavaRuntime); + assert!(!health.is_cooling_down(NOW)); + assert_eq!(health.not_found, 10); + assert_eq!(health.consecutive_failures, 0); +} + +#[test] +fn probe_failure_re_arms_cooldown_and_success_clears_it() { + let mut health = SourceHealth::default(); + for _ in 0..FAILURE_THRESHOLD { + health.record_failure("timeout", NOW); + } + let after_cooldown = NOW + COOLDOWN_MS + 1; + assert!(!health.is_cooling_down(after_cooldown)); + // 探测再一次失败 → 立即重新冷却。 + health.record_failure("timeout again", after_cooldown); + assert!(health.is_cooling_down(after_cooldown)); + + // 成功则全部清零。 + health.record_success(90, after_cooldown + 1); + assert!(!health.is_cooling_down(after_cooldown + 1)); + assert!(!health.is_probe(after_cooldown + 1)); + assert_eq!(health.consecutive_failures, 0); + assert_eq!(health.cooldown_until_ms, 0); + assert_eq!(health.last_error, None); +} + +#[test] +fn ema_smooths_latency_and_counts_samples() { + let mut health = SourceHealth::default(); + health.record_success(100, NOW); + assert_eq!(health.ema_rtt_ms, 100.0); + health.record_success(200, NOW); + // 0.3 * 200 + 0.7 * 100 = 130 + assert!((health.ema_rtt_ms - 130.0).abs() < 1e-9); + assert_eq!(health.samples, 2); + assert_eq!(health.successes, 2); +} + +#[test] +fn failure_rate_counts_both_sides() { + let mut health = SourceHealth::default(); + health.record_success(50, NOW); + health.record_failure("boom", NOW); + assert!((health.failure_rate() - 0.5).abs() < 1e-9); +} + +#[test] +fn registry_ranks_healthy_first_and_cooling_last() { + let registry = registry(); + for _ in 0..FAILURE_THRESHOLD { + registry.record_failure("mirror-b", ResourceKind::ClientJar, "HTTP 500", NOW); + } + registry.record_success( + "mirror-a", + ResourceKind::ClientJar, + Duration::from_millis(80), + NOW, + ); + + let candidates = vec![ + Candidate::new("mirror-b", "https://b.example/file"), + Candidate::new("unknown", "https://u.example/file"), + Candidate::new("mirror-a", "https://a.example/file"), + ]; + let ranked = registry.rank(&candidates, ResourceKind::ClientJar, NOW); + assert_eq!(ranked[0].source, "mirror-a"); + assert_eq!(ranked[1].source, "unknown"); + assert_eq!( + ranked[2].source, "mirror-b", + "冷却中的源排最后但仍保留为兜底" + ); +} + +#[test] +fn health_is_tracked_per_resource_kind() { + let registry = registry(); + for _ in 0..FAILURE_THRESHOLD { + registry.record_failure("bmclapi", ResourceKind::Asset, "404", NOW); + } + assert!(registry.is_cooling_down("bmclapi", ResourceKind::Asset, NOW)); + // 另一种资源类型不受影响(资产挂了不代表 JDK 也挂)。 + assert!(!registry.is_cooling_down("bmclapi", ResourceKind::JavaRuntime, NOW)); +} + +#[test] +fn persistence_roundtrip_and_age_pruning() { + let dir = std::env::temp_dir().join(format!("ull-health-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("health.json"); + + let registry = HealthRegistry::open(&path, 7 * 24 * 3600 * 1000, NOW); + registry.record_failure("bmclapi", ResourceKind::Library, "HTTP 503", NOW); + registry.record_success( + "official", + ResourceKind::Library, + Duration::from_millis(55), + NOW, + ); + registry.save().expect("持久化失败"); + + let reloaded = HealthRegistry::open(&path, 7 * 24 * 3600 * 1000, NOW + 1_000); + assert_eq!(reloaded.snapshot().len(), 2); + assert_eq!( + reloaded + .health_of("bmclapi", ResourceKind::Library) + .failures, + 1 + ); + assert!( + reloaded + .health_of("official", ResourceKind::Library) + .ema_rtt_ms + > 0.0 + ); + + // 超出最大保留期 → 丢弃旧记录(缓存目录被长时间放着的情况)。 + let pruned = HealthRegistry::open(&path, 10, NOW + 60_000); + assert!(pruned.snapshot().is_empty()); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// P1 回归:健康度必须由**引擎的生产路径**落盘——下载→(模拟进程退出)→重开 registry, +/// 冷却状态与失败计数都要还在,否则「连续 3 败冷却 10 分钟」每轮启动从零学起。 +#[tokio::test] +async fn engine_persists_health_so_a_restart_keeps_the_cooldown() { + let failed = || MockResponse::status(500); + let server = MockServer::start(vec![failed(), failed(), failed(), failed()]).await; + let url = server.url("/lib.jar"); + let dir = support::temp_dir("health-engine-persist"); + let path = dir.join("cache/health.json"); + let now = now_ms(); + let max_age_ms = 7 * 24 * 3600 * 1000; + + let registry = Arc::new(HealthRegistry::open(&path, max_age_ms, now)); + let engine = DownloadEngine::new( + EngineConfig { + max_attempts_per_source: FAILURE_THRESHOLD, + backoff: BackoffPolicy { + base_ms: 1, + max_ms: 2, + jitter_ratio: 0.0, + max_attempts: FAILURE_THRESHOLD, + }, + ..EngineConfig::default() + }, + Arc::new(MirrorTable::builtin()), + Arc::new(RegionProfile::direct(0)), + Arc::clone(®istry), + Arc::new(ContentCache::open(dir.join("cache"))), + ); + + let error = engine + .download(DownloadRequest::new( + ResourceKind::Library, + url.as_str(), + dir.join("lib.jar"), + )) + .await + .expect_err("全 500 必须失败"); + assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed); + server.stop(); + + // 第 3 次连续失败进入冷却时就该落盘(不等收尾 flush)。 + assert!(path.is_file(), "健康度必须已经落盘到 cache/health.json"); + let before = registry.health_of(SOURCE_OFFICIAL, ResourceKind::Library); + assert!(before.consecutive_failures >= FAILURE_THRESHOLD); + assert!(before.is_cooling_down(now_ms())); + + // 「进程退出后重开」:从同一个文件恢复,冷却状态与失败计数都还在。 + let reopened = HealthRegistry::open(&path, max_age_ms, now_ms()); + let restored = reopened.health_of(SOURCE_OFFICIAL, ResourceKind::Library); + assert_eq!(restored.consecutive_failures, before.consecutive_failures); + assert_eq!(restored.failures, before.failures); + assert!( + restored.is_cooling_down(now_ms()), + "重开后仍应处于冷却:{restored:?}" + ); + assert!(restored.last_error.is_some(), "失败原因也要持久化"); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/core/tests/download/mirrors_test.rs b/crates/core/tests/download/mirrors_test.rs new file mode 100644 index 0000000..61c0696 --- /dev/null +++ b/crates/core/tests/download/mirrors_test.rs @@ -0,0 +1,281 @@ +//! 镜像清单与前缀重写的单测(纯字符串,无网络)。 + +use unlucky_core::download::adoptium::{parse_adoptium_artifact, JdkFlavor}; +use unlucky_core::download::mirrors::{host_of, source_id_for, MirrorTable, PrefixRule}; +use unlucky_core::download::region::RegionProfile; +use unlucky_core::download::types::ResourceKind; + +const LIBRARIES_URL: &str = + "https://libraries.minecraft.net/com/google/guava/guava/32.0.0/guava-32.0.0.jar"; +const ASSET_URL: &str = "https://resources.download.minecraft.net/ab/abcdef0123456789"; +const GITHUB_URL: &str = + "https://github.com/AskaEth/Unlucky-Launcher/releases/download/v1.0.0/ull-windows-x64.exe"; +const ADOPTIUM_URL: &str = "https://github.com/adoptium/temurin17-binaries/releases/download/JDK17.0.13%2B11/OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip"; + +#[test] +fn builtin_table_is_embedded_and_complete() { + let table = MirrorTable::builtin(); + assert!(table.version >= 1); + assert!(table.rules.len() >= 10, "内置规则太少"); + assert!(table.adoptium.len() >= 3, "院校镜像至少要 TUNA/USTC/SJTU"); + assert!(!table.gh_proxies.is_empty()); + assert!(table.gh_proxies[0].prefix.starts_with("https://gh-proxy")); +} + +#[test] +fn prefix_rule_preserves_sub_path() { + let table = MirrorTable::builtin(); + let rule = table + .prefix_rule_for(LIBRARIES_URL, ResourceKind::Library) + .expect("libraries 规则缺失"); + let candidate = rule.apply(LIBRARIES_URL).expect("重写失败"); + assert_eq!(candidate.source, "bmclapi"); + assert_eq!( + candidate.url, + LIBRARIES_URL.replace( + "https://libraries.minecraft.net", + "https://bmclapi2.bangbang93.com" + ) + ); +} + +#[test] +fn asset_rule_injects_path_prefix() { + let table = MirrorTable::builtin(); + let candidate = table + .prefix_rule_for(ASSET_URL, ResourceKind::Asset) + .and_then(|rule| rule.apply(ASSET_URL)) + .expect("资源对象规则缺失"); + assert_eq!( + candidate.url, + "https://bmclapi2.bangbang93.com/assets/ab/abcdef0123456789" + ); +} + +#[test] +fn direct_mode_puts_official_first() { + let table = MirrorTable::builtin(); + let region = RegionProfile::direct(0); + let candidates = table.expand(LIBRARIES_URL, ResourceKind::Library, ®ion); + assert_eq!(candidates[0].source, "official"); + assert_eq!(candidates[0].url, LIBRARIES_URL); + // 镜像仍然保留为兜底(官方失败时能救回来)。 + assert!(candidates + .iter() + .any(|candidate| candidate.source == "bmclapi")); +} + +#[test] +fn mirror_first_mode_puts_mirror_first() { + let table = MirrorTable::builtin(); + let region = RegionProfile::mirror_first(0); + let candidates = table.expand(LIBRARIES_URL, ResourceKind::Library, ®ion); + assert_eq!(candidates[0].source, "bmclapi"); + assert_eq!( + candidates.last().map(|c| c.source.as_str()), + Some("official") + ); +} + +#[test] +fn gh_proxy_only_for_github_hosts_in_domestic_mode() { + let table = MirrorTable::builtin(); + let domestic = RegionProfile::mirror_first(0); + let direct = RegionProfile::direct(0); + + let github = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &domestic); + assert!( + github.iter().any(|candidate| candidate.source == "gh_proxy" + && candidate.url.starts_with("https://gh-proxy.com/")), + "国内模式下 GitHub 资源必须套 gh-proxy:{github:?}" + ); + + let non_github = table.expand(LIBRARIES_URL, ResourceKind::Library, &domestic); + assert!( + non_github + .iter() + .all(|candidate| candidate.source != "gh_proxy"), + "非 GitHub 资源不受 gh-proxy 影响" + ); + + let github_direct = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &direct); + assert!(github_direct + .iter() + .all(|candidate| candidate.source != "gh_proxy")); +} + +#[test] +fn pinned_or_disabled_gh_proxy_is_respected() { + let table = MirrorTable::builtin(); + + // 用户关掉:地区档案显式不带前缀 → 一条 gh_proxy 候选都不出。 + let off = RegionProfile::mirror_first(0).with_gh_proxy(None); + let candidates = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &off); + assert!(candidates.iter().all(|c| c.source != "gh_proxy")); + + // 自定义前缀(设置项写进地区档案)。 + let custom = RegionProfile::mirror_first(0).with_gh_proxy(Some("https://proxy.example/")); + let candidates = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &custom); + let proxy = candidates + .iter() + .find(|candidate| candidate.source == "gh_proxy") + .expect("自定义前缀未生效"); + assert_eq!(proxy.url, format!("https://proxy.example/{GITHUB_URL}")); + + // 清单给设置做默认值。 + assert_eq!(table.default_gh_proxy(), Some("https://gh-proxy.com/")); +} + +#[test] +fn adoptium_artifact_parsing_and_mirror_ordering() { + let parsed = parse_adoptium_artifact(ADOPTIUM_URL).expect("解析 Temurin 产物名失败"); + assert_eq!(parsed.major, "17"); + assert_eq!(parsed.flavor, JdkFlavor::Jdk); + assert_eq!(parsed.arch, "x64"); + assert_eq!(parsed.os, "windows"); + assert!(parsed.filename.starts_with("OpenJDK17U-jdk_x64_windows")); + + let table = MirrorTable::builtin(); + let region = RegionProfile::mirror_first(0); + let candidates = table.expand(ADOPTIUM_URL, ResourceKind::JavaRuntime, ®ion); + let first = &candidates[0]; + assert_eq!(first.source, "tuna"); + assert!( + first + .url + .starts_with("https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/jdk/x64/windows/"), + "院校镜像路径不符合模板:{}", + first.url + ); + // 官方仍在候选里(全部镜像不可达时的兜底)。 + assert!(candidates + .iter() + .any(|candidate| candidate.source == "official")); +} + +/// JRE 产物必须落到镜像的 `/jre/` 子树(否则 4 个镜像全 404,加速白费)。 +#[test] +fn adoptium_jre_uses_jre_path_segment() { + let jre_url = ADOPTIUM_URL.replace("OpenJDK17U-jdk_", "OpenJDK17U-jre_"); + let parsed = parse_adoptium_artifact(&jre_url).expect("解析 JRE 产物名失败"); + assert_eq!(parsed.flavor, JdkFlavor::Jre); + assert_eq!( + parsed.mirror_url("https://mirrors.tuna.tsinghua.edu.cn/Adoptium"), + format!( + "https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/jre/x64/windows/{}", + parsed.filename + ) + ); + + let table = MirrorTable::builtin(); + let candidates = table.expand( + &jre_url, + ResourceKind::JavaRuntime, + &RegionProfile::mirror_first(0), + ); + let first = &candidates[0]; + assert_eq!(first.source, "tuna"); + assert!( + first.url.contains("/17/jre/x64/windows/"), + "JRE 必须走 /jre/ 子树:{}", + first.url + ); + + // `-jre-17…` 这种兜底形态同样识别。 + let fallback = "https://github.com/adoptium/temurin21-binaries/releases/download/jre-21.0.5%2B11/OpenJDK21U-jre_x64_linux_hotspot_21.0.5_11.tar.gz"; + assert_eq!( + parse_adoptium_artifact(fallback).map(|artifact| artifact.flavor), + Some(JdkFlavor::Jre) + ); +} + +#[test] +fn custom_rules_take_precedence() { + let mut table = MirrorTable::builtin(); + table.add_custom_rule(PrefixRule::new( + "my-mirror", + "https://libraries.minecraft.net", + "https://mirror.example/libraries", + )); + let candidate = table + .prefix_rule_for(LIBRARIES_URL, ResourceKind::Library) + .and_then(|rule| rule.apply(LIBRARIES_URL)) + .expect("自定义规则未生效"); + assert_eq!(candidate.source, "mirror.example"); + assert_eq!( + candidate.url, + "https://mirror.example/libraries/com/google/guava/guava/32.0.0/guava-32.0.0.jar" + ); +} + +#[test] +fn fallback_rule_keeps_official_first_even_in_domestic_mode() { + let mut table = MirrorTable { + version: 1, + notes: Vec::new(), + rules: vec![PrefixRule::new( + "modrinth-accelerator", + "https://api.modrinth.com", + "https://cdn.example", + ) + .fallback()], + // v3 新增的三张 JDK 源表在这张夹具里必须为空(否则会混进别的候选)。 + adoptium: Vec::new(), + oracle_openjdk: Vec::new(), + microsoft_openjdk: None, + zulu: None, + gh_proxies: Vec::new(), + }; + let region = RegionProfile::mirror_first(0); + let candidates = table.expand( + "https://api.modrinth.com/v2/project/sodium", + ResourceKind::ModFile, + ®ion, + ); + assert_eq!( + candidates[0].source, "official", + "「仅加速」规则不能顶掉官方" + ); + assert_eq!(candidates[1].source, "cdn.example"); + + // 关掉规则后只剩官方一条。 + table.rules.clear(); + let candidates = table.expand( + "https://api.modrinth.com/v2/project/sodium", + ResourceKind::ModFile, + ®ion, + ); + assert_eq!(candidates.len(), 1); +} + +#[test] +fn host_and_source_id_helpers() { + assert_eq!( + host_of("https://libraries.minecraft.net/a/b"), + Some("libraries.minecraft.net") + ); + assert_eq!( + host_of("https://user:pass@mirror.example:8443/x/y"), + Some("mirror.example") + ); + assert_eq!(host_of("http://127.0.0.1:8080/file"), Some("127.0.0.1")); + assert_eq!(host_of("not-a-url"), Some("not-a-url")); + + assert_eq!( + source_id_for("https://bmclapi2.bangbang93.com/assets/ab/cd"), + "bmclapi" + ); + assert_eq!( + source_id_for("https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/x.zip"), + "tuna" + ); + assert_eq!( + source_id_for("https://mirrors.ustc.edu.cn/adoptium/17/x.zip"), + "ustc" + ); + assert_eq!( + source_id_for("https://gh-proxy.com/https://github.com/a/b"), + "gh_proxy" + ); + assert_eq!(source_id_for("https://example.org/file"), "example.org"); +} diff --git a/crates/core/tests/download/partfile_test.rs b/crates/core/tests/download/partfile_test.rs new file mode 100644 index 0000000..f267d79 --- /dev/null +++ b/crates/core/tests/download/partfile_test.rs @@ -0,0 +1,233 @@ +//! `download::atomic::PartFile`:中间文件的进程/实例隔离、续传收养与孤儿清理。 +//! +//! 背景(M35 真机 finding):中间文件路径原先只由目标路径决定,两个启动器进程装同一 +//! 数据根时共用一个 `.ullpart`,先提交的一方 `rename` 走,另一方 `os error 2` +//! (实测两次 `ull install` 各失败 819 / 1116 个资源)。这里守住三条:名字带会话标识、 +//! 旧材料能收养、孤儿清理不许误删有人在用的中间文件。 + +mod support; + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use unlucky_core::download::backoff::BackoffPolicy; +use unlucky_core::download::cache::ContentCache; +use unlucky_core::download::chunked::ChunkedConfig; +use unlucky_core::download::config::EngineConfig; +use unlucky_core::download::engine::DownloadEngine; +use unlucky_core::download::health::HealthRegistry; +use unlucky_core::download::mirrors::MirrorTable; +use unlucky_core::download::region::RegionProfile; +use unlucky_core::download::types::{DownloadRequest, ResourceKind}; +use unlucky_core::download::{PartFile, ADOPT_MIN_AGE, META_SUFFIX, ORPHAN_MAX_AGE, PART_SUFFIX}; + +use support::{age_file, part_files, sha1_hex, temp_dir, MockResponse, MockServer}; + +/// 另一个会话号(保证与本进程不同)。 +fn foreign_session() -> u32 { + std::process::id() ^ 1 +} + +fn part_name(name: &str, session: u32, seq: u64) -> String { + format!("{name}{PART_SUFFIX}-{session}-{seq}") +} + +fn write_part(dir: &Path, name: &str) -> PathBuf { + let path = dir.join(name); + fs::write(&path, b"0123456789").expect("写中间文件失败"); + path +} + +#[test] +fn part_file_names_carry_session_and_sequence() { + let dir = temp_dir("partfile-identity"); + let dest = dir.join("client.jar"); + + let first = PartFile::for_dest(&dest); + let second = PartFile::for_dest(&dest); + + assert_ne!( + first.path(), + second.path(), + "同一 dest 的两个守卫不能共用文件" + ); + assert_eq!(first.path().parent(), Some(dir.as_path())); + let name = first.path().file_name().unwrap().to_string_lossy(); + assert!( + name.starts_with(&format!("client.jar{PART_SUFFIX}-")), + "中间文件名要带进程标识:{name}" + ); + assert!( + name.contains(&std::process::id().to_string()), + "进程号要在名字里(跨进程隔离的依据):{name}" + ); +} + +#[test] +fn two_threads_writing_the_same_dest_commit_independently() { + let dir = temp_dir("partfile-threads"); + let dest = dir.join("client.jar"); + + let handles: Vec<_> = (0..2_u8) + .map(|index| { + let dest = dest.clone(); + std::thread::spawn(move || { + let payload = vec![b'a' + index; 4096]; + let part = PartFile::for_dest(&dest); + part.open_truncate() + .expect("打开中间文件失败") + .write_all(&payload) + .expect("写中间文件失败"); + part.commit(&dest).expect("原子落盘失败"); + payload + }) + }) + .collect(); + + let payloads: Vec> = handles + .into_iter() + .map(|handle| handle.join().expect("下载线程 panic")) + .collect(); + let written = fs::read(&dest).expect("读落盘文件失败"); + assert!( + payloads.contains(&written), + "落盘内容必须是某个线程的完整产物" + ); + assert!( + part_files(&dir).is_empty(), + "两线程各自的中间文件都要被提交掉:{:?}", + part_files(&dir) + ); +} + +#[test] +fn legacy_part_file_is_adopted_for_resume() { + let dir = temp_dir("partfile-legacy"); + let dest = dir.join("client.jar"); + let legacy = write_part(&dir, &format!("client.jar{PART_SUFFIX}")); + let legacy_meta = dir.join(format!("client.jar{PART_SUFFIX}{META_SUFFIX}")); + fs::write(&legacy_meta, b"{}").expect("写元数据失败"); + + let part = PartFile::for_dest(&dest); + + assert_ne!(part.path(), legacy.as_path(), "新守卫要换自己的名字"); + assert_eq!(part.len(), 10, "旧版中间文件必须被收养(续传语义不变)"); + assert!(!legacy.exists(), "收养后不许留下旧名字"); + assert!(!legacy_meta.exists(), "元数据要跟着中间文件一起搬"); + assert!(part.meta_path().is_file(), "收养后的元数据路径要就位"); +} + +#[test] +fn foreign_part_file_is_adopted_only_after_it_cools_down() { + let dir = temp_dir("partfile-foreign"); + let dest = dir.join("client.jar"); + let foreign = write_part(&dir, &part_name("client.jar", foreign_session(), 1)); + + let fresh = PartFile::for_dest(&dest); + assert!(!fresh.exists(), "外进程刚写过的中间文件不能抢"); + assert!(foreign.is_file(), "更不能删(可能正被那个进程写着)"); + + age_file(&foreign, ADOPT_MIN_AGE + Duration::from_secs(60)); + let adopted = PartFile::for_dest(&dest); + assert_eq!(adopted.len(), 10, "静置够久的外来中间文件应被收养续传"); + assert!(!foreign.exists(), "收养后旧名字应消失"); +} + +#[test] +fn sweeps_ownerless_orphans_but_keeps_fresh_and_live_files() { + let dir = temp_dir("partfile-sweep"); + let stale = write_part(&dir, &format!("other.jar{PART_SUFFIX}")); + let stale_meta = dir.join(format!("other.jar{PART_SUFFIX}{META_SUFFIX}")); + fs::write(&stale_meta, b"{}").expect("写元数据失败"); + age_file(&stale, ORPHAN_MAX_AGE + Duration::from_secs(60)); + let fresh = write_part(&dir, &format!("fresh.jar{PART_SUFFIX}")); + + let _guard = PartFile::for_dest(&dir.join("client.jar")); + + assert!(!stale.exists(), "无主且超龄的中间文件应被清理"); + assert!(!stale_meta.exists(), "它的元数据也要一起清"); + assert_eq!( + fs::read(&fresh).unwrap().len(), + 10, + "还新鲜的无主文件不能删" + ); +} + +#[test] +fn keeps_another_live_guard_of_the_same_dest() { + let dir = temp_dir("partfile-live"); + let dest = dir.join("client.jar"); + let running = PartFile::for_dest(&dest); + running + .open_truncate() + .expect("打开中间文件失败") + .write_all(&[b'x'; 1024]) + .expect("写中间文件失败"); + + let other = PartFile::for_dest(&dest); + + assert!( + running.path().is_file(), + "有人在用的中间文件不能被清理或收养" + ); + assert_eq!(running.len(), 1024); + assert_ne!(other.path(), running.path()); +} + +fn fast_engine(cache: &Path) -> DownloadEngine { + DownloadEngine::new( + EngineConfig { + max_attempts_per_source: 1, + backoff: BackoffPolicy { + base_ms: 1, + max_ms: 2, + jitter_ratio: 0.0, + max_attempts: 1, + }, + read_timeout: Duration::from_secs(5), + connect_timeout: Duration::from_secs(5), + chunked: ChunkedConfig::disabled(), + ..EngineConfig::default() + }, + Arc::new(MirrorTable::builtin()), + Arc::new(RegionProfile::direct(0)), + Arc::new(HealthRegistry::in_memory()), + Arc::new(ContentCache::open(cache)), + ) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn two_engines_downloading_the_same_dest_do_not_share_parts() { + let payload = b"same destination payload".to_vec(); + let server = MockServer::start(vec![ + MockResponse::ok(payload.clone()), + MockResponse::ok(payload.clone()), + ]) + .await; + let url = server.url("/client.jar"); + let dir = temp_dir("partfile-engine"); + let dest = dir.join("client.jar"); + // 两份独立的缓存目录:否则后到的那次会直接吃内容缓存,绕过中间文件路径。 + let left = fast_engine(&dir.join("cache-a")); + let right = fast_engine(&dir.join("cache-b")); + let request = || { + DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)) + .with_size(payload.len() as u64) + }; + + let (first, second) = tokio::join!(left.download(request()), right.download(request())); + + first.expect("并行下载 A 失败"); + second.expect("并行下载 B 失败"); + assert_eq!(fs::read(&dest).expect("读落盘文件失败"), payload); + assert!( + part_files(&dir).is_empty(), + "并发下载不能留下中间文件:{:?}", + part_files(&dir) + ); + server.stop(); +} diff --git a/crates/core/tests/download/region_test.rs b/crates/core/tests/download/region_test.rs new file mode 100644 index 0000000..9f84b7d --- /dev/null +++ b/crates/core/tests/download/region_test.rs @@ -0,0 +1,121 @@ +//! 地区判定的单测:只测纯判定与缓存,不真发探测请求。 + +use unlucky_core::download::region::{ + decide_from, load_cached, store_cached, RegionMode, RegionProfile, RegionTrigger, Signals, + DECISION_TTL_MS, DEFAULT_GH_PROXY, +}; + +fn signals(timezone: Option<&str>, language: Option<&str>, google: Option) -> Signals { + Signals { + timezone: timezone.map(str::to_string), + languages: language.map(str::to_string).into_iter().collect(), + google_reachable: google, + baidu_reachable: None, + } +} + +#[test] +fn timezone_signal_detects_greater_china() { + assert!(signals(Some("Asia/Shanghai"), None, None).is_greater_china()); + assert!(signals(Some("Asia/Taipei"), None, None).is_greater_china()); + // 大小写/空白容错。 + assert!(signals(Some(" asia/hong_kong "), None, None).is_greater_china()); + assert!(!signals(Some("Europe/Berlin"), None, None).is_greater_china()); +} + +#[test] +fn language_signal_handles_posix_and_bcp47() { + assert!(signals(None, Some("zh_CN.UTF-8"), None).is_greater_china()); + assert!(signals(None, Some("zh-Hant-TW"), None).is_greater_china()); + assert!(!signals(None, Some("en_US.UTF-8"), None).is_greater_china()); + // 时区不命中、语言命中也算。 + assert!(signals(Some("UTC"), Some("zh_TW"), None).is_greater_china()); +} + +#[test] +fn domestic_without_google_goes_mirror_first() { + let profile = decide_from(&signals(Some("Asia/Shanghai"), None, Some(false)), 1); + assert_eq!(profile.mode, RegionMode::MirrorFirst); + assert!(profile.greater_china); + assert_eq!(profile.trigger, RegionTrigger::Auto); + assert_eq!(profile.gh_proxy.as_deref(), Some(DEFAULT_GH_PROXY)); + assert!(profile.gh_proxy_enabled()); +} + +#[test] +fn domestic_with_google_reachable_stays_direct() { + // 已有全局代理:尊重用户代理,不再插镜像。 + let profile = decide_from(&signals(Some("Asia/Shanghai"), None, Some(true)), 1); + assert_eq!(profile.mode, RegionMode::Direct); + assert!(profile.gh_proxy.is_none()); + assert!(!profile.gh_proxy_enabled()); +} + +#[test] +fn non_greater_china_is_always_direct() { + for google in [Some(true), Some(false), None] { + let profile = decide_from(&signals(Some("America/New_York"), Some("en-US"), google), 1); + assert_eq!(profile.mode, RegionMode::Direct, "google={google:?}"); + assert!(!profile.greater_china); + } +} + +#[test] +fn probes_disabled_defaults_to_mirror_first_for_chinese_env() { + let profile = decide_from(&signals(None, Some("zh_CN.UTF-8"), None), 1); + assert_eq!(profile.mode, RegionMode::MirrorFirst); + assert!(!profile.probes_enabled); +} + +#[test] +fn pinned_mode_never_goes_stale() { + let pinned = RegionProfile::pinned(RegionMode::Direct, 10); + assert_eq!(pinned.trigger, RegionTrigger::UserPinned); + assert!(!pinned.is_stale(10 + DECISION_TTL_MS * 10)); + + let auto = decide_from(&signals(Some("Asia/Shanghai"), None, Some(false)), 10); + assert!(!auto.is_stale(10 + 1_000)); + assert!(auto.is_stale(10 + DECISION_TTL_MS + 1)); +} + +#[test] +fn gh_proxy_helpers() { + let mut profile = RegionProfile::mirror_first(0); + assert!(profile.gh_proxy_enabled()); + profile = profile.with_gh_proxy(Some(" ")); + assert!(!profile.gh_proxy_enabled(), "空白前缀视为不启用"); + profile = profile.with_gh_proxy(Some("https://proxy.example/")); + assert!(profile.gh_proxy_enabled()); + + // 直连模式下即便配了前缀也不生效(gh-proxy 只在国内模式用)。 + let direct = RegionProfile::direct(0).with_gh_proxy(Some("https://proxy.example/")); + assert!(!direct.gh_proxy_enabled()); + assert_eq!(direct.label(), "直连"); + assert_eq!(RegionProfile::mirror_first(0).label(), "镜像优先"); +} + +#[test] +fn cache_roundtrip_and_ttl_expiry() { + let dir = std::env::temp_dir().join(format!("ull-region-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("region.json"); + + let profile = decide_from(&signals(Some("Asia/Shanghai"), None, Some(false)), 1_000); + store_cached(&path, &profile).expect("写缓存失败"); + + let cached = load_cached(&path, 1_000 + 60_000).expect("应有缓存"); + assert_eq!(cached, profile); + + // 过期 → 视为没有缓存。 + assert!(load_cached(&path, 1_000 + DECISION_TTL_MS + 1).is_none()); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn missing_cache_file_is_not_an_error() { + let path = std::env::temp_dir().join("ull-region-does-not-exist.json"); + let _ = std::fs::remove_file(&path); + assert!(load_cached(&path, 0).is_none()); +} diff --git a/crates/core/tests/download/resume_test.rs b/crates/core/tests/download/resume_test.rs new file mode 100644 index 0000000..5c966fd --- /dev/null +++ b/crates/core/tests/download/resume_test.rs @@ -0,0 +1,223 @@ +//! 断点续传保守判定的单测:矩阵覆盖 + 416 处理。 + +use unlucky_core::download::resume::{ + decide, on_range_unsatisfiable, RestartReason, ResumeDecision, ResumeMeta, Validator, +}; +use unlucky_core::download::types::{HashAlgo, ResumePolicy}; + +const URL: &str = "https://bmclapi2.bangbang93.com/versions/1.20.1/client.jar"; +const HASH: &str = "0123456789abcdef0123456789abcdef01234567"; + +fn meta(url: &str) -> ResumeMeta { + ResumeMeta { + url: url.to_string(), + etag: Some("\"abc\"".to_string()), + last_modified: None, + total: Some(1_000), + algo: HashAlgo::Sha1, + hash: HASH.to_string(), + saved_at_ms: 1, + } +} + +fn resume_ok(part_len: u64, meta: &ResumeMeta) -> ResumeDecision { + decide( + ResumePolicy::Conservative, + part_len, + Some(meta), + URL, + Some((HashAlgo::Sha1, HASH)), + Some(1_000), + ) +} + +#[test] +fn resumes_when_everything_matches() { + let decision = resume_ok(400, &meta(URL)); + assert_eq!( + decision, + ResumeDecision::Resume { + offset: 400, + validator: Validator { + etag: Some("\"abc\"".to_string()), + last_modified: None, + }, + } + ); +} + +#[test] +fn never_policy_and_missing_files_restart() { + let meta = meta(URL); + assert_eq!( + decide( + ResumePolicy::Never, + 400, + Some(&meta), + URL, + Some((HashAlgo::Sha1, HASH)), + Some(1_000) + ), + ResumeDecision::Restart { + reason: RestartReason::PolicyNever + } + ); + assert_eq!( + resume_ok(0, &meta), + ResumeDecision::Restart { + reason: RestartReason::EmptyPart + } + ); + // 没有元数据:无法证明中间文件是同一次下载的产物。 + assert_eq!( + decide( + ResumePolicy::Conservative, + 400, + None, + URL, + Some((HashAlgo::Sha1, HASH)), + Some(1_000) + ), + ResumeDecision::Restart { + reason: RestartReason::NoMeta + } + ); +} + +#[test] +fn changed_url_or_hash_restarts() { + assert_eq!( + decide( + ResumePolicy::Conservative, + 400, + Some(&meta("https://other.example/client.jar")), + URL, + Some((HashAlgo::Sha1, HASH)), + Some(1_000) + ), + ResumeDecision::Restart { + reason: RestartReason::UrlChanged + } + ); + assert_eq!( + decide( + ResumePolicy::Conservative, + 400, + Some(&meta(URL)), + URL, + Some((HashAlgo::Sha1, "ffffffffffffffffffffffffffffffffffffffff")), + Some(1_000) + ), + ResumeDecision::Restart { + reason: RestartReason::HashChanged + } + ); +} + +#[test] +fn unknown_total_or_oversize_restarts() { + let mut unknown = meta(URL); + unknown.total = None; + assert_eq!( + decide( + ResumePolicy::Conservative, + 400, + Some(&unknown), + URL, + None, + None + ), + ResumeDecision::Restart { + reason: RestartReason::TotalUnknown + } + ); + + assert_eq!( + resume_ok(2_000, &meta(URL)), + ResumeDecision::Restart { + reason: RestartReason::Oversize + } + ); +} + +#[test] +fn complete_part_skips_transfer() { + assert_eq!( + resume_ok(1_000, &meta(URL)), + ResumeDecision::Complete { size: 1_000 } + ); +} + +#[test] +fn missing_validator_restarts() { + let mut weak = meta(URL); + weak.etag = None; + weak.last_modified = None; + assert_eq!( + resume_ok(400, &weak), + ResumeDecision::Restart { + reason: RestartReason::NoValidator + } + ); + // Last-Modified 单独也能当校验器。 + weak.last_modified = Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()); + assert!(matches!( + resume_ok(400, &weak), + ResumeDecision::Resume { offset: 400, .. } + )); +} + +#[test] +fn expected_size_can_stand_in_for_missing_total() { + let mut unknown = meta(URL); + unknown.total = None; + let decision = decide( + ResumePolicy::Conservative, + 400, + Some(&unknown), + URL, + Some((HashAlgo::Sha1, HASH)), + Some(1_000), + ); + assert!(matches!( + decision, + ResumeDecision::Resume { offset: 400, .. } + )); +} + +#[test] +fn range_not_satisfiable_handling() { + // 已下满:直接进入校验,不要白删一个好文件。 + assert_eq!( + on_range_unsatisfiable(1_000, Some(1_000)), + ResumeDecision::Complete { size: 1_000 } + ); + // 没下满却 416:中间文件不可信,丢掉重来。 + let decision = on_range_unsatisfiable(400, Some(1_000)); + assert_eq!( + decision, + ResumeDecision::Restart { + reason: RestartReason::RangeUnsatisfiable + } + ); + assert!(decision.should_drop_part()); + // 不允许丢掉「已完成」的中间文件。 + assert!(!ResumeDecision::Complete { size: 1 }.should_drop_part()); +} + +#[test] +fn resume_meta_roundtrip_and_removal() { + let dir = std::env::temp_dir().join(format!("ull-resume-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("client.jar.ullpart.ullmeta.json"); + + let original = meta(URL); + original.save(&path).expect("写元数据失败"); + assert_eq!(ResumeMeta::load(&path), Some(original)); + + ResumeMeta::remove(&path); + assert!(ResumeMeta::load(&path).is_none()); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/core/tests/download/support/mod.rs b/crates/core/tests/download/support/mod.rs new file mode 100644 index 0000000..fb9330d --- /dev/null +++ b/crates/core/tests/download/support/mod.rs @@ -0,0 +1,278 @@ +//! 测试支撑:临时目录、摘要工具、进程内极简 HTTP 服务器(零额外依赖)。 +//! +//! 为什么自己写 mock server 而不用 wiremock:P0 阶段控制依赖面(Win7 Tier-3 构建 + +//! 单文件体积),一个 60 行的 `TcpListener` 足够表达「成功 / 500 / 校验不符 / 无视 Range」。 + +#![allow(dead_code)] + +use std::net::SocketAddr; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +/// 断言用的小工具:唯一临时目录(进程内计数 + 时间戳,避免并行测试互相踩)。 +pub fn temp_dir(tag: &str) -> std::path::PathBuf { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_nanos()); + let dir = std::env::temp_dir().join(format!("ull-download-test-{tag}-{stamp}-{unique}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("创建临时目录失败"); + dir +} + +/// 算一段字节的 SHA-1(测试断言用)。 +pub fn sha1_hex(bytes: &[u8]) -> String { + use sha1::Digest; + let digest = sha1::Sha1::digest(bytes); + let mut out = String::with_capacity(40); + for byte in digest { + out.push_str(&format!("{byte:02x}")); + } + out +} + +/// 目录里(不含子目录)所有中间文件家族的名字(`.ullpart` 与 `.ullmeta.json`)。 +pub fn part_files(dir: &std::path::Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut names: Vec = entries + .flatten() + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.contains(unlucky_core::download::PART_SUFFIX)) + .collect(); + names.sort(); + names +} + +/// 把文件的修改时间挪到「`age` 之前」(清理/收养的年龄判定用)。 +pub fn age_file(path: &std::path::Path, age: std::time::Duration) { + let times = std::fs::FileTimes::new().set_modified(std::time::SystemTime::now() - age); + std::fs::File::options() + .write(true) + .open(path) + .expect("打开待改 mtime 的文件失败") + .set_times(times) + .expect("改 mtime 失败"); +} + +/// mock 服务器给一次请求的应答。 +#[derive(Debug, Clone)] +pub struct MockResponse { + /// HTTP 状态码。 + pub status: u16, + /// 响应体。 + pub body: Vec, + /// 是否声明 `Accept-Ranges: bytes` 并在收到 `Range` 时真的切段。 + pub range_support: bool, + /// `ETag`(续传测试用)。 + pub etag: Option, + /// 返回 206 时故意把 `Content-Range` 写成从 0 开始(模拟段错位的坏服务器)。 + pub lie_content_range: bool, +} + +impl MockResponse { + /// 200 + 完整内容(支持 Range)。 + pub fn ok(body: impl Into>) -> Self { + Self { + status: 200, + body: body.into(), + range_support: true, + etag: Some("\"v1\"".to_string()), + lie_content_range: false, + } + } + + /// 固定状态码 + 空体。 + pub fn status(status: u16) -> Self { + Self { + status, + body: Vec::new(), + range_support: false, + etag: None, + lie_content_range: false, + } + } + + /// 声明不支持 Range(模拟部分镜像)。 + pub fn without_range(mut self) -> Self { + self.range_support = false; + self + } + + /// 206 但 `Content-Range` 谎报(模拟段错位的坏 CDN)。 + pub fn with_wrong_content_range(mut self) -> Self { + self.lie_content_range = true; + self + } +} + +/// 进程内极简 HTTP/1.1 服务器:按脚本顺序返回响应,脚本用尽后一律 500。 +pub struct MockServer { + addr: SocketAddr, + hits: Arc, + handle: tokio::task::JoinHandle<()>, +} + +impl MockServer { + /// 启动服务器(脚本是「每次请求一个响应」的队列)。 + pub async fn start(script: Vec) -> Self { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("绑定端口失败"); + let addr = listener.local_addr().expect("取本地地址失败"); + let hits = Arc::new(AtomicUsize::new(0)); + let script = Arc::new(std::sync::Mutex::new(script)); + let counter = Arc::clone(&hits); + + let handle = tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + let script = Arc::clone(&script); + let counter = Arc::clone(&counter); + tokio::spawn(async move { + let index = counter.fetch_add(1, Ordering::Relaxed); + let response = { + let script = script.lock().unwrap_or_else(|err| err.into_inner()); + script.get(index).cloned() + }; + let response = response.unwrap_or_else(|| MockResponse::status(500)); + let _ = serve(stream, response).await; + }); + } + }); + + Self { addr, hits, handle } + } + + /// 第 `index` 个请求该用的 URL(换成不同路径可模拟「不同源」)。 + pub fn url(&self, path: &str) -> String { + format!("http://{}{}", self.addr, path) + } + + /// 换 host 拼 URL(用 `localhost` vs `127.0.0.1` 造出两个不同源)。 + pub fn url_with(&self, host: &str, path: &str) -> String { + format!("http://{host}:{}{}", self.addr.port(), path) + } + + /// 端口号。 + pub fn port(&self) -> u16 { + self.addr.port() + } + + /// 已收到的请求数。 + pub fn hits(&self) -> usize { + self.hits.load(Ordering::Relaxed) + } + + /// 关停。 + pub fn stop(self) { + self.handle.abort(); + } +} + +async fn serve(mut stream: TcpStream, response: MockResponse) -> std::io::Result<()> { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 1024]; + loop { + let read = stream.read(&mut chunk).await?; + if read == 0 { + break; + } + buffer.extend_from_slice(&chunk[..read]); + if buffer.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let request = String::from_utf8_lossy(&buffer).to_ascii_lowercase(); + let range = request + .lines() + .find_map(|line| line.strip_prefix("range: bytes=")) + .map(str::trim) + .and_then(parse_range_header); + + let total = response.body.len() as u64; + let (status, headers, body) = match (range, response.range_support) { + (Some((start, requested_end)), true) if start < total => { + // 尊重请求里的结束偏移(分块下载必须逐段精确,否则组装后长度对不上)。 + let end = requested_end.unwrap_or(total - 1).min(total - 1); + let slice = response.body[start as usize..=end as usize].to_vec(); + // 谎报模式:body 仍是正确的那一段,但头部声称从 0 开始(长度恰好 → 只有 + // 严格比对 Content-Range 才抓得住)。 + let (claimed_start, claimed_end) = if response.lie_content_range { + (0, slice.len().saturating_sub(1) as u64) + } else { + (start, end) + }; + ( + 206, + vec![ + format!("Content-Range: bytes {claimed_start}-{claimed_end}/{total}"), + format!("Content-Length: {}", slice.len()), + "Accept-Ranges: bytes".to_string(), + ], + slice, + ) + } + (Some(_), true) => ( + 416, + vec![ + format!("Content-Range: bytes */{total}"), + "Content-Length: 0".to_string(), + ], + Vec::new(), + ), + _ => { + let mut headers = vec![format!("Content-Length: {total}")]; + if response.range_support { + headers.push("Accept-Ranges: bytes".to_string()); + } + (response.status, headers, response.body.clone()) + } + }; + let mut head = format!("HTTP/1.1 {status} {}\r\n", reason(status)); + if let Some(etag) = &response.etag { + head.push_str(&format!("ETag: {etag}\r\n")); + } + for header in headers { + head.push_str(&header); + head.push_str("\r\n"); + } + head.push_str("Connection: close\r\n\r\n"); + stream.write_all(head.as_bytes()).await?; + stream.write_all(&body).await?; + stream.flush().await?; + Ok(()) +} + +/// 解析 `Range: bytes=-`(`end` 可缺省 = 到文件末尾)。 +fn parse_range_header(value: &str) -> Option<(u64, Option)> { + let (start, end) = value.split_once('-')?; + let start = start.trim().parse::().ok()?; + let end = end.trim(); + let end = if end.is_empty() { + None + } else { + Some(end.parse::().ok()?) + }; + Some((start, end)) +} + +fn reason(status: u16) -> &'static str { + match status { + 200 => "OK", + 206 => "Partial Content", + 404 => "Not Found", + 416 => "Range Not Satisfiable", + 500 => "Internal Server Error", + 503 => "Service Unavailable", + _ => "Status", + } +} diff --git a/crates/core/tests/game/arguments_rules.rs b/crates/core/tests/game/arguments_rules.rs new file mode 100644 index 0000000..b7350af --- /dev/null +++ b/crates/core/tests/game/arguments_rules.rs @@ -0,0 +1,256 @@ +//! 规则求值、参数展开、natives 选择(launch 直接消费这些结果)。 + +use std::collections::BTreeMap; + +use unlucky_core::game::arguments::{ + matches_simple_pattern, rules_allow, Argument, ArgumentValue, ConditionalArgument, Environment, + OsRestriction, Rule, RuleAction, +}; +use unlucky_core::game::library::Library; + +use crate::helpers::parse; + +fn env(os: &str, arch: &str) -> Environment { + Environment { + os: os.to_owned(), + os_version: "10.0".to_owned(), + arch: arch.to_owned(), + features: BTreeMap::new(), + } +} + +fn allow_os(name: &str) -> Rule { + Rule { + action: RuleAction::Allow, + os: Some(OsRestriction { + name: Some(name.to_owned()), + ..OsRestriction::default() + }), + features: None, + } +} + +fn disallow_os(name: &str) -> Rule { + Rule { + action: RuleAction::Disallow, + ..allow_os(name) + } +} + +#[test] +fn empty_rules_always_allow() { + assert!(rules_allow(&[], &env("linux", "x86_64"))); +} + +#[test] +fn allow_rule_must_match_environment() { + let rules = [allow_os("windows")]; + assert!(rules_allow(&rules, &env("windows", "x86_64"))); + assert!(!rules_allow(&rules, &env("linux", "x86_64"))); +} + +#[test] +fn later_rules_override_earlier_ones() { + // 白名单语义:列表非空时从 DISALLOW 起步。 + let allow_after_disallow = [disallow_os("windows"), allow_os("windows")]; + assert!(rules_allow( + &allow_after_disallow, + &env("windows", "x86_64") + )); + + let disallow_after_allow = [allow_os("windows"), disallow_os("windows")]; + assert!(!rules_allow( + &disallow_after_allow, + &env("windows", "x86_64") + )); +} + +#[test] +fn feature_gate_blocks_argument() { + let mut features = BTreeMap::new(); + features.insert("has_custom_resolution".to_owned(), true); + let rule = Rule { + action: RuleAction::Allow, + os: None, + features: Some(features), + }; + + let enabled = env("windows", "x86_64").with_feature("has_custom_resolution", true); + let disabled = env("windows", "x86_64"); + + assert_eq!(rule.action_in(&enabled), Some(RuleAction::Allow)); + assert_eq!(rule.action_in(&disabled), None); +} + +#[test] +fn host_environment_is_mojang_shaped() { + let host = Environment::host(); + assert!(matches!(host.os.as_str(), "windows" | "osx" | "linux")); + assert!(!host.arch.is_empty()); + assert!(!host.feature("has_custom_resolution")); + // 空串是「没探」,真值来自 platform::os_info::current();只要探得到就不能再是空的。 + let probed = unlucky_core::platform::os_info::current(); + assert_eq!(host.os_version, probed.os_version()); + assert_eq!(host.os, probed.family.manifest_name()); + assert_eq!(host.arch, probed.arch); +} + +#[test] +fn environment_from_an_injected_os_snapshot_carries_os_version() { + use unlucky_core::platform::linux_caps::SessionKind; + use unlucky_core::platform::os_info::{support_level_for, OsFamily, OsInfo}; + use unlucky_core::platform::vk_probe::VkProbe; + + let fake = OsInfo { + family: OsFamily::Windows, + arch: "x86_64".to_string(), + major_minor: Some((10, 0)), + build: Some(19_045), + product: "Windows 10".to_string(), + version_string: "10.0.19045".to_string(), + session: SessionKind::Unknown, + gl: unlucky_core::platform::linux_caps::GlProbe::Unknown, + vk: VkProbe::Available, + kernel: None, + glibc: None, + support_level: support_level_for(OsFamily::Windows, Some((10, 0)), None, None), + }; + + let environment = Environment::from_os_info(&fake); + assert_eq!(environment.os, "windows"); + assert_eq!(environment.os_version, "10.0.19045"); + assert_eq!(environment.arch, "x86_64"); + assert!(environment.features.is_empty(), "注入快照不该带特性开关"); + + // 系统版本真的参与规则求值(这正是不再用空串的原因)。 + let rule = Rule { + os: Some(OsRestriction { + name: None, + version: Some("^10\\.".to_string()), + arch: None, + }), + ..Rule::default() + }; + assert_eq!(rule.action_in(&environment), Some(RuleAction::Allow)); + + let mac_rule = Rule { + os: Some(OsRestriction { + name: None, + version: Some("^11\\.".to_string()), + arch: None, + }), + ..Rule::default() + }; + assert_eq!(mac_rule.action_in(&environment), None); +} + +#[test] +fn os_version_patterns_match_conservatively() { + assert!(matches_simple_pattern("^10\\.", "10.0")); + assert!(!matches_simple_pattern("^10\\.", "11.0")); + assert!(matches_simple_pattern("x86_64", "x86_64")); + assert!(!matches_simple_pattern("x86_64", "x86")); + assert!(matches_simple_pattern("10\\.0$", "10.0")); + // 不认识的复杂正则放宽为匹配(宁可多带参数也不漏参数)。 + assert!(matches_simple_pattern("^(10|11)\\.", "11.0")); +} + +#[test] +fn expand_arguments_by_environment() { + let version = parse( + r#"{ + "id": "x", + "arguments": { + "game": [ + "--username", + { "rules": [{ "action": "allow", "features": { "is_demo_user": true } }], + "value": "--demo" }, + { "value": ["--pair", "1"] } + ], + "jvm": [ "-Dfoo=1" ] + } + }"#, + ); + let arguments = version.arguments.as_ref().expect("arguments 应解析"); + + let plain = arguments.expand(&env("windows", "x86_64")); + assert_eq!(plain.game, vec!["--username", "--pair", "1"]); + assert_eq!(plain.jvm, vec!["-Dfoo=1"]); + + let demo = env("windows", "x86_64").with_feature("is_demo_user", true); + assert!(arguments.expand(&demo).game.contains(&"--demo".to_owned())); +} + +#[test] +fn conditional_argument_expands_single_value() { + let argument = Argument::Conditional(ConditionalArgument { + rules: vec![allow_os("windows")], + value: ArgumentValue::One("--single".to_owned()), + }); + let mut out = Vec::new(); + argument.expand_into(&env("windows", "x86_64"), &mut out); + assert_eq!(out, vec!["--single"]); + + let mut out = Vec::new(); + argument.expand_into(&env("linux", "x86_64"), &mut out); + assert!(out.is_empty()); +} + +#[test] +fn native_classifier_expands_arch() { + let version = parse( + r#"{ "id": "x", + "libraries": [{ + "name": "org.lwjgl:lwjgl:3.3.2", + "natives": { "windows": "natives-windows-${arch}", "linux": "natives-linux" }, + "downloads": { + "artifact": { "path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2.jar" }, + "classifiers": { + "natives-windows-64": { "path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-64.jar" } + } + } + }] + }"#, + ); + let library = &version.libraries.as_ref().expect("libraries 应解析")[0]; + + assert_eq!( + library.native_classifier(&env("windows", "x86_64")), + Some("natives-windows-64".to_owned()) + ); + assert_eq!( + library.native_classifier(&env("windows", "x86")), + Some("natives-windows-".to_owned()) + ); + assert_eq!( + library.native_classifier(&env("linux", "x86_64")), + Some("natives-linux".to_owned()) + ); + assert_eq!(library.native_classifier(&env("osx", "arm64")), None); + + let classifier = "natives-windows-64"; + assert!(library.native_artifact(classifier).is_some()); + assert_eq!( + library + .native_relative_path(classifier) + .expect("路径应可推导"), + std::path::PathBuf::from("org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-64.jar") + ); +} + +#[test] +fn library_rules_and_local_hint() { + let library = Library { + name: "com.example:inst:1".to_owned(), + rules: vec![allow_os("linux")], + hint: Some("local".to_owned()), + ..Library::default() + }; + assert!(library.applies(&env("linux", "x86_64"))); + assert!(!library.applies(&env("windows", "x86_64"))); + assert!(library.is_local()); + assert_eq!( + library.relative_path().expect("路径应可推导"), + std::path::PathBuf::from("com/example/inst/1/inst-1.jar") + ); +} diff --git a/crates/core/tests/game/helpers.rs b/crates/core/tests/game/helpers.rs new file mode 100644 index 0000000..59461e2 --- /dev/null +++ b/crates/core/tests/game/helpers.rs @@ -0,0 +1,66 @@ +//! 测试公用件:fixture 解析、内存父版本表、临时目录守卫。 + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use unlucky_core::game::manifest::VersionJson; + +/// 解析 fixture JSON(fixture 是测试常量,解析失败即 panic)。 +pub fn parse(json: &str) -> VersionJson { + VersionJson::from_json_str(json).expect("fixture 必须可解析") +} + +/// 用 `(id, json)` 对建内存父版本表,喂给 `inherit::resolve`。 +pub fn source(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(id, json)| ((*id).to_owned(), parse(json))) + .collect() +} + +/// 唯一临时目录守卫:`drop` 时递归删除。 +pub struct TempDir { + path: PathBuf, +} + +impl TempDir { + /// 建一个唯一临时目录。 + pub fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "ull-game-{tag}-{}-{nanos}-{seq}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("临时目录应可创建"); + Self { path } + } + + /// 目录路径。 + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +/// 建一个游戏根布局(纯路径计算,不建目录)。 +pub fn layout(root: &Path) -> unlucky_core::game::GameLayout { + unlucky_core::game::GameLayout::new(root) +} + +/// 造 `dir/` 并写一个文件进去(模拟玩家内容:`mods`/`saves`/`config` 的条目)。 +pub fn seed(dir: &Path, name: &str) { + let target = dir.join(name); + std::fs::create_dir_all(&target).expect("建目录"); + std::fs::write(target.join("entry.txt"), b"x").expect("写文件"); +} diff --git a/crates/core/tests/game/inherit_chain.rs b/crates/core/tests/game/inherit_chain.rs new file mode 100644 index 0000000..1172572 --- /dev/null +++ b/crates/core/tests/game/inherit_chain.rs @@ -0,0 +1,265 @@ +//! `inheritsFrom` 解析:逐字段 merge 语义、patch 优先级、环检测、缺失父版本。 + +use unlucky_core::game::error::GameError; +use unlucky_core::game::inherit::{apply_patches, merge, resolve, sorted_patches}; +use unlucky_core::game::version::ReleaseType; + +use crate::helpers::{parse, source}; + +const VANILLA: &str = r#"{ + "id": "1.20.1", + "type": "release", + "releaseTime": "2023-06-12T13:25:51+00:00", + "mainClass": "net.minecraft.client.main.Main", + "minecraftArguments": "--username ${auth_player_name}", + "assets": "5", + "assetIndex": { "id": "5" }, + "complianceLevel": 1, + "minimumLauncherVersion": 21, + "javaVersion": { "component": "java-runtime-gamma", "majorVersion": 17 }, + "libraries": [ { "name": "lib:a:1" }, { "name": "lib:b:1" } ], + "downloads": { + "client": { "url": "https://example.invalid/client.jar", "sha1": "c", "size": 1 }, + "server": { "url": "https://example.invalid/server.jar", "sha1": "s", "size": 1 } + }, + "arguments": { "game": ["--game-parent"], "jvm": ["-Dparent=1"] }, + "logging": { "client": { "argument": "-Dlog4j", "file": { "id": "client-1.12.xml" } } } +}"#; + +const FORGE: &str = r#"{ + "id": "1.20.1-forge-47.2.0", + "inheritsFrom": "1.20.1", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "time": "2024-01-01T00:00:00+00:00", + "minimumLauncherVersion": 14, + "javaVersion": { "component": "java-runtime-delta", "majorVersion": 21 }, + "libraries": [ { "name": "net.minecraftforge:forge:1" } ], + "downloads": { "server": { "url": "https://example.invalid/server2.jar" } }, + "arguments": { "game": ["--game-child"], "jvm": ["-Dchild=1"] } +}"#; + +fn vanilla_and_forge( +) -> std::collections::BTreeMap { + source(&[("1.20.1", VANILLA), ("1.20.1-forge-47.2.0", FORGE)]) +} + +#[test] +fn merge_table_follows_analyse_03() { + let sources = vanilla_and_forge(); + let child = parse(FORGE); + let resolved = resolve(&sources, &child).expect("继承链应可解析"); + + // 子覆盖父。 + assert_eq!( + resolved.main_class.as_deref(), + Some("cpw.mods.bootstraplauncher.BootstrapLauncher") + ); + // 子没写就继承父。 + assert_eq!( + resolved.minecraft_arguments.as_deref(), + Some("--username ${auth_player_name}") + ); + assert_eq!(resolved.assets.as_deref(), Some("5")); + assert_eq!(resolved.version_type, Some(ReleaseType::Release)); + + // arguments 父在前、子在后追加。 + let arguments = resolved.arguments.as_ref().expect("arguments 应保留"); + assert_eq!(arguments.game.len(), 2); + assert_eq!(arguments.jvm.len(), 2); + + // libraries 父在前、子在后。 + let libraries = resolved.libraries.as_ref().expect("libraries 应保留"); + let names: Vec<&str> = libraries.iter().map(|l| l.name.as_str()).collect(); + assert_eq!( + names, + vec!["lib:a:1", "lib:b:1", "net.minecraftforge:forge:1"] + ); + + // downloads 整体覆盖:子只写 server,client 就没了(非直觉点 1,必须显式守门)。 + let downloads = resolved.downloads.as_ref().expect("downloads 应存在"); + assert!(downloads.client().is_none(), "整体覆盖会让父层 client 消失"); + assert_eq!( + downloads.server().and_then(|s| s.url.as_deref()), + Some("https://example.invalid/server2.jar") + ); + + // complianceLevel 只取子(非直觉点 2):父层 1 被丢弃。 + assert_eq!(resolved.compliance_level, None); + + // javaVersion 子覆盖父。 + let java = resolved.java_version.as_ref().expect("javaVersion 应存在"); + assert_eq!(java.major_version, Some(21)); + + // minimumLauncherVersion 取 max(21, 14)=21。 + assert_eq!(resolved.minimum_launcher_version, Some(21)); + + // logging 子没有 → 继承父。 + assert!(resolved.logging.is_some()); + + // 解析结果的不变量。 + assert_eq!(resolved.jar.as_deref(), Some("1.20.1-forge-47.2.0")); + assert!(resolved.inherits_from.is_none()); + assert!(resolved.patches.is_none()); + assert_eq!(resolved.root, Some(true)); + assert!(resolved.is_resolved()); +} + +#[test] +fn three_level_chain_orders_ancestor_first() { + const A: &str = r#"{ "id": "a", "mainClass": "a.Main", "libraries": [{ "name": "l:a:1" }] }"#; + const B: &str = r#"{ "id": "b", "inheritsFrom": "a", "libraries": [{ "name": "l:b:1" }] }"#; + const C: &str = r#"{ "id": "c", "inheritsFrom": "b", "mainClass": "c.Main", + "libraries": [{ "name": "l:c:1" }] }"#; + let sources = source(&[("a", A), ("b", B), ("c", C)]); + + let resolved = resolve(&sources, &parse(C)).expect("三层链应可解析"); + + assert_eq!(resolved.main_class.as_deref(), Some("c.Main")); + let names: Vec<&str> = resolved + .libraries + .as_ref() + .expect("libraries 应存在") + .iter() + .map(|l| l.name.as_str()) + .collect(); + assert_eq!(names, vec!["l:a:1", "l:b:1", "l:c:1"]); +} + +#[test] +fn patch_priority_sorts_ascending() { + let root = parse( + r#"{ + "id": "patched", + "inheritsFrom": "1.20.1", + "patches": [ + { "id": "forge", "priority": 30000, "mainClass": "loader.Main", + "libraries": [{ "name": "loader:a:1" }] }, + { "id": "base", "mainClass": "base.Main", + "arguments": { "game": ["--base"] } } + ] + }"#, + ); + let resolved = resolve(&vanilla_and_forge(), &root).expect("带 patches 的清单应可解析"); + + // 无 priority 的补丁排最前(Integer.MIN_VALUE 语义),loader 后合并 → 覆盖 mainClass。 + assert_eq!(resolved.main_class.as_deref(), Some("loader.Main")); + let libraries = resolved.libraries.as_ref().expect("libraries 应存在"); + assert_eq!( + libraries.last().map(|l| l.name.as_str()), + Some("loader:a:1") + ); + + let arguments = resolved.arguments.as_ref().expect("arguments 应存在"); + let flat: Vec = arguments.game.iter().map(|a| format!("{a:?}")).collect(); + assert!(flat.iter().any(|a| a.contains("--base"))); +} + +#[test] +fn equal_priority_patches_keep_insertion_order() { + let root = parse( + r#"{ + "id": "patched", + "patches": [ + { "id": "first", "priority": 10, "mainClass": "first.Main" }, + { "id": "second", "priority": 10, "mainClass": "second.Main" } + ] + }"#, + ); + let resolved = resolve(&source(&[]), &root).expect("无继承的清单应可解析"); + assert_eq!( + resolved.main_class.as_deref(), + Some("second.Main"), + "同优先级按插入顺序合并,后者覆盖前者" + ); +} + +#[test] +fn sorted_patches_puts_missing_priority_first() { + let patches = vec![ + parse(r#"{ "id": "loader", "priority": 30000 }"#), + parse(r#"{ "id": "base" }"#), + parse(r#"{ "id": "mc", "priority": 0 }"#), + ]; + let order: Vec<&str> = sorted_patches(&patches) + .iter() + .filter_map(|p| p.id.as_deref()) + .collect(); + assert_eq!(order, vec!["base", "mc", "loader"]); +} + +#[test] +fn apply_patches_stacks_layers() { + let base = + parse(r#"{ "id": "root", "mainClass": "base.Main", "libraries": [{ "name": "l:a:1" }] }"#); + let patched = apply_patches( + base, + &[parse( + r#"{ "id": "loader", "priority": 30000, "mainClass": "loader.Main", + "libraries": [{ "name": "l:loader:1" }] }"#, + )], + ); + assert_eq!(patched.main_class.as_deref(), Some("loader.Main")); + let names: Vec<&str> = patched + .libraries + .as_ref() + .expect("libraries 应存在") + .iter() + .map(|l| l.name.as_str()) + .collect(); + assert_eq!(names, vec!["l:a:1", "l:loader:1"]); +} + +#[test] +fn circular_inheritance_is_reported() { + let sources = source(&[ + ("a", r#"{ "id": "a", "inheritsFrom": "b" }"#), + ("b", r#"{ "id": "b", "inheritsFrom": "a" }"#), + ]); + let error = resolve(&sources, &parse(r#"{ "id": "a", "inheritsFrom": "b" }"#)) + .expect_err("成环必须报错而不是静默降级"); + match error { + GameError::CircularInheritance { chain } => { + assert_eq!(chain, vec!["a", "b", "a"], "链要能看出环的位置"); + } + other => panic!("期望 CircularInheritance,得到 {other:?}"), + } +} + +#[test] +fn missing_parent_is_reported() { + let error = resolve(&source(&[]), &parse(FORGE)).expect_err("缺失父版本必须报错"); + match error { + GameError::MissingParent { + parent, + required_by, + } => { + assert_eq!(parent, "1.20.1"); + assert_eq!(required_by, "1.20.1-forge-47.2.0"); + } + other => panic!("期望 MissingParent,得到 {other:?}"), + } +} + +#[test] +fn manifest_without_id_is_rejected() { + let error = + resolve(&source(&[]), &parse(r#"{ "mainClass": "a.B" }"#)).expect_err("缺 id 应报错"); + assert!(matches!(error, GameError::MissingVersionId)); +} + +#[test] +fn merge_preserves_unknown_fields_with_child_winning() { + let parent = parse(r#"{ "id": "p", "customParentOnly": 1, "shared": "parent" }"#); + let child = parse(r#"{ "id": "c", "shared": "child" }"#); + let merged = merge(&child, &parent); + + assert_eq!( + merged.extra.get("customParentOnly"), + Some(&serde_json::json!(1)) + ); + assert_eq!( + merged.extra.get("shared"), + Some(&serde_json::json!("child")) + ); + assert_eq!(merged.id.as_deref(), Some("c")); +} diff --git a/crates/core/tests/game/isolation.rs b/crates/core/tests/game/isolation.rs new file mode 100644 index 0000000..7e0e96f --- /dev/null +++ b/crates/core/tests/game/isolation.rs @@ -0,0 +1,194 @@ +//! D20 实例隔离:覆盖链派生、双侧切换检测、稀疏落盘不变量。 +//! +//! 对应 mission 的验收点:三档派生 / 覆盖链优先级 / 切换检测(双侧空、单侧、双侧)/ +//! `inherit` 不落盘 / 恒共享层不可配。 + +use std::path::Path; + +use unlucky_core::game::install::instance::{InstanceConfig, Isolation}; +use unlucky_core::game::isolation::{ + derive_game_dir, probe_both_sides, probe_side, IsolationMode, IsolationSource, + PROBED_CONTENT_DIRS, SHARED_LAYERS, +}; +use unlucky_core::game::GameError; + +use super::helpers::{layout, seed, TempDir}; + +// ---------------------------------------------------------------- 派生(三档) + +#[test] +fn isolated_mode_derives_version_dir() { + let temp = TempDir::new("iso-derived"); + let game = layout(temp.path()); + let derived = derive_game_dir(&game, "1.20.1", IsolationMode::Isolated, None, None) + .expect("isolated 一定能派生"); + assert_eq!(derived.isolation, Isolation::Isolated); + assert_eq!(derived.game_dir, game.version_dir("1.20.1")); + assert_eq!(derived.source, IsolationSource::Global); +} + +#[test] +fn shared_mode_derives_game_root() { + let temp = TempDir::new("iso-shared"); + let game = layout(temp.path()); + let derived = derive_game_dir(&game, "1.20.1", IsolationMode::Shared, None, None) + .expect("shared 一定能派生"); + assert_eq!(derived.isolation, Isolation::Shared); + assert_eq!(derived.game_dir, game.root()); +} + +#[test] +fn custom_mode_uses_instance_game_dir() { + let temp = TempDir::new("iso-custom"); + let game = layout(temp.path()); + let custom = temp.path().join("elsewhere").join("farm"); + let mut config = InstanceConfig::new("id-1", "1.20.1"); + config.isolation = Some(Isolation::Custom); + config.game_dir = Some(custom.clone()); + let derived = derive_game_dir( + &game, + "1.20.1", + IsolationMode::Isolated, + Some(&config), + None, + ) + .expect("custom 有目录就能派生"); + assert_eq!(derived.isolation, Isolation::Custom); + assert_eq!(derived.game_dir, custom); + assert_eq!(derived.source, IsolationSource::Instance); +} + +#[test] +fn custom_without_game_dir_is_an_error() { + let temp = TempDir::new("iso-custom-missing"); + let game = layout(temp.path()); + let mut config = InstanceConfig::new("id-missing", "1.20.1"); + config.isolation = Some(Isolation::Custom); + let error = derive_game_dir( + &game, + "1.20.1", + IsolationMode::Isolated, + Some(&config), + None, + ) + .expect_err("custom 缺目录必须报错,不许猜默认值"); + assert!(matches!(error, GameError::CustomIsolationWithoutDir { .. })); + assert!(error.to_string().contains("id-missing"), "{error}"); +} + +// ------------------------------------------------------------ 覆盖链(优先级) + +#[test] +fn instance_isolation_beats_global_mode() { + let temp = TempDir::new("iso-priority"); + let game = layout(temp.path()); + let mut config = InstanceConfig::new("id-2", "1.20.1"); + config.isolation = Some(Isolation::Shared); + let derived = derive_game_dir( + &game, + "1.20.1", + IsolationMode::Isolated, + Some(&config), + None, + ) + .expect("派生"); + assert_eq!(derived.isolation, Isolation::Shared); + assert_eq!(derived.game_dir, game.root()); + assert_eq!(derived.source, IsolationSource::Instance); +} + +#[test] +fn inherit_falls_back_to_global_mode() { + let temp = TempDir::new("iso-inherit"); + let game = layout(temp.path()); + let config = InstanceConfig::new("id-3", "1.20.1"); + assert!(config.inherits_isolation(), "新实例默认 inherit"); + for (mode, expected) in [ + (IsolationMode::Isolated, game.version_dir("1.20.1")), + (IsolationMode::Shared, game.root().to_path_buf()), + ] { + let derived = derive_game_dir(&game, "1.20.1", mode, Some(&config), None).expect("派生"); + assert_eq!( + derived.game_dir, + expected, + "全局 {} 应决定 inherit", + mode.as_str() + ); + assert_eq!(derived.source, IsolationSource::Global); + } +} + +#[test] +fn temporary_override_wins_over_everything() { + let temp = TempDir::new("iso-temporary"); + let game = layout(temp.path()); + let mut config = InstanceConfig::new("id-4", "1.20.1"); + config.isolation = Some(Isolation::Isolated); + let forced = temp.path().join("forced"); + let derived = derive_game_dir( + &game, + "1.20.1", + IsolationMode::Shared, + Some(&config), + Some(&forced), + ) + .expect("派生"); + assert_eq!(derived.game_dir, forced); + assert_eq!(derived.source, IsolationSource::Temporary); + // 档位本身仍是实例档位(临时参数只改目录,不改语义)。 + assert_eq!(derived.isolation, Isolation::Isolated); + + // 空路径 = 没给临时参数。 + let derived = derive_game_dir( + &game, + "1.20.1", + IsolationMode::Shared, + Some(&config), + Some(Path::new("")), + ) + .expect("派生"); + assert_eq!(derived.source, IsolationSource::Instance); +} + +#[test] +fn shared_layers_are_fixed_and_unconfigurable() { + assert_eq!(SHARED_LAYERS, ["versions", "assets", "libraries"]); + // natives 不在恒共享层里:它按实例解包(两家启动器一致)。 + assert!(!SHARED_LAYERS.contains(&"natives")); + assert_eq!(PROBED_CONTENT_DIRS, ["mods", "saves", "config"]); +} + +// -------------------------------------------------------------- 切换检测(探测) + +#[test] +fn probe_counts_only_player_content() { + let temp = TempDir::new("iso-probe"); + seed(temp.path(), "mods"); + seed(temp.path(), "saves"); + seed(temp.path(), "screenshots"); + let side = probe_side(temp.path()).expect("探测"); + assert_eq!(side.entries, 2, "screenshots 不算玩家内容"); + assert!(side.occupied()); +} + +#[test] +fn probe_missing_dir_counts_as_zero() { + let temp = TempDir::new("iso-probe-empty"); + let side = probe_side(&temp.path().join("nope")).expect("目录不存在不算错误"); + assert_eq!(side.entries, 0); + assert!(!side.occupied()); +} + +#[test] +fn probe_both_sides_reads_isolated_and_shared() { + let temp = TempDir::new("iso-probe-both"); + let game = layout(temp.path()); + seed(&game.version_dir("1.20.1"), "saves"); + seed(game.root(), "mods"); + seed(game.root(), "config"); + let (isolated, shared) = probe_both_sides(&game, "1.20.1").expect("探测"); + assert_eq!(isolated.entries, 1); + assert_eq!(shared.entries, 2); + assert_eq!(isolated.path, game.version_dir("1.20.1")); + assert_eq!(shared.path, game.root()); +} diff --git a/crates/core/tests/game/isolation_switch.rs b/crates/core/tests/game/isolation_switch.rs new file mode 100644 index 0000000..80dba99 --- /dev/null +++ b/crates/core/tests/game/isolation_switch.rs @@ -0,0 +1,222 @@ +//! D20 切换检测与稀疏落盘:observe/warn 语义、`inherit` 与 `lastModeChange` 不落盘、 +//! 旧字面量读兼容(从 `isolation.rs` 拆出,保持单文件 ≤400 行)。 + +use std::path::PathBuf; + +use unlucky_core::game::install::instance::{InstanceConfig, Isolation}; +use unlucky_core::game::isolation::{ + derive_game_dir, observe_mode, IsolationMode, IsolationSource, +}; + +use super::helpers::{layout, seed, TempDir}; + +// ------------------------------------------------- 切换记录(幂等 + 只提示一次) + +#[test] +fn observe_mode_records_baseline_without_warning() { + let temp = TempDir::new("iso-observe-baseline"); + let game = layout(temp.path()); + seed(game.root(), "saves"); + let mut config = InstanceConfig::new("id-5", "1.20.1"); + let changed = observe_mode( + &mut config, + &game, + "2026-10-30T00:00:00Z", + Isolation::Isolated, + ) + .expect("观察"); + assert!(changed, "首次观察要建基线"); + let change = config.last_mode_change.clone().expect("基线"); + assert_eq!(change.from, change.to); + assert!(!change.is_real_change()); + assert!(change.warned, "基线不该弹提示"); + assert_eq!(change.probe.shared_side, 1); + assert!( + config.take_mode_change_warning().is_none(), + "基线即使两侧都有内容也不提示" + ); +} + +#[test] +fn observe_mode_is_idempotent() { + let temp = TempDir::new("iso-observe-idempotent"); + let game = layout(temp.path()); + let mut config = InstanceConfig::new("id-6", "1.20.1"); + assert!( + observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("观察"), + "首次写入" + ); + assert!( + !observe_mode(&mut config, &game, "t2", Isolation::Isolated).expect("观察"), + "同一档位不重复记" + ); + assert_eq!( + config.last_mode_change.expect("记录仍在").at, + "t1", + "时间戳不该被刷新" + ); +} + +#[test] +fn mode_change_with_both_sides_warns_exactly_once() { + let temp = TempDir::new("iso-warn-once"); + let game = layout(temp.path()); + seed(&game.version_dir("1.20.1"), "saves"); + seed(&game.version_dir("1.20.1"), "mods"); + seed(game.root(), "saves"); + let mut config = InstanceConfig::new("id-7", "1.20.1"); + config.isolation = Some(Isolation::Shared); + observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线"); + config.isolation = Some(Isolation::Isolated); + assert!( + observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("记切换"), + "档位变了要写记录" + ); + let change = config.last_mode_change.clone().expect("记录"); + assert_eq!(change.from, Isolation::Shared); + assert_eq!(change.to, Isolation::Isolated); + assert!(change.is_real_change()); + assert_eq!(change.probe.isolated_side, 2); + assert_eq!(change.probe.shared_side, 1); + + let warning = config + .take_mode_change_warning() + .expect("两侧都有内容要提示"); + assert!(warning.probe.both_occupied()); + let message = warning.message(); + assert!(message.contains("isolated"), "{message}"); + assert!( + message.contains("不会自动搬"), + "文案必须说清不迁移:{message}" + ); + assert!( + config.take_mode_change_warning().is_none(), + "同一次切换只提示一次" + ); + assert!(config.last_mode_change.expect("记录").warned); +} + +#[test] +fn mode_change_with_one_empty_side_is_quiet() { + let temp = TempDir::new("iso-warn-single-side"); + let game = layout(temp.path()); + // 只有共享侧有内容:隔离侧空 → 不提示(切到共享还能看见存档,反之亦然)。 + seed(game.root(), "saves"); + let mut config = InstanceConfig::new("id-8", "1.20.1"); + observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线"); + observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("记切换"); + assert!(config.take_mode_change_warning().is_none()); + + // 两侧都空同样安静。 + let empty = TempDir::new("iso-warn-both-empty"); + let game = layout(empty.path()); + let mut config = InstanceConfig::new("id-9", "1.20.1"); + observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线"); + observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("记切换"); + assert!(config.take_mode_change_warning().is_none()); +} + +// -------------------------------------------------------------- 稀疏落盘不变量 + +#[test] +fn inherit_and_no_change_stay_out_of_the_file() { + let config = InstanceConfig::new("id-10", "1.20.1"); + let json = config.to_json_string().expect("序列化"); + assert!(!json.contains("isolation"), "inherit 不许落盘:{json}"); + assert!(!json.contains("lastModeChange"), "没切过就不写:{json}"); + assert!(!json.contains("gameDir"), "没目录就不写:{json}"); + let back = InstanceConfig::from_json_str(&json).expect("回读"); + assert_eq!(back, config); +} + +#[test] +fn last_mode_change_round_trips_in_camel_case() { + let temp = TempDir::new("iso-roundtrip"); + let game = layout(temp.path()); + let mut config = InstanceConfig::new("id-11", "1.20.1"); + observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线"); + let json = config.to_json_string().expect("序列化"); + assert!(json.contains("\"lastModeChange\""), "{json}"); + assert!(json.contains("\"isolatedSide\""), "{json}"); + assert!(json.contains("\"sharedSide\""), "{json}"); + let back = InstanceConfig::from_json_str(&json).expect("回读"); + assert_eq!(back, config); +} + +#[test] +fn legacy_per_version_value_reads_as_isolated() { + let raw = r#"{ + "id": "id-12", + "versionId": "1.20.1", + "isolation": "per_version" + }"#; + let config = InstanceConfig::from_json_str(raw).expect("旧字面量读兼容"); + assert_eq!(config.isolation, Some(Isolation::Isolated)); + assert_eq!(Isolation::parse("per_version"), Some(Isolation::Isolated)); + assert_eq!(Isolation::parse("isolated"), Some(Isolation::Isolated)); + assert_eq!(Isolation::parse("nonsense"), None); +} + +#[test] +fn effective_isolation_resolves_inherit() { + let config = InstanceConfig::new("id-13", "1.20.1"); + assert_eq!( + config.effective_isolation(IsolationMode::Shared), + Isolation::Shared + ); + let mut config = InstanceConfig::new("id-14", "1.20.1"); + config.isolation = Some(Isolation::Custom); + assert_eq!( + config.effective_isolation(IsolationMode::Isolated), + Isolation::Custom + ); +} + +#[test] +fn custom_dir_from_relative_path_is_kept_verbatim() { + // custom 目录不做规范化(可能是相对路径或系统符号链接),派生层只透传。 + let temp = TempDir::new("iso-custom-relative"); + let game = layout(temp.path()); + let mut config = InstanceConfig::new("id-15", "1.20.1"); + config.isolation = Some(Isolation::Custom); + config.game_dir = Some(PathBuf::from("relative/mc")); + let derived = derive_game_dir( + &game, + "1.20.1", + IsolationMode::Isolated, + Some(&config), + None, + ) + .expect("派生"); + assert_eq!(derived.game_dir, PathBuf::from("relative/mc")); +} + +#[test] +fn explicit_inherit_reads_back_as_absent() { + // 回归 P2-2(review r1):schema 允许写 `"isolation": "inherit"`, + // 读路径必须把它折叠成「键不存在」,否则设置层写回默认值后启动会报 unknown variant。 + let raw = r#"{ + "id": "id-16", + "versionId": "1.20.1", + "isolation": "inherit" + }"#; + let config = InstanceConfig::from_json_str(raw).expect("显式 inherit 必须能读"); + assert!(config.inherits_isolation()); + assert_eq!(config.isolation, None); + let json = config.to_json_string().expect("序列化"); + assert!( + !json.contains("isolation"), + "写回时把 inherit 落成键缺失:{json}" + ); + // 走到派生层就是「跟随全局」,不再是错误路径。 + let temp = TempDir::new("iso-inherit-explicit"); + let game = layout(temp.path()); + let derived = + derive_game_dir(&game, "1.20.1", IsolationMode::Shared, Some(&config), None).expect("派生"); + assert_eq!(derived.isolation, Isolation::Shared); + assert_eq!(derived.source, IsolationSource::Global); + + // 其他非法字面量仍然报错(不静默取默认值)。 + let bad = r#"{ "id": "id-17", "versionId": "1.20.1", "isolation": "maybe" }"#; + assert!(InstanceConfig::from_json_str(bad).is_err()); +} diff --git a/crates/core/tests/game/java_discover.rs b/crates/core/tests/game/java_discover.rs new file mode 100644 index 0000000..cb25780 --- /dev/null +++ b/crates/core/tests/game/java_discover.rs @@ -0,0 +1,126 @@ +//! `core::game::java::discover`:标准安装位置扫描与来源优先级。 +//! +//! 真机上扫到什么取决于本机装了什么,所以断言只钉**不变量**: +//! 扫描结果形状、来源优先级(env 胜过扫盘)、去重保留高优先级、缺目录不报错。 + +use unlucky_core::game::java::discover; +use unlucky_core::game::java::mapping::{JavaRequirement, RequirementSource}; +use unlucky_core::game::java::{pick, JavaOrigin, JavaRuntime}; + +fn runtime(path: &str, major: u32, origin: JavaOrigin) -> JavaRuntime { + JavaRuntime { + binary: std::path::PathBuf::from(path), + origin, + version: Some(format!("openjdk version \"{major}.0.1\"")), + major: Some(major), + bits: 64, + vendor: None, + } +} + +fn at_least(minimum: u32) -> JavaRequirement { + JavaRequirement::new(minimum, None, RequirementSource::Table) +} + +#[test] +fn scanned_paths_always_point_at_a_java_binary() { + // 本机可能一个都没有(裸机/容器)——那也算通过,只是列表为空。 + for binary in discover::scan_java_binaries() { + assert!( + binary.is_file(), + "扫描结果必须是真实存在的文件:{}", + binary.display() + ); + assert_eq!( + binary + .file_name() + .map(|name| name.to_string_lossy().into_owned()), + Some(unlucky_core::game::java::java_binary_name().to_string()), + "扫描结果必须是 bin/java[.exe]:{}", + binary.display() + ); + } +} + +#[test] +fn standard_roots_are_absolute_and_unique() { + let roots = discover::standard_roots(); + for root in &roots { + assert!( + root.is_absolute(), + "候选根必须是绝对路径:{}", + root.display() + ); + } + let mut keys: Vec = roots.iter().map(|root| discover::path_key(root)).collect(); + let total = keys.len(); + keys.sort(); + keys.dedup(); + assert_eq!(keys.len(), total, "候选根不该重复"); +} + +#[test] +fn environment_java_beats_scanned_java_when_both_satisfy() { + // D13 的「优先级链不变」:环境变量是用户意图,扫盘只是候选。 + let candidates = [ + runtime( + "C:/Program Files/Eclipse Adoptium/jdk-21/bin/java.exe", + 21, + JavaOrigin::System, + ), + runtime( + "C:/Program Files/Microsoft/jdk-21/bin/java.exe", + 21, + JavaOrigin::JavaHome, + ), + ]; + let picked = pick(&candidates, &at_least(17)).expect("应有候选"); + assert_eq!(picked.origin, JavaOrigin::JavaHome); +} + +#[test] +fn scanned_java_is_still_used_when_nothing_else_matches() { + // MSI 装了 Temurin 21、没设环境变量、PATH 上是 Java 8:1.20.5 只认扫盘那个。 + let candidates = [ + runtime("java", 8, JavaOrigin::Path), + runtime( + "C:/Program Files/Java/jdk-21/bin/java.exe", + 21, + JavaOrigin::System, + ), + ]; + let picked = pick(&candidates, &at_least(21)).expect("应有候选"); + assert_eq!(picked.origin, JavaOrigin::System); +} + +#[test] +fn dedupe_collapses_one_install_found_twice() { + // `JAVA_HOME` 指向 Program Files 里的 JDK → 同一个 java 不应列两次。 + 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, + ), + ]); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].origin, JavaOrigin::JavaHome); +} + +#[test] +fn registry_absence_is_tolerated() { + // 非 Windows 必然为空;Windows 上没装老 Oracle JDK 也是空。 + let homes = discover::registry_homes(); + for home in &homes { + assert!( + home.is_absolute(), + "注册表里的 JavaHome 应是绝对路径:{}", + home.display() + ); + } +} diff --git a/crates/core/tests/game/java_install.rs b/crates/core/tests/game/java_install.rs new file mode 100644 index 0000000..5d69239 --- /dev/null +++ b/crates/core/tests/game/java_install.rs @@ -0,0 +1,291 @@ +//! `core::game::java` 的安装侧集成测试:归档解压(幂等 / 顶层剥离 / 越界拒绝)、 +//! 托管目录命名、下载请求装配、平台归档形态。 +//! +//! 镜像选源见 `java_mirrors.rs`,MC↔Java 映射见 `java_mapping.rs`。 + +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use flate2::write::GzEncoder; +use flate2::Compression; +use unlucky_core::download::ResourceKind; +use unlucky_core::game::java::{ + self, extract, install, probe, AdoptiumTarget, JdkArchive, JdkRelease, +}; + +/// 64 位十六进制(解压戳记只当字符串比对,不校验内容)。 +const SHA_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +/// 另一个「新版本」的摘要。 +const SHA_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +fn temp_dir(tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "ull-java-{tag}-{}-{nanos}-{seq}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建临时目录"); + dir +} +/// 造一个 Temurin 形状的 zip(顶层 `jdk-21.0.13+9/`)。 +fn write_zip(path: &Path, entries: &[(&str, &[u8])]) { + let file = File::create(path).expect("建 zip"); + let mut writer = zip::ZipWriter::new(file); + let options = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for (name, body) in entries { + writer.start_file(*name, options).expect("写 zip 条目"); + writer.write_all(body).expect("写 zip 内容"); + } + writer.finish().expect("收尾 zip"); +} + +/// 造一个 Temurin 形状的 tar.gz。 +fn write_tar_gz(path: &Path, entries: &[(&str, &[u8])]) { + let file = File::create(path).expect("建 tar.gz"); + let encoder = GzEncoder::new(file, Compression::fast()); + let mut builder = tar::Builder::new(encoder); + for (name, body) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(body.len() as u64); + header.set_mode(0o755); + header.set_entry_type(tar::EntryType::Regular); + builder + .append_data(&mut header, name, *body) + .expect("写 tar 条目"); + } + let encoder = builder.into_inner().expect("收尾 tar"); + encoder.finish().expect("收尾 gzip"); +} + +fn java_bin(root: &Path) -> PathBuf { + root.join("bin").join(java::java_binary_name()) +} + +#[test] +fn zip_archive_loses_the_temurin_top_level_directory() { + let dir = temp_dir("zip"); + let archive = dir.join("jdk.zip"); + let top = "jdk-21.0.13+9"; + write_zip( + &archive, + &[ + ( + &format!("{top}/bin/{}", java::java_binary_name()), + b"#!/bin/true\n", + ), + (&format!("{top}/release"), b"JAVA_VERSION=\"21.0.13\"\n"), + ], + ); + let dest = dir.join("jdk-21-temurin"); + let report = extract::extract_archive(&archive, SHA_A, 21, &dest).expect("解压应成功"); + + assert!(!report.skipped); + assert_eq!(report.files, 2); + assert!(java_bin(&dest).is_file(), "bin/java 应在顶层"); + assert!(!dest.join(top).exists(), "顶层目录必须被剥掉"); + let stamp = extract::read_stamp(&dest).expect("应写戳记"); + assert_eq!(stamp.major, 21); + assert_eq!(stamp.sha256, SHA_A); + assert_eq!(stamp.files, 2); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn second_extract_with_same_sha_touches_nothing() { + let dir = temp_dir("idem"); + let archive = dir.join("jdk.zip"); + write_zip( + &archive, + &[( + &format!("jdk-21.0.13+9/bin/{}", java::java_binary_name()), + b"bin\n", + )], + ); + let dest = dir.join("jdk-21-temurin"); + extract::extract_archive(&archive, SHA_A, 21, &dest).expect("首解"); + + // 用户手工动过的痕迹:幂等命中时不能被抹掉。 + let marker = dest.join("安装后手工放的.txt"); + std::fs::write(&marker, b"keep").expect("写标记"); + + let again = extract::extract_archive(&archive, SHA_A, 21, &dest).expect("重解"); + assert!(again.skipped, "同一 sha256 应整体跳过"); + assert_eq!(again.files, 0); + assert!(marker.is_file(), "幂等命中时不应重写目录"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn new_sha_replaces_the_managed_directory() { + let dir = temp_dir("upgrade"); + let archive = dir.join("jdk.zip"); + write_zip( + &archive, + &[( + &format!("jdk-21.0.13+9/bin/{}", java::java_binary_name()), + b"v1\n", + )], + ); + let dest = dir.join("jdk-21-temurin"); + extract::extract_archive(&archive, SHA_A, 21, &dest).expect("首解"); + let marker = dest.join("旧版残留"); + std::fs::write(&marker, b"old").expect("写标记"); + + let report = extract::extract_archive(&archive, SHA_B, 21, &dest).expect("新摘要重解"); + assert!(!report.skipped); + assert!(!marker.exists(), "官方发新版后旧目录应被替换"); + assert_eq!( + extract::read_stamp(&dest).map(|stamp| stamp.sha256), + Some(SHA_B.to_string()) + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn tar_gz_archive_is_supported_for_linux_and_mac() { + let dir = temp_dir("targz"); + let archive = dir.join("OpenJDK21U-jdk_x64_linux_hotspot_21.0.13_9.tar.gz"); + let top = "jdk-21.0.13+9"; + write_tar_gz( + &archive, + &[ + (&format!("{top}/bin/{}", java::java_binary_name()), b"bin\n"), + (&format!("{top}/lib/modules"), b"modules\n"), + ], + ); + let dest = dir.join("jdk-21-temurin"); + let report = extract::extract_archive(&archive, SHA_A, 21, &dest).expect("解压 tar.gz"); + assert_eq!(report.files, 2); + assert!(dest.join("bin").join(java::java_binary_name()).is_file()); + assert!(dest.join("lib").join("modules").is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn archive_without_a_single_top_directory_is_promoted_as_is() { + // 有的魔改发行版直接把 bin/ 放在根上:不能硬剥一层。 + let dir = temp_dir("flat"); + let archive = dir.join("flat.zip"); + write_zip( + &archive, + &[ + ( + &format!("bin/{}", java::java_binary_name()), + b"bin\n".as_slice(), + ), + ("release", b"x\n".as_slice()), + ], + ); + let dest = dir.join("jdk-17-temurin"); + extract::extract_archive(&archive, SHA_A, 17, &dest).expect("解压"); + assert!(dest.join("bin").join(java::java_binary_name()).is_file()); + assert!(dest.join("release").is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn foreign_directory_is_never_clobbered() { + let dir = temp_dir("foreign"); + let archive = dir.join("jdk.zip"); + write_zip( + &archive, + &[( + &format!("jdk-21.0.13+9/bin/{}", java::java_binary_name()), + b"bin\n", + )], + ); + let dest = dir.join("jdk-21-temurin"); + std::fs::create_dir_all(&dest).expect("建目标目录"); + std::fs::write(dest.join("用户的文件.txt"), b"mine").expect("写用户文件"); + + let error = extract::extract_archive(&archive, SHA_A, 21, &dest).expect_err("应拒绝覆盖"); + assert_eq!(error.kind(), java::JavaErrorKind::Io); + assert!(dest.join("用户的文件.txt").is_file(), "用户文件必须还在"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn zip_slip_entries_do_not_escape_the_target_directory() { + let dir = temp_dir("slip"); + let archive = dir.join("evil.zip"); + write_zip( + &archive, + &[ + ( + &format!("jdk-21.0.13+9/bin/{}", java::java_binary_name()), + b"bin\n", + ), + ("../../escaped.txt", b"nope"), + ], + ); + let dest = dir.join("jdk-21-temurin"); + let report = extract::extract_archive(&archive, SHA_A, 21, &dest).expect("越界条目只跳过"); + assert_eq!(report.files, 1, "越界条目不应被写盘"); + assert!(!dir.join("escaped.txt").exists()); + assert!(!dir.parent().unwrap_or(&dir).join("escaped.txt").exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn managed_layout_names_carry_major_and_vendor() { + let root = Path::new("/tmp/java-managed"); + let dest = install::managed_root_dir(root, 21, java::JDK_VENDOR); + assert_eq!(dest, root.join("jdk-21-temurin")); + assert_eq!( + probe::parse_managed_dir("jdk-21-temurin"), + Some((21, "temurin")) + ); + assert_eq!(probe::managed_dir_name(8, "temurin"), "jdk-8-temurin"); +} + +#[test] +fn archive_request_carries_sha256_and_the_java_runtime_kind() { + let release = JdkRelease { + vendor: java::JdkVendor::Temurin, + major: 21, + version: "21.0.13+9".to_string(), + semver: Some("21.0.13+9.0.LTS".to_string()), + release_name: "jdk-21.0.13+9".to_string(), + archive: JdkArchive { + name: "OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip".to_string(), + url: "https://github.com/adoptium/temurin21-binaries/releases/download/x.zip" + .to_string(), + sha256: SHA_A.to_string(), + size: 1024, + }, + }; + let dest = Path::new("/tmp/x.zip"); + let request = install::archive_request(&release, dest.to_path_buf()); + assert_eq!(request.kind, ResourceKind::JavaRuntime); + assert_eq!(request.expected_size, Some(1024)); + assert_eq!( + request + .integrity + .as_ref() + .map(|integrity| integrity.hex.as_str()), + Some(SHA_A) + ); + assert!(request.validate().is_ok()); + assert_eq!( + install::archive_path(Path::new("/tmp/java-managed"), &release), + Path::new("/tmp/java-managed/.cache/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip") + ); +} + +#[test] +fn host_target_picks_the_platform_archive_shape() { + let target = AdoptiumTarget::host().expect("宿主应受支持"); + assert_eq!(target.is_zip(), cfg!(windows)); + assert_eq!( + target.archive_ext(), + if cfg!(windows) { "zip" } else { "tar.gz" } + ); +} diff --git a/crates/core/tests/game/java_location.rs b/crates/core/tests/game/java_location.rs new file mode 100644 index 0000000..55eaef3 --- /dev/null +++ b/crates/core/tests/game/java_location.rs @@ -0,0 +1,136 @@ +//! `core::game::java::location`:落点三模式(Portable / Standard / Custom)+ 孤儿目录清理。 +//! +//! 三模式的口径来自 D23/D6:Portable 保持 v1 布局、Standard 走 OS 标准目录的 +//! `<发行方>/<版本>` 分层、Custom 由 `java.installRoot` 覆盖;清理只碰我们自己的形状。 + +use std::path::PathBuf; + +use unlucky_core::game::java::location::{ + cleanup_orphans, sanitize_version, standard_java_root, JavaInstallRoot, LocationMode, +}; +use unlucky_core::game::java::JdkVendor; +use unlucky_core::platform::layout::LauncherLayout; +use unlucky_core::platform::paths::{MapEnv, Platform}; + +fn temp_dir(tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "ull-java-loc-{tag}-{}-{nanos}-{seq}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建临时目录"); + dir +} + +#[test] +fn standard_root_follows_each_platform_convention() { + let env = MapEnv::new() + .with("APPDATA", "C:/Users/u/AppData/Roaming") + .with("HOME", "/home/u") + .with("USERPROFILE", "C:/Users/u"); + // 期望值一律用 `join` 拼(Windows 上的分隔符与 `Path` 比较细节交给标准库)。 + let unix_home = PathBuf::from("/home/u"); + assert_eq!( + standard_java_root(Platform::Windows, &env), + Some(PathBuf::from("C:/Users/u/AppData/Roaming").join("Java")), + "Windows 用户原话:%APPDATA%\\Java" + ); + assert_eq!( + standard_java_root(Platform::MacOs, &env), + Some(unix_home.join("Library/Application Support").join("Java")) + ); + assert_eq!( + standard_java_root(Platform::Linux, &env), + Some(unix_home.join(".local/share").join("java")), + "Linux 走 XDG 约定(无 XDG_DATA_HOME 时回落 ~/.local/share)" + ); + // `Path::is_absolute()` 的口径是编译目标决定的:Windows 上 `C:/data/u` 才是绝对路径, + // 而 `/data/u` 只是「rooted」。夹具按目标平台给,才能同时验到「绝对 → 采用」。 + let xdg_root = if cfg!(windows) { + "C:/data/u" + } else { + "/data/u" + }; + let xdg = MapEnv::new() + .with("HOME", "/home/u") + .with("XDG_DATA_HOME", xdg_root); + assert_eq!( + standard_java_root(Platform::Linux, &xdg), + Some(PathBuf::from(xdg_root).join("java")) + ); + // 相对路径的 XDG_DATA_HOME 按规范忽略 → 回落 HOME。 + let relative = MapEnv::new() + .with("HOME", "/home/u") + .with("XDG_DATA_HOME", "data/u"); + assert_eq!( + standard_java_root(Platform::Linux, &relative), + Some(unix_home.join(".local/share").join("java")) + ); + assert_eq!(standard_java_root(Platform::Linux, &MapEnv::new()), None); +} + +#[test] +fn portable_and_custom_modes_derive_the_documented_directories() { + let launcher = LauncherLayout::new("/data/.ULL"); + let portable = JavaInstallRoot::portable(&launcher); + assert_eq!(portable.mode, LocationMode::Portable); + assert_eq!( + portable.jdk_dir(21, JdkVendor::Temurin, "21.0.13+9"), + PathBuf::from("/data/.ULL/data/java-managed/jdk-21-temurin"), + "便携模式必须保持 v1 布局(M33/M35 兼容)" + ); + let custom = JavaInstallRoot::custom("/opt/java"); + assert_eq!(custom.mode, LocationMode::Custom); + assert_eq!( + custom.jdk_dir(21, JdkVendor::Oracle, "21.0.2"), + PathBuf::from("/opt/java/oracle/21.0.2"), + "Custom 用 /(D23)" + ); + assert_eq!(custom.cache_dir(), PathBuf::from("/opt/java/.cache")); + assert!(custom.describe().contains("custom")); +} + +#[test] +fn version_strings_are_sanitized_for_directory_names() { + assert_eq!(sanitize_version("21.0.13+9"), "21.0.13+9"); + assert_eq!(sanitize_version("21.0.13.0.2"), "21.0.13.0.2"); + assert_eq!(sanitize_version("21.0.13+3"), "21.0.13+3"); + assert_eq!(sanitize_version(" 21.0.2 "), "21.0.2"); + assert_eq!(sanitize_version("../evil"), ".._evil"); + assert_eq!(sanitize_version(""), "unknown"); +} + +#[test] +fn cleanup_removes_staging_and_empty_shells_but_never_foreign_dirs() { + let root = temp_dir("cleanup"); + // 半成品:解压中断的暂存目录。 + std::fs::create_dir_all(root.join(".staging-jdk-21-temurin")).expect("建暂存"); + std::fs::write(root.join(".staging-jdk-21-temurin/bin"), b"x").expect("写暂存文件"); + // 空壳 vendor 目录(Standard 布局的残留)。 + std::fs::create_dir_all(root.join("oracle")).expect("建 vendor"); + // 用户自己的目录(非空、无戳记):绝不能删。 + std::fs::create_dir_all(root.join("my-own-jdk")).expect("建用户目录"); + std::fs::write(root.join("my-own-jdk/notes.txt"), b"mine").expect("写用户文件"); + // 归档缓存目录:保留。 + std::fs::create_dir_all(root.join(".cache")).expect("建缓存"); + + let report = cleanup_orphans(&root); + assert!( + !root.join(".staging-jdk-21-temurin").exists(), + "暂存目录应删" + ); + assert!(!root.join("oracle").exists(), "空 vendor 目录应删"); + assert!( + root.join("my-own-jdk/notes.txt").is_file(), + "用户目录必须还在" + ); + assert!(root.join(".cache").is_dir(), "缓存目录不能删"); + assert_eq!(report.removed.len(), 2, "{:?}", report.removed); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/core/tests/game/java_mapping.rs b/crates/core/tests/game/java_mapping.rs new file mode 100644 index 0000000..8b98e66 --- /dev/null +++ b/crates/core/tests/game/java_mapping.rs @@ -0,0 +1,106 @@ +//! MC↔Java 映射:版本 JSON 的 `javaVersion` 优先(含继承链),上限取自静态表。 + +use std::path::PathBuf; + +use unlucky_core::game::java::mapping; + +fn temp_dir(tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "ull-java-{tag}-{}-{nanos}-{seq}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建临时目录"); + dir +} + +#[test] +fn version_json_java_version_beats_the_static_table() { + let root = temp_dir("mapping"); + let layout = unlucky_core::game::GameLayout::new(&root); + let write = |id: &str, json: serde_json::Value| { + let dir = root.join("versions").join(id); + std::fs::create_dir_all(&dir).expect("建版本目录"); + std::fs::write( + dir.join(format!("{id}.json")), + serde_json::to_vec(&json).expect("序列化"), + ) + .expect("写版本 JSON"); + }; + + // 1.17 的清单自己说是 16(静态表也是 16,但来源必须标 manifest)。 + write( + "1.17.1", + serde_json::json!({"id": "1.17.1", "javaVersion": {"component": "java-runtime-alpha", "majorVersion": 16}}), + ); + let manifest = mapping::requirement_for_version(&layout, "1.17.1"); + assert_eq!(manifest.minimum, 16); + assert_eq!(manifest.source.as_str(), "manifest"); + + // 魔改实例覆盖父版本:21 应当生效。 + write("1.20.1", serde_json::json!({"id": "1.20.1"})); + write( + "1.20.1-forge", + serde_json::json!({"id": "1.20.1-forge", "inheritsFrom": "1.20.1", "javaVersion": {"majorVersion": 21}}), + ); + assert_eq!( + mapping::requirement_for_version(&layout, "1.20.1-forge").minimum, + 21 + ); + + // 清单没写 javaVersion:父版本也没有 → 静态表兜底。 + let table = mapping::requirement_for_version(&layout, "1.20.1"); + assert_eq!(table.minimum, 17); + assert_eq!(table.source.as_str(), "table"); + + // 1.12.2:清单只说「最低 8」,但上限(LaunchWrapper 只能 Java 8)由静态表补上。 + write( + "1.12.2", + serde_json::json!({"id": "1.12.2", "javaVersion": {"component": "jre-legacy", "majorVersion": 8}}), + ); + let legacy = mapping::requirement_for_version(&layout, "1.12.2"); + assert_eq!((legacy.minimum, legacy.maximum), (8, Some(8))); + assert_eq!(legacy.source.as_str(), "manifest"); + assert!( + mapping::check(21, &legacy).is_some(), + "1.12.2 + Java 21 必须报不符" + ); + + // 版本根本没装:按 id 走静态表,不报错。 + let missing = mapping::requirement_for_version(&layout, "26.3"); + assert_eq!(missing.minimum, 25); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn parent_java_version_is_inherited() { + let root = temp_dir("inherit"); + let layout = unlucky_core::game::GameLayout::new(&root); + let write = |id: &str, json: serde_json::Value| { + let dir = root.join("versions").join(id); + std::fs::create_dir_all(&dir).expect("建版本目录"); + std::fs::write( + dir.join(format!("{id}.json")), + serde_json::to_vec(&json).expect("序列化"), + ) + .expect("写版本 JSON"); + }; + write( + "1.20.5", + serde_json::json!({"id": "1.20.5", "javaVersion": {"component": "java-runtime-delta", "majorVersion": 21}}), + ); + write( + "1.20.5-fabric", + serde_json::json!({"id": "1.20.5-fabric", "inheritsFrom": "1.20.5"}), + ); + let requirement = mapping::requirement_for_version(&layout, "1.20.5-fabric"); + assert_eq!(requirement.minimum, 21); + assert_eq!(requirement.source.as_str(), "manifest"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/core/tests/game/java_mirrors.rs b/crates/core/tests/game/java_mirrors.rs new file mode 100644 index 0000000..0fbc484 --- /dev/null +++ b/crates/core/tests/game/java_mirrors.rs @@ -0,0 +1,96 @@ +//! 镜像选源(D7 地区策略注入样本):国内镜像优先 / 国外官方优先,候选里都留兜底。 + +use unlucky_core::download::mirrors::MirrorTable; +use unlucky_core::download::region::RegionProfile; +use unlucky_core::download::ResourceKind; + +#[test] +fn domestic_region_puts_college_mirrors_first() { + let table = MirrorTable::builtin(); + let url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.13%2B9/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip"; + let domestic = table.expand( + url, + ResourceKind::JavaRuntime, + &RegionProfile::mirror_first(0), + ); + assert_eq!( + domestic.first().map(|candidate| candidate.source.as_str()), + Some("tuna") + ); + assert_eq!( + domestic.first().map(|candidate| candidate.url.as_str()), + Some("https://mirrors.tuna.tsinghua.edu.cn/Adoptium/21/jdk/x64/windows/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip") + ); + let sources: Vec<&str> = domestic + .iter() + .map(|candidate| candidate.source.as_str()) + .collect(); + // USTC/SJTU/BFSU 没有这套目录树(实测 404)、华为云是门户假阳性(M34 实测) + // → 清单里全停用,不该出现在候选里。 + for dead in ["ustc", "sjtu", "bfsu", "huaweicloud"] { + assert!(!sources.contains(&dead), "{dead} 应已停用:{sources:?}"); + } + assert_eq!(sources.last().copied(), Some("official"), "官方兜底在最后"); +} + +#[test] +fn foreign_region_keeps_official_first_but_still_has_mirrors() { + let table = MirrorTable::builtin(); + let url = "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.13%2B11/OpenJDK17U-jdk_aarch64_linux_hotspot_17.0.13_11.tar.gz"; + let abroad = table.expand(url, ResourceKind::JavaRuntime, &RegionProfile::direct(0)); + assert_eq!( + abroad.first().map(|candidate| candidate.source.as_str()), + Some("official") + ); + assert_eq!( + abroad.first().map(|candidate| candidate.url.as_str()), + Some(url) + ); + // 镜像仍然排在后面(国外也可能是镜像更快;健康度会让它上位)。 + assert!(abroad.len() > 1); + assert!(abroad + .iter() + .any(|candidate| candidate.url.contains("/Adoptium/17/jdk/aarch64/linux/"))); +} + +#[test] +fn vendor_cdn_and_mirror_urls_are_labelled_by_their_real_host() { + let table = MirrorTable::builtin(); + let direct = RegionProfile::direct(0); + // Oracle 走国内镜像时,直连 URL 就是华为云——不能标成 official(否则用户以为在啃官方 5.3KB/s)。 + let oracle = + "https://mirrors.huaweicloud.com/openjdk/21.0.2/openjdk-21.0.2_windows-x64_bin.zip"; + assert_eq!( + table + .expand(oracle, ResourceKind::JavaRuntime, &direct) + .first() + .map(|candidate| candidate.source.as_str()), + Some("huawei") + ); + // MS 的 aka.ms 与 Zulu 的 CDN 同理。 + let microsoft = "https://aka.ms/download-jdk/microsoft-jdk-21.0.13.0.2-windows-x64.zip"; + assert_eq!( + table + .expand(microsoft, ResourceKind::JavaRuntime, &direct) + .first() + .map(|candidate| candidate.source.as_str()), + Some("microsoft") + ); + let zulu = "https://cdn.azul.com/zulu/bin/zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip"; + assert_eq!( + table + .expand(zulu, ResourceKind::JavaRuntime, &direct) + .first() + .map(|candidate| candidate.source.as_str()), + Some("azul") + ); + // Mojang 系资源仍是 official(别把官方源误标成厂商)。 + let mojang = "https://piston-data.mojang.com/v1/objects/abc/client.jar"; + assert_eq!( + table + .expand(mojang, ResourceKind::ClientJar, &direct) + .first() + .map(|candidate| candidate.source.as_str()), + Some("official") + ); +} diff --git a/crates/core/tests/game/java_vendor_chain.rs b/crates/core/tests/game/java_vendor_chain.rs new file mode 100644 index 0000000..0647e22 --- /dev/null +++ b/crates/core/tests/game/java_vendor_chain.rs @@ -0,0 +1,183 @@ +//! D23 发行版回退链:地区默认序、用户优先级覆盖、能力过滤、mirrors v3 三张新表。 +//! +//! 全部离线:只跑纯函数与清单解析,不发一个请求。 +//! 真机验收(TUNA 装 17 / 华为云装 Oracle 21 / `%APPDATA%` 落点)见 mission 报告。 + +use unlucky_core::download::MirrorTable; +use unlucky_core::game::java::install::chain_order; +use unlucky_core::game::java::release::{parse_sha256_text, JdkArchive, JdkRelease}; +use unlucky_core::game::java::vendor::{ids, order, parse_priority, JdkVendor, VENDOR_IDS}; +use unlucky_core::game::java::InstallRequest; + +fn vendors(items: &[&str]) -> Vec { + parse_priority( + &items + .iter() + .map(|item| (*item).to_string()) + .collect::>(), + ) +} + +#[test] +fn domestic_default_is_temurin_oracle_microsoft_zulu() { + assert_eq!( + ids(&order(&[], true)), + vec!["temurin", "oracle", "microsoft", "zulu"], + "国内:Oracle 国内镜像 11.5MB/s vs MS 410KB/s(D23 实测修正)" + ); + assert_eq!( + ids(&order(&[], false)), + vec!["temurin", "microsoft", "oracle", "zulu"], + "国外:维持用户原话 Temurin→MS→Oracle" + ); +} + +#[test] +fn setting_priority_overrides_the_region_default_but_never_drops_a_vendor() { + let priority = vendors(&["microsoft"]); + assert_eq!( + ids(&order(&priority, true)), + vec!["microsoft", "temurin", "oracle", "zulu"] + ); + // 写全四家也等于顺序覆盖。 + let full = vendors(&["zulu", "microsoft", "oracle", "temurin"]); + assert_eq!( + ids(&order(&full, false)), + vec!["zulu", "microsoft", "oracle", "temurin"] + ); + // 未知值被忽略(老配置里可能残留 graalvm 之类)。 + let messy = vendors(&["graalvm", "oracle"]); + assert_eq!( + ids(&order(&messy, true)), + vec!["oracle", "temurin", "microsoft", "zulu"] + ); +} + +#[test] +fn capability_filter_drops_microsoft_and_oracle_for_java_8() { + // Java 8:MS 已停发、Oracle archive 从 9 起 → 链里只剩 Temurin 与 Zulu。 + assert_eq!( + ids(&chain_order(&InstallRequest::new(8), true)), + vec!["temurin", "zulu"] + ); + // Java 21:四家都发,顺序完整。 + assert_eq!( + ids(&chain_order(&InstallRequest::new(21), true)), + vec!["temurin", "oracle", "microsoft", "zulu"] + ); + // 钉死一家:链里只放它,即使它不支持这个大版本(由 install 报明确原因,不偷偷换家)。 + let pinned = InstallRequest::with_vendor(8, JdkVendor::Microsoft); + assert_eq!( + ids(&chain_order(&pinned, true)), + vec!["microsoft"], + "--vendor 不做能力过滤:用户点名的那家要显式失败" + ); +} + +#[test] +fn pinned_vendor_wins_over_the_user_priority() { + let request = + InstallRequest::with_vendor(17, JdkVendor::Oracle).with_priority(vendors(&["zulu"])); + assert_eq!(ids(&request.order(true)), vec!["oracle"]); + let chained = InstallRequest::new(17).with_priority(vendors(&["zulu"])); + assert_eq!( + ids(&chained.order(true)), + vec!["zulu", "temurin", "oracle", "microsoft"] + ); +} + +#[test] +fn mirror_table_v3_registers_all_four_source_families() { + let table = MirrorTable::builtin(); + assert_eq!(table.version, 3); + // adoptium 收敛:只有 TUNA 启用,其余六家登记为 disabled。 + assert_eq!( + table + .adoptium + .iter() + .filter(|mirror| mirror.enabled) + .map(|mirror| mirror.id.as_str()) + .collect::>(), + vec!["tuna"] + ); + assert_eq!(table.adoptium.len(), 7, "TUNA + USTC/SJTU/BFSU/NJU/ZJU/163"); + // 华为云的假阳性不再登记成 adoptium 源(它没有 Adoptium 镜像)。 + assert!(table + .adoptium + .iter() + .all(|mirror| mirror.id != "huaweicloud")); + // Oracle:华为云在前、南大在后(顺序即默认尝试顺序)。 + assert_eq!( + table + .oracle_mirrors() + .map(|mirror| mirror.id.as_str()) + .collect::>(), + vec!["huawei", "nju"] + ); + // MS:模板 + 无国内镜像 + 四个大版本。 + let microsoft = table.microsoft_openjdk().expect("MS 表"); + assert!(microsoft.domestic_mirrors.is_empty()); + assert_eq!(microsoft.majors, vec![25, 21, 17, 11]); + // Zulu:size 不可信,只信 sha256。 + assert!(!table.zulu().expect("Zulu 表").size_field_reliable); +} + +#[test] +fn every_vendor_release_shape_carries_its_own_version_semantics() { + // 四家的版本串并列:目录名、戳记、--json 都直接用 vendor 原生串。 + let samples = [ + (JdkVendor::Temurin, "21.0.13+9"), + (JdkVendor::Oracle, "21.0.2"), + (JdkVendor::Microsoft, "21.0.13.0.2"), + (JdkVendor::Zulu, "21.0.13+3"), + ]; + for (vendor, version) in samples { + let release = JdkRelease { + vendor, + major: 21, + version: version.to_string(), + semver: None, + release_name: version.to_string(), + archive: JdkArchive { + name: format!("{}-{version}.zip", vendor.id()), + url: format!("https://example.invalid/{}-{version}.zip", vendor.id()), + sha256: "a".repeat(64), + size: 0, + }, + }; + assert!(release.matches(vendor, 21)); + assert!(release.summary().contains(version), "{}", release.summary()); + assert_eq!(release.archive_json()["vendor"], vendor.id()); + assert_eq!( + unlucky_core::game::java::sanitize_version(&release.version), + version, + "版本串要能直接当目录名" + ); + } +} + +#[test] +fn sha256_texts_are_parsed_the_same_way_for_every_upstream() { + let hash = "b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"; + // 华为云 .sha256:纯哈希。 + assert_eq!(parse_sha256_text(hash).as_deref(), Some(hash)); + // MS .sha256sum.txt:` `(大写 + CRLF)。 + assert_eq!( + parse_sha256_text(&format!( + "{} microsoft-jdk-21.0.13-windows-x64.zip\r\n", + hash.to_uppercase() + )) + .as_deref(), + Some(hash) + ); + assert_eq!(parse_sha256_text("404 not found"), None); +} + +#[test] +fn vendor_ids_are_the_setting_whitelist() { + assert_eq!(VENDOR_IDS, &["temurin", "oracle", "microsoft", "zulu"]); + for id in VENDOR_IDS { + assert_eq!(JdkVendor::parse(id).map(|vendor| vendor.id()), Some(*id)); + } + assert!(JdkVendor::parse("graalvm").is_none()); +} diff --git a/crates/core/tests/game/layout.rs b/crates/core/tests/game/layout.rs new file mode 100644 index 0000000..bd034ab --- /dev/null +++ b/crates/core/tests/game/layout.rs @@ -0,0 +1,143 @@ +//! `.minecraft` 目录布局:路径规则、Maven 坐标、已安装版本枚举。 + +use std::path::PathBuf; + +use unlucky_core::game::error::GameError; +use unlucky_core::game::layout::GameLayout; +use unlucky_core::game::library::MavenCoordinate; + +use crate::helpers::TempDir; + +fn layout() -> (TempDir, GameLayout) { + let temp = TempDir::new("layout"); + let layout = GameLayout::new(temp.path().to_path_buf()); + (temp, layout) +} + +#[test] +fn version_paths_follow_mojang_layout() { + let (_temp, layout) = layout(); + let root = layout.root(); + + assert_eq!(layout.versions_dir(), root.join("versions")); + assert_eq!(layout.version_dir("1.20.1"), root.join("versions/1.20.1")); + assert_eq!( + layout.version_json("1.20.1"), + root.join("versions/1.20.1/1.20.1.json") + ); + assert_eq!( + layout.version_jar("1.20.1"), + root.join("versions/1.20.1/1.20.1.jar") + ); + // ULL 扩展:实例私有状态与 natives 解压目录。 + assert_eq!( + layout.version_state_dir("1.20.1"), + root.join("versions/1.20.1/.ull") + ); + assert_eq!( + layout.natives_dir("1.20.1"), + root.join("versions/1.20.1/natives") + ); + assert_eq!( + layout.version_libraries_dir("1.20.1"), + root.join("versions/1.20.1/libraries") + ); + assert_eq!(layout.ull_root(), root.join(".ull")); +} + +#[test] +fn asset_paths_use_two_level_sharding() { + let (_temp, layout) = layout(); + let root = layout.root(); + + assert_eq!(layout.asset_index("5"), root.join("assets/indexes/5.json")); + let hash = "a3b3b1b0e0d8f2a8f0fbe1a4b0a8f6a1c6c6a7a2"; + assert_eq!( + layout.asset_object(hash).expect("哈希合法"), + root.join("assets/objects/a3").join(hash) + ); + assert_eq!( + layout.log_config("client-1.12.xml"), + root.join("assets/log_configs/client-1.12.xml") + ); + + let error = layout.asset_object("a").expect_err("短哈希应报错"); + assert!(matches!(error, GameError::InvalidAssetHash { .. })); +} + +#[test] +fn maven_coordinates_parse_and_lay_out() { + let coordinate = MavenCoordinate::parse("org.lwjgl:lwjgl:3.3.2").expect("坐标应可解析"); + assert_eq!(coordinate.group, "org.lwjgl"); + assert_eq!(coordinate.group_path(), "org/lwjgl"); + assert_eq!(coordinate.file_name(), "lwjgl-3.3.2.jar"); + assert_eq!( + coordinate.relative_path(), + PathBuf::from("org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2.jar") + ); + + let natives = + MavenCoordinate::parse("org.lwjgl:lwjgl:3.3.2:natives-windows-64").expect("带分类器"); + assert_eq!( + natives.relative_path(), + PathBuf::from("org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-64.jar") + ); + + let non_jar = MavenCoordinate::parse("net.java:jinput:1.0.0@zip").expect("带扩展名"); + assert_eq!(non_jar.file_name(), "jinput-1.0.0.zip"); +} + +#[test] +fn invalid_coordinates_are_rejected() { + for raw in ["a:b", "a::1", "a:b:c:d:e"] { + let error = MavenCoordinate::parse(raw).expect_err("不合法坐标应报错"); + assert!( + matches!(error, GameError::InvalidCoordinate { .. }), + "{raw}" + ); + } +} + +#[test] +fn library_paths_land_under_libraries() { + let (_temp, layout) = layout(); + let root = layout.root(); + let coordinate = MavenCoordinate::parse("com.mojang:logging:1.1.1").expect("坐标应可解析"); + + assert_eq!( + layout.library(&coordinate), + root.join("libraries/com/mojang/logging/1.1.1/logging-1.1.1.jar") + ); + assert_eq!( + layout.version_library("1.20.1", &coordinate), + root.join("versions/1.20.1/libraries/com/mojang/logging/1.1.1/logging-1.1.1.jar") + ); +} + +#[test] +fn installed_versions_requires_manifest_file() { + let (_temp, layout) = layout(); + + assert!( + layout + .installed_versions() + .expect("缺目录视为空") + .is_empty(), + "目录不存在时不能报错" + ); + + let version_dir = layout.version_dir("1.20.1"); + std::fs::create_dir_all(&version_dir).expect("建目录"); + std::fs::write(layout.version_json("1.20.1"), "{}").expect("写清单"); + // 只有 jar 没有 json 的目录不算已安装。 + let broken = layout.version_dir("1.19.4"); + std::fs::create_dir_all(&broken).expect("建目录"); + std::fs::write(layout.version_jar("1.19.4"), b"jar").expect("写 jar"); + + assert!(layout.is_version_installed("1.20.1")); + assert!(!layout.is_version_installed("1.19.4")); + assert_eq!( + layout.installed_versions().expect("应可枚举"), + vec!["1.20.1".to_owned()] + ); +} diff --git a/crates/core/tests/game/main.rs b/crates/core/tests/game/main.rs new file mode 100644 index 0000000..25cc1de --- /dev/null +++ b/crates/core/tests/game/main.rs @@ -0,0 +1,30 @@ +//! `core::game` 的集成测试入口(阶段 A:先写好,mod.rs 接线在阶段 B 完成)。 +//! +//! 每个子模块对应 mission 清单里的一项: +//! - `manifest_parse`:版本 JSON 解析与保真 +//! - `inherit_chain`:inheritsFrom 解析 / 环检测 / 缺失父 / merge 语义 / patch 优先级 +//! - `arguments_rules`:规则求值、参数展开、natives 选择 +//! - `version_list`:清单 v2 解析、分类、排序、unlisted 合并 +//! - `layout`:`.minecraft` 目录布局 +//! - `snapshot`:不可变快照 + 单写者草稿 +//! - `profiles`:实例清单与稳定 UUID +//! - `vulkan_capability`:版本库表 → `has_vulkan_backend` 能力位 + +mod arguments_rules; +mod helpers; +mod inherit_chain; +mod isolation; +mod isolation_switch; +mod java_discover; +mod java_install; +mod java_location; +mod java_mapping; +mod java_mirrors; +mod java_vendor_chain; +mod layout; +mod manifest_parse; +mod profiles; +mod snapshot; +mod unlisted; +mod version_list; +mod vulkan_capability; diff --git a/crates/core/tests/game/manifest_parse.rs b/crates/core/tests/game/manifest_parse.rs new file mode 100644 index 0000000..83610fa --- /dev/null +++ b/crates/core/tests/game/manifest_parse.rs @@ -0,0 +1,186 @@ +//! 版本 JSON 解析:宽松解析、未知字段保真、旧版字段并存。 + +use unlucky_core::game::arguments::{Argument, ArgumentValue, RuleAction}; +use unlucky_core::game::manifest::VersionJson; +use unlucky_core::game::version::ReleaseType; + +use crate::helpers::parse; + +const VANILLA_1_20_1: &str = r#"{ + "id": "1.20.1", + "type": "release", + "time": "2023-06-12T13:25:51+00:00", + "releaseTime": "2023-06-12T13:25:51+00:00", + "complianceLevel": 1, + "minimumLauncherVersion": 21, + "mainClass": "net.minecraft.client.main.Main", + "assets": "5", + "assetIndex": { + "id": "5", + "sha1": "a3b3b1b0e0d8f2a8f0fbe1a4b0a8f6a1c6c6a7a2", + "size": 404297, + "totalSize": 598385820, + "url": "https://piston-meta.mojang.com/v1/packages/sha/5.json" + }, + "javaVersion": { "component": "java-runtime-gamma", "majorVersion": 17 }, + "downloads": { + "client": { "sha1": "abc", "size": 100, "url": "https://example.invalid/client.jar" }, + "client_mappings": { "sha1": "def", "size": 50, "url": "https://example.invalid/mappings.txt" } + }, + "arguments": { + "game": [ + "--username", + "${auth_player_name}", + { "rules": [{ "action": "allow", "features": { "has_custom_resolution": true } }], + "value": ["--width", "${resolution_width}"] } + ], + "jvm": [ + { "rules": [{ "action": "allow", "os": { "name": "windows", "version": "^10\\." } }], + "value": "-Dos.name=Windows 10" } + ] + }, + "libraries": [ + { "name": "com.mojang:logging:1.1.1", + "downloads": { "artifact": { "path": "com/mojang/logging/1.1.1/logging-1.1.1.jar", + "sha1": "aaa", "size": 10, + "url": "https://example.invalid/logging.jar" } } } + ], + "logging": { + "client": { "argument": "-Dlog4j.configurationFile=${path}", + "file": { "id": "client-1.12.xml", "sha1": "bbb", "size": 20, + "url": "https://example.invalid/log4j.xml" }, + "type": "log4j2-xml" } + }, + "tlauncherVersion": 1, + "MMC-hint": "local" +}"#; + +#[test] +fn parses_full_version_json() { + let version = parse(VANILLA_1_20_1); + + assert_eq!(version.id.as_deref(), Some("1.20.1")); + assert_eq!(version.version_type, Some(ReleaseType::Release)); + assert_eq!( + version.main_class.as_deref(), + Some("net.minecraft.client.main.Main") + ); + assert_eq!(version.compliance_level, Some(1)); + assert_eq!(version.minimum_launcher_version, Some(21)); + assert_eq!(version.assets_id(), "5"); + + let asset_index = version.asset_index.as_ref().expect("assetIndex 应解析"); + assert_eq!(asset_index.id, "5"); + assert_eq!(asset_index.total_size, Some(598_385_820)); + + let java = version.java_version.as_ref().expect("javaVersion 应解析"); + assert_eq!(java.component.as_deref(), Some("java-runtime-gamma")); + assert_eq!(java.major_version, Some(17)); + + let downloads = version.downloads.as_ref().expect("downloads 应解析"); + assert_eq!(downloads.len(), 2, "未知键也要保留在映射里"); + assert_eq!( + downloads.client().and_then(|c| c.url.as_deref()), + Some("https://example.invalid/client.jar") + ); + assert!(downloads.get("client_mappings").is_some()); +} + +#[test] +fn keeps_unknown_fields_verbatim() { + let version = parse(VANILLA_1_20_1); + // 第三方启动器私有字段(rawJson 保真,analyse/03 §4.2)。 + assert_eq!( + version.extra.get("tlauncherVersion"), + Some(&serde_json::json!(1)) + ); + assert_eq!( + version.extra.get("MMC-hint"), + Some(&serde_json::json!("local")) + ); + + let roundtrip = version.to_json_string().expect("应可序列化"); + let reparsed = VersionJson::from_json_str(&roundtrip).expect("应可再解析"); + assert_eq!(reparsed.extra, version.extra); +} + +#[test] +fn parses_arguments_with_rules() { + let version = parse(VANILLA_1_20_1); + let arguments = version.arguments.as_ref().expect("arguments 应解析"); + + assert_eq!(arguments.game.len(), 3); + assert_eq!(arguments.jvm.len(), 1); + assert!(matches!(&arguments.game[0], Argument::Plain(value) if value == "--username")); + + let conditional = match &arguments.game[2] { + Argument::Conditional(cond) => cond, + Argument::Plain(_) => panic!("第三项应是条件参数"), + }; + assert_eq!(conditional.rules.len(), 1); + assert_eq!(conditional.rules[0].action, RuleAction::Allow); + assert_eq!( + conditional.rules[0] + .features + .as_ref() + .and_then(|f| f.get("has_custom_resolution")), + Some(&true) + ); + assert_eq!( + conditional.value, + ArgumentValue::Many(vec!["--width".into(), "${resolution_width}".into()]) + ); + + // jvm 里的 os 限制带正则版本模式。 + let jvm = match &arguments.jvm[0] { + Argument::Conditional(cond) => cond, + Argument::Plain(_) => panic!("jvm 第一项应是条件参数"), + }; + let os = jvm.rules[0].os.as_ref().expect("应有 os 限制"); + assert_eq!(os.name.as_deref(), Some("windows")); + assert_eq!(os.version.as_deref(), Some("^10\\.")); +} + +#[test] +fn parses_libraries_and_logging() { + let version = parse(VANILLA_1_20_1); + let libraries = version.libraries.as_ref().expect("libraries 应解析"); + assert_eq!(libraries.len(), 1); + let artifact = libraries[0].artifact().expect("应有 artifact"); + assert_eq!( + artifact.path.as_deref(), + Some("com/mojang/logging/1.1.1/logging-1.1.1.jar") + ); + + let logging = version.logging.as_ref().expect("logging 应解析"); + let client = logging.client().expect("应有 client 日志配置"); + assert_eq!(client.kind.as_deref(), Some("log4j2-xml")); + assert_eq!(client.file.id.as_deref(), Some("client-1.12.xml")); +} + +#[test] +fn tolerant_defaults_for_minimal_json() { + let version = parse(r#"{ "id": "b1.7.3" }"#); + assert_eq!(version.id.as_deref(), Some("b1.7.3")); + assert_eq!(version.version_type, None, "未知 type 不报错"); + assert_eq!(version.client_jar(), None, "老 JSON 没有 downloads.client"); + assert_eq!(version.assets_id(), "legacy", "assets 缺失按 legacy"); + assert!(!version.is_resolved()); +} + +#[test] +fn unknown_type_value_falls_back_to_unknown() { + let version = parse(r#"{ "id": "x", "type": "some_new_kind" }"#); + assert_eq!(version.version_type, Some(ReleaseType::Unknown)); +} + +#[test] +fn missing_id_is_reported() { + let version = parse(r#"{ "mainClass": "a.B" }"#); + let error = version.require_id().expect_err("缺 id 应报错"); + assert_eq!( + error.to_string(), + "版本 JSON 缺少 id", + "错误文案要给用户看得懂" + ); +} diff --git a/crates/core/tests/game/profiles.rs b/crates/core/tests/game/profiles.rs new file mode 100644 index 0000000..76e0fa0 --- /dev/null +++ b/crates/core/tests/game/profiles.rs @@ -0,0 +1,109 @@ +//! 实例清单:稳定 UUID、增删查、落盘格式兼容。 + +use unlucky_core::game::profiles::{ + InstanceProfile, ProfileId, ProfileList, PROFILE_SCHEMA_VERSION, +}; + +#[test] +fn generated_ids_are_unique_and_uuid_shaped() { + let ids: Vec = (0..256).map(|_| ProfileId::generate()).collect(); + for id in &ids { + assert!(id.is_uuid_shaped(), "id 形状不对: {id}"); + } + let mut unique: Vec<&str> = ids.iter().map(ProfileId::as_str).collect(); + let total = unique.len(); + unique.sort_unstable(); + unique.dedup(); + assert_eq!(unique.len(), total, "同一批生成的 id 不能重复"); +} + +#[test] +fn id_shape_validation_rejects_junk() { + assert!(!ProfileId::new("not-a-uuid").is_uuid_shaped()); + assert!(!ProfileId::new("").is_uuid_shaped()); + assert!(ProfileId::new("7c9e6679-7425-40de-944b-e07fc1f90ae7").is_uuid_shaped()); +} + +#[test] +fn upsert_and_remove_keep_one_entry_per_id() { + let mut list = ProfileList::default(); + assert_eq!(list.schema_version, PROFILE_SCHEMA_VERSION); + assert!(list.is_empty()); + + let mut profile = InstanceProfile::new("主世界", "1.20.1"); + assert_eq!(list.upsert(profile.clone()), None, "首次插入没有旧值"); + assert_eq!(list.len(), 1); + + profile.name = "主世界(Fabric)".to_owned(); + let replaced = list.upsert(profile.clone()).expect("同 id 应替换"); + assert_eq!(replaced.name, "主世界"); + assert_eq!(list.len(), 1); + assert_eq!( + list.get(&profile.id).map(|p| p.name.as_str()), + Some("主世界(Fabric)") + ); + + let removed = list.remove(&profile.id).expect("应可删除"); + assert_eq!(removed.id, profile.id); + assert!(list.is_empty()); + assert!(list.remove(&profile.id).is_none()); +} + +#[test] +fn profiles_group_by_version() { + let mut list = ProfileList::default(); + list.upsert(InstanceProfile::new("a", "1.20.1")); + list.upsert(InstanceProfile::new("b", "1.20.1")); + list.upsert(InstanceProfile::new("c", "1.19.4")); + + assert_eq!(list.for_version("1.20.1").len(), 2); + assert_eq!(list.for_version("1.19.4").len(), 1); + assert!(list.for_version("1.18.2").is_empty()); +} + +#[test] +fn roundtrips_profiles_json() { + let mut list = ProfileList::default(); + let mut profile = InstanceProfile::new("整合包", "1.20.1-forge-47.2.0"); + profile.running_dir = Some(std::path::PathBuf::from("D:/mc/runs/pack")); + profile + .extra + .insert("thirdPartyFlag".to_owned(), serde_json::json!(true)); + list.upsert(profile.clone()); + + let json = list.to_json_string().expect("应可序列化"); + let reparsed = ProfileList::from_json_str(&json).expect("应可再解析"); + assert_eq!(reparsed, list); + assert_eq!(reparsed.schema_version, PROFILE_SCHEMA_VERSION); +} + +#[test] +fn reads_minimal_profile_file() { + // 用户手改/旧版本写下的最小档案:只有三个必填语义字段,其它缺省。 + let list = ProfileList::from_json_str( + r#"{ "profiles": [ { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "name": "旧档案", "version_id": "1.12.2" } ] }"#, + ) + .expect("最小档案应可解析"); + + assert_eq!( + list.schema_version, PROFILE_SCHEMA_VERSION, + "schemaVersion 缺省补齐" + ); + assert_eq!(list.len(), 1); + let profile = &list.profiles[0]; + assert_eq!(profile.name, "旧档案"); + assert!(profile.running_dir.is_none()); + assert!(profile.id.is_uuid_shaped()); +} + +#[test] +fn invalid_profiles_json_reports_context() { + let error = ProfileList::from_json_str("{").expect_err("非法 JSON 应报错"); + match error { + unlucky_core::game::error::GameError::Json { context, .. } => { + assert_eq!(context, "profiles") + } + other => panic!("期望 Json 错误,得到 {other:?}"), + } +} diff --git a/crates/core/tests/game/snapshot.rs b/crates/core/tests/game/snapshot.rs new file mode 100644 index 0000000..b8876cc --- /dev/null +++ b/crates/core/tests/game/snapshot.rs @@ -0,0 +1,75 @@ +//! 快照仓库:不可变读、单写者草稿、发布即指针交换。 + +use std::sync::Arc; + +use unlucky_core::game::error::GameError; +use unlucky_core::game::snapshot::{Snapshot, Store}; + +#[test] +fn snapshot_survives_draft_publish() { + let store = Store::new(vec!["1.20.1".to_owned()]); + let before = store.snapshot(); + + let mut draft = store.open_draft().expect("首个草稿应可打开"); + draft.edit().push("1.20.2".to_owned()); + let published = draft.commit(); + + // 旧快照不变(读端遍历时不会看到半新半旧的状态)。 + assert_eq!(before.get().len(), 1); + assert_eq!(published.get().len(), 2); + assert_eq!(store.snapshot().get().len(), 2); +} + +#[test] +fn only_one_draft_at_a_time() { + let store = Store::new(0u32); + let draft = store.open_draft().expect("首个草稿应可打开"); + + match store.open_draft() { + Err(GameError::DraftBusy) => {} + other => panic!("第二个草稿必须被拒绝,得到 {other:?}"), + } + + drop(draft); + let reopened = store.open_draft(); + assert!(reopened.is_ok(), "放弃草稿后写权必须释放"); +} + +#[test] +fn dropping_draft_discards_changes() { + let store = Store::new(vec![1u32]); + { + let mut draft = store.open_draft().expect("草稿应可打开"); + draft.edit().push(2); + } + assert_eq!(store.snapshot().get(), &vec![1u32]); +} + +#[test] +fn replace_publishes_whole_table() { + let store = Store::new(vec!["old".to_owned()]); + let snapshot = store.replace(vec!["new".to_owned()]).expect("替换应成功"); + assert_eq!(snapshot.get(), &vec!["new".to_owned()]); + assert_eq!(store.snapshot().get(), &vec!["new".to_owned()]); +} + +#[test] +fn clones_share_one_allocation() { + let store = Store::new(vec![1u32]); + let first = store.snapshot(); + let second = first.clone(); + + assert!(Arc::ptr_eq(&first.shared(), &second.shared())); + assert_eq!(first, second); + + let published = store.replace(vec![2u32]).expect("替换应成功"); + assert!(!Arc::ptr_eq(&first.shared(), &published.shared())); + assert_eq!(first.get(), &vec![1u32], "旧快照保持旧内容"); +} + +#[test] +fn snapshot_new_wraps_value() { + let snapshot = Snapshot::new("payload".to_owned()); + assert_eq!(snapshot.get(), "payload"); + assert_eq!(snapshot.clone().get(), "payload"); +} diff --git a/crates/core/tests/game/unlisted.rs b/crates/core/tests/game/unlisted.rs new file mode 100644 index 0000000..e891d29 --- /dev/null +++ b/crates/core/tests/game/unlisted.rs @@ -0,0 +1,90 @@ +//! 内嵌补充清单 `unlisted-versions.json`:条目数、分类分布、合并规则。 +//! +//! 这些断言同时是**数据完整性守门**:内嵌数据换版本时如果条目数/分类崩了, +//! 这里先红,而不是等用户在旧版列表里少看到几十个版本。 + +use unlucky_core::game::unlisted::{self, EMBEDDED_VERSION_COUNT}; +use unlucky_core::game::version::{ReleaseType, RemoteVersion, VersionCategory, VersionList}; + +#[test] +fn embedded_list_has_expected_shape() { + let embedded = unlisted::versions(); + assert_eq!(embedded.len(), EMBEDDED_VERSION_COUNT); + assert_eq!( + embedded.versions.len(), + unlisted::entries().len(), + "借用视图与清单一致" + ); + assert!(embedded.latest.is_none(), "补充清单没有 latest 对象"); + + for entry in unlisted::entries() { + assert!(!entry.id.is_empty()); + assert!(!entry.url.is_empty(), "每条都要有下载地址: {}", entry.id); + assert_ne!( + entry.version_type, + ReleaseType::Unknown, + "内嵌数据的 type 必须能归类: {}", + entry.id + ); + } +} + +#[test] +fn embedded_categories_match_analysis() { + // analyse/03 §2.3 实测口径:old_alpha 88 / old_beta 16 / snapshot 70 / + // pending 20 / unobfuscated 11。 + let counts = |category: VersionCategory| { + unlisted::entries() + .iter() + .filter(|v| v.version_type.category() == category) + .count() + }; + assert_eq!(counts(VersionCategory::Old), 88 + 16); + assert_eq!(counts(VersionCategory::Snapshot), 70); + assert_eq!(counts(VersionCategory::Pending), 20); + assert_eq!(counts(VersionCategory::Unobfuscated), 11); + assert_eq!(counts(VersionCategory::Release), 0); +} + +#[test] +fn embedded_ids_are_unique() { + let mut ids: Vec<&str> = unlisted::entries().iter().map(|v| v.id.as_str()).collect(); + let total = ids.len(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), total, "同 id 重复会让合并结果不确定"); +} + +#[test] +fn merge_adds_missing_entries_only() { + let mut official = VersionList { + latest: None, + versions: vec![RemoteVersion { + id: "a1.1.1".to_owned(), + version_type: ReleaseType::OldAlpha, + url: "https://example.invalid/official-a1.1.1.json".to_owned(), + time: None, + release_time: None, + sha1: Some("official".to_owned()), + size: Some(1), + }], + }; + + let added = unlisted::merge_into(&mut official); + assert_eq!(added, EMBEDDED_VERSION_COUNT - 1, "只补官方缺的条目"); + assert_eq!(official.len(), EMBEDDED_VERSION_COUNT); + + // 同 id 冲突时官方条目胜出(我们与 HMCL 的差异,见 unlisted 模块注释)。 + assert_eq!( + official.find("a1.1.1").map(|v| v.url.as_str()), + Some("https://example.invalid/official-a1.1.1.json") + ); +} + +#[test] +fn merge_into_empty_list_is_identity() { + let mut empty = VersionList::default(); + assert!(empty.is_empty()); + assert_eq!(unlisted::merge_into(&mut empty), EMBEDDED_VERSION_COUNT); + assert_eq!(empty, *unlisted::versions()); +} diff --git a/crates/core/tests/game/version_list.rs b/crates/core/tests/game/version_list.rs new file mode 100644 index 0000000..15407b3 --- /dev/null +++ b/crates/core/tests/game/version_list.rs @@ -0,0 +1,137 @@ +//! 版本清单(`version_manifest_v2.json`):解析、分类、排序、与补充清单合并。 + +use unlucky_core::game::error::GameError; +use unlucky_core::game::version::{ReleaseType, RemoteVersion, VersionCategory, VersionList}; + +const MANIFEST_V2: &str = r#"{ + "latest": { "release": "1.20.1", "snapshot": "23w31a" }, + "versions": [ + { "id": "1.20.1", "type": "release", "url": "https://example.invalid/1.20.1.json", + "time": "2023-06-12T13:25:51+00:00", "releaseTime": "2023-06-12T13:25:51+00:00", + "sha1": "aaa", "size": 1000 }, + { "id": "23w31a", "type": "snapshot", "url": "https://example.invalid/23w31a.json", + "time": "2023-08-02T12:00:00+00:00", "releaseTime": "2023-08-02T12:00:00+00:00", + "sha1": "bbb", "size": 900 }, + { "id": "b1.7.3", "type": "old_beta", "url": "https://example.invalid/b1.7.3.json", + "time": "2011-07-08T00:00:00+00:00", "releaseTime": "2011-07-08T00:00:00+00:00" } + ] +}"#; + +fn manifest() -> VersionList { + VersionList::from_json_str(MANIFEST_V2).expect("清单应可解析") +} + +#[test] +fn parses_manifest_v2_with_checksums() { + let list = manifest(); + assert_eq!(list.len(), 3); + + let latest = list.latest.as_ref().expect("latest 应解析"); + assert_eq!(latest.release.as_deref(), Some("1.20.1")); + assert_eq!(latest.snapshot.as_deref(), Some("23w31a")); + + let release = list.find("1.20.1").expect("1.20.1 应存在"); + assert!(release.is_verifiable()); + assert_eq!(release.size, Some(1000)); + // 内嵌旧清单没有 sha1/size(v1 形态)。 + assert!(!list.find("b1.7.3").expect("b1.7.3 应存在").is_verifiable()); +} + +#[test] +fn classifies_release_types() { + assert_eq!( + ReleaseType::from_json_str("release").category(), + VersionCategory::Release + ); + assert_eq!( + ReleaseType::from_json_str("old_beta").category(), + VersionCategory::Old + ); + assert_eq!( + ReleaseType::from_json_str("old_alpha").category(), + VersionCategory::Old + ); + assert_eq!( + ReleaseType::from_json_str("pending").category(), + VersionCategory::Pending + ); + assert_eq!( + ReleaseType::from_json_str("unobfuscated").category(), + VersionCategory::Unobfuscated + ); + assert_eq!( + ReleaseType::from_json_str("未见过").category(), + VersionCategory::Uncategorized + ); + // 序列化回原始字符串,保证能原样写回清单。 + assert_eq!(ReleaseType::OldBeta.as_json_str(), "old_beta"); + assert_eq!(ReleaseType::Unknown.as_json_str(), "unknown"); +} + +#[test] +fn sorts_newest_first() { + let mut list = manifest(); + list.sort_newest_first(); + let ids: Vec<&str> = list.versions.iter().map(|v| v.id.as_str()).collect(); + assert_eq!(ids, vec!["23w31a", "1.20.1", "b1.7.3"]); +} + +#[test] +fn filters_by_ui_category() { + let list = manifest(); + assert_eq!(list.filter_category(VersionCategory::Release).len(), 1); + assert_eq!(list.filter_category(VersionCategory::Snapshot).len(), 1); + assert_eq!(list.filter_category(VersionCategory::Old).len(), 1); + assert!(list.filter_category(VersionCategory::Pending).is_empty()); +} + +#[test] +fn merging_keeps_existing_entries() { + let mut list = manifest(); + let added = list.merge_missing(vec![ + // 同 id 冲突:已有条目(官方)胜出,URL 不被覆盖。 + RemoteVersion { + id: "1.20.1".to_owned(), + version_type: ReleaseType::Release, + url: "https://mirror.invalid/1.20.1.json".to_owned(), + time: None, + release_time: None, + sha1: None, + size: None, + }, + RemoteVersion { + id: "a1.1.1".to_owned(), + version_type: ReleaseType::OldAlpha, + url: "https://mirror.invalid/a1.1.1.json".to_owned(), + time: None, + release_time: None, + sha1: None, + size: None, + }, + ]); + + assert_eq!(added, 1, "只应新增缺失条目"); + assert_eq!(list.len(), 4); + assert_eq!( + list.find("1.20.1").map(|v| v.url.as_str()), + Some("https://example.invalid/1.20.1.json") + ); + assert!(list.find("a1.1.1").is_some()); +} + +#[test] +fn roundtrips_manifest_json() { + let list = manifest(); + let json = list.to_json_string().expect("应可序列化"); + let reparsed = VersionList::from_json_str(&json).expect("应可再解析"); + assert_eq!(reparsed, list); +} + +#[test] +fn invalid_json_reports_context() { + let error = VersionList::from_json_str("not json").expect_err("非法 JSON 应报错"); + match error { + GameError::Json { context, .. } => assert_eq!(context, "version_manifest"), + other => panic!("期望 Json 错误,得到 {other:?}"), + } +} diff --git a/crates/core/tests/game/vulkan_capability.rs b/crates/core/tests/game/vulkan_capability.rs new file mode 100644 index 0000000..2f4fe6e --- /dev/null +++ b/crates/core/tests/game/vulkan_capability.rs @@ -0,0 +1,125 @@ +//! 版本能力位:从版本 JSON 的库表推 `has_vulkan_backend`。 +//! +//! 事实基线(`research/05-mc-vulkan.md` §2.2,piston-meta 921 个版本逐版扫过): +//! `org.lwjgl:lwjgl-vulkan` 从 **26.2-snapshot-1** 起出现,26.1 全线(含 `26w14a`)没有。 +//! 所以「库在不在」是判断版本带不带 Vulkan 后端的可靠依据。 + +use unlucky_core::game::capability::{ + is_vulkan_library, library_names_have_vulkan, VersionCapabilities, +}; +use unlucky_core::game::manifest::VersionJson; + +use crate::helpers::parse; + +/// 26.2-snapshot-1 的库表形态(Vulkan 三件套 + 常规库,实测结构)。 +const VULKAN_ERA_JSON: &str = r#"{ + "id": "26.2-snapshot-1", + "type": "snapshot", + "mainClass": "net.minecraft.client.main.Main", + "libraries": [ + { "name": "org.lwjgl:lwjgl:3.4.1", "downloads": { "artifact": { "path": "org/lwjgl/lwjgl/3.4.1/lwjgl-3.4.1.jar" } } }, + { "name": "org.lwjgl:lwjgl-vulkan:3.4.1", "downloads": { "artifact": { "path": "org/lwjgl/lwjgl-vulkan/3.4.1/lwjgl-vulkan-3.4.1.jar" } } }, + { "name": "org.lwjgl:lwjgl-vulkan:3.4.1:natives-macos" }, + { "name": "org.lwjgl:lwjgl-shaderc:3.4.1" }, + { "name": "com.mojang:brigadier:1.3.10" } + ] +}"#; + +/// 26.1(含 `26w14a` 周快照那一段)的库表:只有 opengl,没有 vulkan。 +const PRE_VULKAN_JSON: &str = r#"{ + "id": "26.1", + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "libraries": [ + { "name": "org.lwjgl:lwjgl:3.4.1" }, + { "name": "org.lwjgl:lwjgl-glfw:3.4.1" }, + { "name": "org.lwjgl:lwjgl-opengl:3.4.1" } + ] +}"#; + +#[test] +fn version_json_with_lwjgl_vulkan_carries_the_capability() { + let version = parse(VULKAN_ERA_JSON); + assert!(version.has_vulkan_backend()); + let capabilities = VersionCapabilities::from_json(&version); + assert!(capabilities.has_vulkan_backend()); +} + +#[test] +fn version_json_without_lwjgl_vulkan_lacks_the_capability() { + let version = parse(PRE_VULKAN_JSON); + assert!(!version.has_vulkan_backend()); + assert_eq!( + VersionCapabilities::from_json(&version), + VersionCapabilities::default() + ); +} + +#[test] +fn missing_libraries_field_is_not_a_capability() { + let version = parse(r#"{ "id": "custom", "mainClass": "a.B" }"#); + assert!(!version.has_vulkan_backend()); +} + +#[test] +fn coordinate_forms_are_all_recognised() { + // 主构件、带 classifier 的 natives、带 @ext 的写法都算命中(只比对 group:artifact)。 + assert!(is_vulkan_library("org.lwjgl:lwjgl-vulkan:3.4.3")); + assert!(is_vulkan_library( + "org.lwjgl:lwjgl-vulkan:3.4.3:natives-macos" + )); + assert!(is_vulkan_library("org.lwjgl:lwjgl-vulkan:3.4.3@jar")); + + // 相邻但不该命中的(前缀相同 / group 不同)。 + assert!(!is_vulkan_library("org.lwjgl:lwjgl-opengl:3.4.3")); + assert!(!is_vulkan_library("org.lwjgl:lwjgl:3.4.3")); + assert!(!is_vulkan_library("com.mojang:lwjgl-vulkan:1.0")); + assert!(!is_vulkan_library("lwjgl-vulkan")); + assert!(!is_vulkan_library("")); +} + +#[test] +fn library_name_slice_helper_matches_the_json_path() { + let names = ["org.lwjgl:lwjgl:3.4.1", "org.lwjgl:lwjgl-vulkan:3.4.1"]; + assert!(library_names_have_vulkan(names)); + assert!(!library_names_have_vulkan(["org.lwjgl:lwjgl:3.4.1"])); + assert!(!library_names_have_vulkan(std::iter::empty())); + + // 与 game 侧的库表判定同源:同一份 JSON 两条路径给同一结论。 + let version = parse(VULKAN_ERA_JSON); + assert_eq!( + library_names_have_vulkan( + version + .libraries + .as_deref() + .unwrap_or_default() + .iter() + .map(|l| l.name.as_str()) + ), + version.has_vulkan_backend() + ); +} + +#[test] +fn capability_bit_is_not_the_parameter_gate() { + // 26.2-snapshot-1 已经有库,却还没有 `--graphicsBackend`(实测差两天)。 + // 能力位是「必要条件」,下发门限另有精确版本门(见 launch 侧 + // `graphics_backend_allowed`),两者不能被压成一个判断。 + let snapshot_one = parse(VULKAN_ERA_JSON); + assert!( + snapshot_one.has_vulkan_backend(), + "26.2-snapshot-1 有 Vulkan 库" + ); + assert_eq!(snapshot_one.id.as_deref(), Some("26.2-snapshot-1")); +} + +#[test] +fn version_json_accessor_delegates_to_capability_module() { + for json in [VULKAN_ERA_JSON, PRE_VULKAN_JSON] { + let version: VersionJson = parse(json); + assert_eq!( + version.has_vulkan_backend(), + VersionCapabilities::from_json(&version).has_vulkan_backend() + ); + } +} diff --git a/crates/core/tests/launch/adapter_cases.rs b/crates/core/tests/launch/adapter_cases.rs new file mode 100644 index 0000000..ee7f73d --- /dev/null +++ b/crates/core/tests/launch/adapter_cases.rs @@ -0,0 +1,166 @@ +//! game 快照 → `LaunchRequest` 适配层测试(缺失库显式记录、features 派生)。 + +use crate::prelude::*; + +fn write_file(path: &std::path::Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("建目录"); + } + std::fs::write(path, content).expect("写文件"); +} + +fn jvm_tokens() -> Vec { + vec![ + "-Djava.library.path=${natives_directory}".to_string(), + "-cp".to_string(), + "${classpath}".to_string(), + ] +} + +fn game_tokens() -> Vec { + vec![ + "--username".to_string(), + "${auth_player_name}".to_string(), + "--version".to_string(), + "${version_name}".to_string(), + ] +} + +#[test] +fn adapter_records_missing_libraries_and_fails_before_start() { + let dir = temp_dir("adapter-strict"); + let lib_ok = dir.join("libraries/a.jar"); + let lib_missing = dir.join("libraries/b.jar"); + let primary = dir.join("versions/1.20.1/1.20.1.jar"); + write_file(&lib_ok, "x"); + write_file(&primary, "x"); + let libraries = vec![ + LibraryInput::new(lib_ok.clone(), "org.example:a:1"), + LibraryInput::new(lib_missing.clone(), "org.example:b:2"), + ]; + let jvm = jvm_tokens(); + let game = game_tokens(); + let snapshot = SnapshotInput { + version_name: Some("1.20.1 Fabric"), + version_type: Some("release"), + game_version: Some("1.20.1"), + main_class: Some("net.fabricmc.loader.impl.launch.knot.KnotClient"), + jvm_arguments: &jvm, + game_arguments: &game, + libraries: &libraries, + primary_jar: primary.clone(), + game_dir: dir.clone(), + assets_index: Some("17"), + natives_dir: dir.join("versions/1.20.1/natives"), + library_dir: dir.join("libraries"), + assets_root: dir.join("assets"), + game_assets: dir.join("assets/virtual/legacy"), + ..SnapshotInput::minimal("1.20.1", "") + }; + let mut options = AdapterOptions::strict(); + options.java = JavaSelection::new("C:/java/bin/java.exe", 21, 64); + options.memory = MemoryOptions { + min_mb: Some(512), + max_mb: Some(2048), + }; + options.resolution = Some(Resolution { + width: 1280, + height: 720, + }); + + let output = build_request(&snapshot, options); + assert_eq!(output.classpath_entries, 2, "a.jar + 主 jar"); + assert_eq!(output.missing.len(), 1); + assert_eq!(output.missing[0].name, "org.example:b:2"); + assert!(output.warnings.iter().any(|warning| matches!( + warning, + AdapterWarning::MissingLibrary { name, .. } if name == "org.example:b:2" + ))); + // features 派生:给了分辨率就打开 has_custom_resolution;1.20.1 支持 QuickPlay。 + assert!(output.request.features.has_custom_resolution); + assert!(output.request.features.has_quick_plays_support); + assert_eq!(output.request.instance.name, "1.20.1 Fabric"); + assert_eq!( + output.request.manifest.main_class.as_deref(), + Some("net.fabricmc.loader.impl.launch.knot.KnotClient") + ); + + // 严格模式下缺失库 → 组装前显式失败(不再交给运行时 NoClassDefFoundError)。 + let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64"); + match assemble(&output.request, &ctx) { + Err(LaunchError::Assembly(AssemblyError::MissingComponents(items))) => { + assert_eq!(items.len(), 1); + } + other => panic!("应因缺组件失败,实际:{other:?}"), + } +} + +#[test] +fn adapter_tolerant_mode_skips_missing_libraries() { + let dir = temp_dir("adapter-tolerant"); + let lib_missing = dir.join("libraries/b.jar"); + let libraries = vec![LibraryInput::new(lib_missing.clone(), "org.example:b:2")]; + let jvm = jvm_tokens(); + let snapshot = SnapshotInput { + main_class: Some("net.minecraft.client.main.Main"), + jvm_arguments: &jvm, + libraries: &libraries, + primary_jar: dir.join("client.jar"), + game_dir: dir.clone(), + assets_index: Some("5"), + ..SnapshotInput::minimal("1.12.2", "") + }; + let mut options = AdapterOptions::strict(); + options.check_classpath = false; + options.java = JavaSelection::new("java", 8, 64); + + let output = build_request(&snapshot, options); + assert!( + !output.request.classpath.is_empty(), + "宽松模式仍保留库(交给启动期报错)" + ); + assert!(output.request.missing_components.is_empty()); + // 宽松模式不报缺失库(其它告警——如缺游戏参数——与本用例无关)。 + assert!(!output + .warnings + .iter() + .any(|warning| matches!(warning, AdapterWarning::MissingLibrary { .. }))); +} + +#[test] +fn adapter_warns_on_incomplete_manifest() { + let dir = temp_dir("adapter-incomplete"); + let snapshot = SnapshotInput { + main_class: None, + ..SnapshotInput::minimal("1.7.10", "") + }; + let output = build_request(&snapshot, AdapterOptions::strict()); + let warnings = &output.warnings; + assert!(warnings.contains(&AdapterWarning::MissingMainClass)); + assert!(warnings.contains(&AdapterWarning::MissingAssetsIndex)); + assert!(warnings.contains(&AdapterWarning::MissingGameArguments)); + // 1.7.10 < 1.20:不支持 QuickPlay。 + assert!(!output.request.features.has_quick_plays_support); + assert_eq!( + output.request.dirs.assets_index, "legacy", + "缺索引时兜底 legacy" + ); + let _ = dir; +} + +#[test] +fn adapter_legacy_arguments_are_passed_through() { + let dir = temp_dir("adapter-legacy"); + let snapshot = SnapshotInput { + main_class: Some("net.minecraft.launchwrapper.Launch"), + legacy_arguments: Some("--username ${auth_player_name} --gameDir ${game_directory}"), + game_dir: dir.clone(), + ..SnapshotInput::minimal("1.12.2", "") + }; + let output = build_request(&snapshot, AdapterOptions::strict()); + assert!(output.request.manifest.is_legacy()); + assert!(output.request.manifest.game.is_empty()); + assert!(!output + .warnings + .contains(&AdapterWarning::MissingGameArguments)); +} diff --git a/crates/core/tests/launch/cmdline_snapshot.rs b/crates/core/tests/launch/cmdline_snapshot.rs new file mode 100644 index 0000000..906649f --- /dev/null +++ b/crates/core/tests/launch/cmdline_snapshot.rs @@ -0,0 +1,338 @@ +//! 命令行组装快照测试(`analyse/02` §4.1 的 33 项顺序表 + §4.2 默认值抑制)。 +//! +//! 期望值是手工核对过的**完整 argv**:一旦组装顺序变了,这里必须一起改—— +//! 顺序本身有语义(后加的默认值会被先加的显式值抑制)。 + +use crate::prelude::*; + +#[test] +fn legacy_forge_full_argv() { + let (mut request, ctx) = legacy_forge_request(); + request.mod_loader = ModLoaderKind::Forge; + request.manifest.legacy_arguments = Some( + "--username ${auth_player_name} --version ${version_name} --gameDir ${game_directory} \ + --assetsDir ${assets_root} --assetIndex ${assets_index_name} --uuid ${auth_uuid} \ + --accessToken ${auth_session} --userType ${user_type} \ + --tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker" + .to_string(), + ); + // 用户指定 GBK → 日志泵也必须用 GBK 解码(`analyse/02` §4.4)。 + request.user.jvm = vec!["-Dfile.encoding=GBK".to_string()]; + + let command = assemble(&request, &ctx).expect("组装应成功"); + assert_eq!(command.encoding, "GBK"); + assert_eq!(command.main_class, "net.minecraft.launchwrapper.Launch"); + assert_eq!(command.classpath.len(), 3, "主 jar 必须追加在末位"); + + assert_argv( + &command, + &[ + "C:/java/bin/java.exe", + "-Xmx4096m", + "-Xms1024m", + "-XX:MetaspaceSize=64m", + "-Dfile.encoding=GBK", + "-Dsun.stdout.encoding=GBK", + "-Dsun.stderr.encoding=GBK", + "-Djava.rmi.server.useCodebaseOnly=true", + "-Dcom.sun.jndi.rmi.object.trustURLCodebase=false", + "-Dcom.sun.jndi.cosnaming.object.trustURLCodebase=false", + "-Dlog4j2.formatMsgNoLookups=true", + "-Dlog4j.configurationFile=C:/mc/log4j2.xml", + "-Dminecraft.client.jar=C:/mc/versions/1.12.2/1.12.2.jar", + "-Dfml.ignoreInvalidMinecraftCertificates=true", + "-Dfml.ignorePatchDiscrepancies=true", + "-Djava.library.path=C:/mc/versions/1.12.2/natives-windows-x86_64", + "-cp", + "C:/mc/libraries/a.jar;C:/mc/libraries/b.jar;C:/mc/versions/1.12.2/1.12.2.jar", + "net.minecraft.launchwrapper.Launch", + "--username", + "Steve", + "--version", + "1.12.2-Forge", + "--gameDir", + "C:/mc", + "--assetsDir", + "C:/mc/assets", + "--assetIndex", + "1.12", + "--uuid", + "0123456789abcdef0123456789abcdef", + "--accessToken", + "SECRET-TOKEN-123456", + "--userType", + "msa", + "--tweakClass", + "net.minecraftforge.fml.common.launcher.FMLTweaker", + "--width", + "854", + "--height", + "480", + ], + ); +} + +#[test] +fn modern_fabric_full_argv_with_rules() { + let (mut request, ctx) = legacy_forge_request(); + request.instance.name = "1.20.1-Fabric".to_string(); + request.instance.version = GameVersion::parse("1.20.1"); + request.manifest.main_class = + Some("net.fabricmc.loader.impl.launch.knot.KnotClient".to_string()); + request.manifest.legacy_arguments = None; + request.manifest.jvm = vec![ + ArgumentEntry::plain(["-Djava.library.path=${natives_directory}"]), + ArgumentEntry::plain(["-cp", "${classpath}"]), + ]; + request.manifest.game = vec![ + ArgumentEntry::plain(["--username", "${auth_player_name}"]), + ArgumentEntry::plain(["--version", "${version_name}"]), + ArgumentEntry::ruled( + vec![Rule::allow().with_os_name("windows")], + ["-Dos.windows=true"], + ), + ArgumentEntry::ruled(vec![Rule::allow().with_os_name("osx")], ["-Dos.macos=true"]), + ArgumentEntry::ruled( + vec![Rule::allow().with_feature("has_custom_resolution", true)], + [ + "--width", + "${resolution_width}", + "--height", + "${resolution_height}", + ], + ), + // 先 allow 后 disallow:按“最后一个命中的 action”生效(`analyse/02` §4.7)。 + ArgumentEntry::ruled( + vec![ + Rule::allow().with_os_name("windows"), + Rule::disallow().with_feature("is_demo_user", true), + ], + ["--windows-only"], + ), + ]; + request.resolution = Some(Resolution { + width: 1280, + height: 720, + }); + request.user.game = vec!["--fullscreen".to_string()]; + + let command = assemble(&request, &ctx).expect("组装应成功"); + // 主 jar 由组装器追加到最后,manifest 里的 `-cp` 只覆盖库。 + assert_eq!(command.classpath.len(), 3); + + assert_argv( + &command, + &[ + "C:/java/bin/java.exe", + "-Xmx4096m", + "-Xms1024m", + "-XX:MetaspaceSize=64m", + "-Dfile.encoding=UTF-8", + "-Dsun.stdout.encoding=UTF-8", + "-Dsun.stderr.encoding=UTF-8", + "-Djava.rmi.server.useCodebaseOnly=true", + "-Dcom.sun.jndi.rmi.object.trustURLCodebase=false", + "-Dcom.sun.jndi.cosnaming.object.trustURLCodebase=false", + "-Dlog4j2.formatMsgNoLookups=true", + "-Dlog4j.configurationFile=C:/mc/log4j2.xml", + "-Dminecraft.client.jar=C:/mc/versions/1.12.2/1.12.2.jar", + "-Djava.library.path=C:/mc/versions/1.12.2/natives-windows-x86_64", + "-cp", + "C:/mc/libraries/a.jar;C:/mc/libraries/b.jar;C:/mc/versions/1.12.2/1.12.2.jar", + "net.fabricmc.loader.impl.launch.knot.KnotClient", + "--username", + "Steve", + "--version", + "1.20.1-Fabric", + "-Dos.windows=true", + "--width", + "1280", + "--height", + "720", + "--windows-only", + "--fullscreen", + ], + ); + assert!(!command.args.iter().any(|arg| arg == "-Dos.macos=true")); +} + +#[test] +fn user_override_beats_defaults() { + let (mut request, ctx) = legacy_forge_request(); + request.java = JavaSelection::new("C:/java8/bin/java.exe", 8, 32); + request.user.override_jvm = vec!["-Xmx8G".to_string(), "-XX:+UseSerialGC".to_string()]; + request.user.jvm = vec!["-Xmx2G".to_string()]; + request.optimization = OptimizationOptions { + enabled: true, + physical_memory_mb: Some(8192), + }; + request.manifest.legacy_arguments = None; + + let command = assemble(&request, &ctx).expect("组装应成功"); + let args = command.args; + // override 的 -Xmx8G 生效,默认的 -Xmx4096m 被抑制。 + assert!(args.iter().any(|arg| arg == "-Xmx8G")); + assert!(!args.iter().any(|arg| arg == "-Xmx4096m")); + // 用户 JVM 参数里的 -Xmx2G 属于“默认值”,被 override 抑制。 + assert!(!args.iter().any(|arg| arg == "-Xmx2G")); + // 用户显式挑了 GC → 不注入 G1 整组,但诊断关键项照旧。 + assert!(args.iter().any(|arg| arg == "-XX:+UseSerialGC")); + assert!(!args.iter().any(|arg| arg == "-XX:+UseG1GC")); + assert!(args + .iter() + .any(|arg| arg == "-XX:-OmitStackTraceInFastThrow")); + // 32 位老 Java → PermSize,且不因内存充裕注入 CodeCache 组。 + assert!(args.iter().any(|arg| arg == "-XX:MetaspaceSize=64m")); + assert!(!args + .iter() + .any(|arg| arg.starts_with("-XX:ReservedCodeCacheSize"))); +} + +#[test] +fn java8_uses_legacy_encoding_keys_and_keeps_xincgc() { + let (mut request, ctx) = legacy_forge_request(); + request.java = JavaSelection::new("C:/java8/bin/java.exe", 8, 32); + request.user.jvm = vec!["-Xincgc".to_string()]; + request.manifest.legacy_arguments = None; + let command = assemble(&request, &ctx).expect("组装应成功"); + assert!(command + .args + .iter() + .any(|arg| arg == "-Dsun.stdout.encoding=UTF-8")); + assert!(!command + .args + .iter() + .any(|arg| arg == "-Dstdout.encoding=UTF-8")); + // Java 8 上 -Xincgc 仍合法(只在 ≥9 上被禁,`analyse/02` §4.1 #33)。 + assert!(command.args.iter().any(|arg| arg == "-Xincgc")); + + let mut modern = request.clone(); + modern.java = JavaSelection::new("C:/java21/bin/java.exe", 21, 64); + let command = assemble(&modern, &ctx).expect("组装应成功"); + assert!(command + .args + .iter() + .any(|arg| arg == "-Dstdout.encoding=UTF-8")); + assert!( + !command.args.iter().any(|arg| arg == "-Xincgc"), + "Java ≥9 必须删掉 -Xincgc" + ); +} + +#[test] +fn quickplay_and_graphics_backend_are_version_gated() { + let (mut request, ctx) = legacy_forge_request(); + request.manifest.legacy_arguments = None; + request.quick_play = Some(QuickPlay::Multiplayer { + address: "mc.example.com:25565".to_string(), + }); + request.graphics_backend = Some("vulkan".to_string()); + + // 1.12.2:QuickPlay 退化成 --server/--port,--graphicsBackend 丢弃。 + let legacy = assemble(&request, &ctx).expect("组装应成功"); + let server = legacy + .args + .windows(2) + .any(|pair| pair[0] == "--server" && pair[1] == "mc.example.com"); + assert!(server, "老版本应退化为 --server"); + assert!(legacy + .args + .windows(2) + .any(|pair| pair[0] == "--port" && pair[1] == "25565")); + assert!(!legacy.args.iter().any(|arg| arg == "--graphicsBackend")); + assert!(legacy + .warnings + .contains(&AssemblyWarning::QuickPlayDegraded { + address: "mc.example.com:25565".to_string() + })); + assert!(legacy + .warnings + .iter() + .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. }))); + + // 26.3:QuickPlay 直用,--graphicsBackend 允许。 + let mut modern = request.clone(); + modern.instance.version = GameVersion::parse("26.3"); + let modern = assemble(&modern, &ctx).expect("组装应成功"); + assert!(modern + .args + .windows(2) + .any(|pair| pair[0] == "--quickPlayMultiplayer" && pair[1] == "mc.example.com:25565")); + assert!(modern + .args + .windows(2) + .any(|pair| pair[0] == "--graphicsBackend" && pair[1] == "vulkan")); +} + +#[test] +fn assembly_fails_fast_on_missing_components() { + let (mut request, ctx) = legacy_forge_request(); + request.missing_components = vec![ + MissingComponent::named("org.lwjgl:lwjgl:3.3.1"), + MissingComponent::at_path("net.minecraft:client", "C:/mc/versions/1.12.2/1.12.2.jar"), + ]; + let error = assemble(&request, &ctx).expect_err("缺组件必须启动前失败"); + match error { + LaunchError::Assembly(AssemblyError::MissingComponents(items)) => { + assert_eq!(items.len(), 2); + assert_eq!(items[0].name, "org.lwjgl:lwjgl:3.3.1"); + } + other => panic!("错误类型不对:{other}"), + } + + let (mut request, ctx) = legacy_forge_request(); + request.manifest.main_class = None; + assert!(matches!( + assemble(&request, &ctx), + Err(LaunchError::Assembly(AssemblyError::MissingMainClass)) + )); + + let (mut request, ctx) = legacy_forge_request(); + request.classpath.clear(); + request.dirs.primary_jar = std::path::PathBuf::new(); + assert!(matches!( + assemble(&request, &ctx), + Err(LaunchError::Assembly(AssemblyError::EmptyClasspath)) + )); +} + +#[test] +fn posix_gets_nice_and_colon_classpath() { + let mut ctx = PlatformCtx::new(OsFamily::Linux, "x86_64"); + ctx.home_dir = Some(std::path::PathBuf::from("/home/aska")); + let (mut request, _) = legacy_forge_request(); + request.nice = Some(5); + request.wrapper = vec!["prime-run".to_string()]; + request.manifest.legacy_arguments = None; + + let command = assemble(&request, &ctx).expect("组装应成功"); + assert_eq!(command.argv()[0], "nice"); + assert_eq!(command.argv()[1], "-n"); + assert_eq!(command.argv()[2], "5"); + assert_eq!(command.argv()[3], "prime-run"); + assert_eq!(command.argv()[4], "C:/java/bin/java.exe"); + assert!(command + .args + .iter() + .any(|arg| arg + == "C:/mc/libraries/a.jar:C:/mc/libraries/b.jar:C:/mc/versions/1.12.2/1.12.2.jar")); + // 非 Windows 才注入 -Duser.home。 + assert!(command + .args + .iter() + .any(|arg| arg.starts_with("-Duser.home="))); +} + +#[test] +fn placeholder_table_is_complete() { + let (request, ctx) = legacy_forge_request(); + let map = unlucky_core::launch::placeholders::PlaceholderMap::from_request(&request, &ctx); + for key in unlucky_core::launch::placeholders::PLACEHOLDER_KEYS { + assert!(map.get(key).is_some(), "占位符 ${key} 未登记"); + } + assert_eq!(map.get("primary_jar_name"), Some("1.12.2.jar")); + assert_eq!(map.get("version_name"), Some("1.12.2-Forge")); + // 未定义的占位符原样保留(便于发现清单漏项)。 + assert_eq!(map.render("${nope}"), "${nope}"); + assert_eq!(map.render("a${version_name}b"), "a1.12.2-Forgeb"); +} diff --git a/crates/core/tests/launch/command_builder_cases.rs b/crates/core/tests/launch/command_builder_cases.rs new file mode 100644 index 0000000..22934e8 --- /dev/null +++ b/crates/core/tests/launch/command_builder_cases.rs @@ -0,0 +1,148 @@ +//! `CommandBuilder` 默认值抑制语义测试(`analyse/02` §4.2)。 + +use crate::prelude::*; + +fn strings(items: &[&str]) -> Vec { + items.iter().map(|item| (*item).to_string()).collect() +} + +#[test] +fn add_default_suppresses_when_prefix_exists() { + let mut builder = CommandBuilder::new(); + builder.add("-Xmx8G"); + let existing = builder.add_default("-Xmx", "4096m"); + assert_eq!(existing.as_deref(), Some("-Xmx8G")); + assert_eq!(builder.args(), &strings(&["-Xmx8G"])); + + let added = builder.add_default("-Xms", "1024m"); + assert_eq!(added, None); + assert_eq!(builder.args(), &strings(&["-Xmx8G", "-Xms1024m"])); +} + +#[test] +fn unstable_flags_do_not_conflict() { + let mut builder = CommandBuilder::new(); + builder.add("-XX:-UseG1GC"); + // 用户已经用 `-XX:-UseG1GC` 关掉了 G1,就不再补 `-XX:+UseG1GC`。 + assert!(builder.add_unstable_flag("UseG1GC", true).is_some()); + assert!(!builder.contains_exact("-XX:+UseG1GC")); + + let mut builder = CommandBuilder::new(); + builder.add("-XX:MaxInlineLevel=20"); + assert!(builder + .add_unstable_default("MaxInlineLevel", "15") + .is_some()); + assert_eq!(builder.args(), &strings(&["-XX:MaxInlineLevel=20"])); +} + +#[test] +fn add_all_default_handles_each_shape() { + let mut builder = CommandBuilder::new(); + builder.add("-Dfile.encoding=GBK"); + builder.add("-XX:+UseSerialGC"); + builder.add("-Xmx8G"); + builder.add("-cp"); + builder.add("user-cp"); + + builder.add_all_default(&strings(&[ + "-Dfile.encoding=UTF-8", + "-Dsun.stdout.encoding=UTF-8", + "-XX:+UseG1GC", + "-XX:G1MixedGCCountTarget=5", + "-Xmx4096m", + "-Xss1m", + "-cp", + "default-cp", + "exact-arg", + ])); + + assert_eq!( + builder.args(), + &strings(&[ + "-Dfile.encoding=GBK", + "-XX:+UseSerialGC", + "-Xmx8G", + "-cp", + "user-cp", + "-Dsun.stdout.encoding=UTF-8", + "-XX:+UseG1GC", + "-XX:G1MixedGCCountTarget=5", + "-Xss1m", + "exact-arg", + ]) + ); +} + +#[test] +fn argfile_expansion_suppresses_later_defaults() { + let mut builder = CommandBuilder::new(); + builder.add_all_without_parsing_and_read_external( + &strings(&["@options.txt"]), + &|path: &str| { + if path == "options.txt" { + Some("-Xmx12G\n-Dfile.encoding=COMPAT\n".to_string()) + } else { + None + } + }, + ); + // @argfile 里的 -Xmx12G 已登记为外部参数,默认值必须被抑制。 + assert!(builder.add_default("-Xmx", "4096m").is_some()); + assert!(builder.add_default("-Dfile.encoding=", "UTF-8").is_some()); + // 外部参数只参与判重,不进 argv。 + assert!(!builder.contains_exact("-Xmx12G")); + assert_eq!(builder.args(), &strings(&["@options.txt"])); +} + +#[test] +fn tokenize_handles_quotes_and_escapes() { + assert_eq!( + command_builder::tokenize(r#"--username "a b" --path 'C:\x y' plain"#), + strings(&["--username", "a b", "--path", "C:\\x y", "plain"]) + ); + assert_eq!( + command_builder::tokenize(r#"-Dmsg="say \"hi\"""#), + strings(&[r#"-Dmsg=say "hi""#]) + ); + assert!(command_builder::tokenize(" ").is_empty()); +} + +#[test] +fn parse_argfile_ignores_comments_and_blank_lines() { + let contents = "# 注释\n-Xmx2G\n\n-Xms512m -Dfoo=bar\n"; + assert_eq!( + command_builder::parse_argfile(contents), + strings(&["-Xmx2G", "-Xms512m", "-Dfoo=bar"]) + ); +} + +#[test] +fn forbidden_args_are_removed_at_the_end() { + let mut builder = CommandBuilder::new(); + builder.add_all(&strings(&["-Xincgc", "-Xmx1G", "-Xincgc"])); + let removed = builder.remove_forbidden(&["-Xincgc"]); + assert_eq!(removed.len(), 2); + assert_eq!(builder.args(), &strings(&["-Xmx1G"])); +} + +#[test] +fn placeholders_are_rendered_on_add_but_not_on_raw_add() { + let mut map = unlucky_core::launch::placeholders::PlaceholderMap::new(); + map.insert("version_name", "1.20.1"); + let mut builder = CommandBuilder::with_placeholders(map); + builder.add("--version"); + builder.add("${version_name}"); + builder.add_without_parsing("--raw ${version_name}"); + assert_eq!( + builder.args(), + &strings(&["--version", "1.20.1", "--raw ${version_name}"]) + ); + // 人读形式给含空格的参数加引号(日志与快照测试用)。 + assert_eq!( + builder.display_args(), + "--version 1.20.1 \"--raw ${version_name}\"" + ); + let mut quoted = CommandBuilder::new(); + quoted.add("-Dmsg=hello world"); + assert_eq!(quoted.display_args(), "\"-Dmsg=hello world\""); +} diff --git a/crates/core/tests/launch/exit_cases.rs b/crates/core/tests/launch/exit_cases.rs new file mode 100644 index 0000000..c861b6b --- /dev/null +++ b/crates/core/tests/launch/exit_cases.rs @@ -0,0 +1,169 @@ +//! 退出分析测试:退出码语义 + `exit 0` 静默失败(`analyse/02` §6.3 §7.2 §7.10-10)。 + +use crate::prelude::*; + +fn line(stream: Stream, text: &str) -> LogLine { + LogLine::new(stream, text) +} + +fn analyze( + code: i32, + uptime_ms: u64, + lines: &[LogLine], + posix: bool, +) -> unlucky_core::launch::exit_analysis::ExitAnalysis { + let mut signals = exit_analysis::ExitSignals::new(code, uptime_ms, lines); + signals.is_posix = posix; + exit_analysis::analyze(&signals) +} + +#[test] +fn clean_long_run_is_normal() { + let lines = vec![line( + Stream::Stdout, + "[12:00:00] [Render thread/INFO]: started", + )]; + let analysis = analyze(0, 120_000, &lines, true); + assert_eq!(analysis.exit_type, ExitType::Normal); + assert!(analysis.silent_failure.is_none()); + assert!(!analysis.needs_diagnosis); +} + +#[test] +fn exit_zero_short_lived_is_silent_failure() { + let lines = vec![line(Stream::Stdout, "Stopping!")]; + let analysis = analyze(0, 8_000, &lines, true); + assert_eq!(analysis.exit_type, ExitType::Normal); + let silent = analysis.silent_failure.expect("应标记静默失败"); + assert!(silent.short_lived); + assert!(!silent.error_output); + assert!(analysis.needs_diagnosis, "静默失败必须进诊断流水线"); +} + +#[test] +fn exit_zero_with_error_output_is_silent_failure() { + let lines = vec![ + line(Stream::Stdout, "[12:00:00] [main/INFO]: loading"), + line( + Stream::Stderr, + "java.lang.RuntimeException: mixin apply failed", + ), + ]; + let analysis = analyze(0, 90_000, &lines, true); + assert_eq!(analysis.exit_type, ExitType::Normal); + let silent = analysis.silent_failure.expect("应标记静默失败"); + assert!(silent.error_output); + assert!(!silent.short_lived); + assert!(silent.describe().contains("错误特征")); + assert_eq!(analysis.error_lines, 1); +} + +#[test] +fn jvm_keywords_take_precedence() { + let lines = vec![line( + Stream::Stderr, + "Error occurred during initialization of VM", + )]; + let analysis = analyze(1, 500, &lines, true); + assert_eq!(analysis.exit_type, ExitType::JvmError); + assert_eq!(analysis.jvm_keywords.len(), 1); + assert!(analysis.needs_diagnosis); +} + +#[test] +fn nonzero_without_keywords_is_application_error() { + let lines = vec![line(Stream::Stdout, "[12:00:00] [main/INFO]: hi")]; + let analysis = analyze(1, 40_000, &lines, true); + assert_eq!(analysis.exit_type, ExitType::ApplicationError); + assert!(analysis.needs_diagnosis); +} + +#[test] +fn crash_keywords_promote_exit_zero() { + let lines = vec![line( + Stream::Stdout, + "[12:00:00] [Render thread/ERROR]: Crash report saved to ./crash-reports/crash-1.txt", + )]; + let analysis = analyze(0, 60_000, &lines, true); + assert_eq!(analysis.exit_type, ExitType::ApplicationError); + assert_eq!(analysis.crash_keywords.len(), 1); + assert!( + analysis.silent_failure.is_none(), + "已判异常就不再是静默失败" + ); +} + +#[test] +fn posix_signals_are_recognized() { + assert_eq!(analyze(137, 60_000, &[], true).exit_type, ExitType::SigKill); + assert_eq!(analyze(143, 60_000, &[], true).exit_type, ExitType::SigTerm); + // Windows 上 137 没有信号语义,按应用错误处理。 + assert_eq!( + analyze(137, 60_000, &[], false).exit_type, + ExitType::ApplicationError + ); +} + +#[test] +fn windows_ntstatus_is_explained() { + let analysis = analyze(-1_073_741_819, 60_000, &[], false); + assert_eq!(analysis.exit_type, ExitType::ApplicationError); + assert_eq!( + analysis.windows_status.as_deref(), + Some("0xC0000005 ACCESS_VIOLATION") + ); + + let dll = analyze(-1_073_741_515, 60_000, &[], false); + assert_eq!( + dll.windows_status.as_deref(), + Some("0xC0000135 DLL_NOT_FOUND") + ); + + // 0xC000013A = STATUS_CONTROL_C_EXIT(控制台 Ctrl+C / 关窗口),十进制 -1073741510。 + let control_c = analyze(-1_073_741_510, 60_000, &[], false); + assert_eq!( + control_c.windows_status.as_deref(), + Some("0xC000013A CONTROL_C_EXIT") + ); + // 回归:-1073741743 其实是 0xC0000051,不得被当成 CONTROL_C_EXIT。 + assert!(analyze(-1_073_741_743, 60_000, &[], false) + .windows_status + .is_none()); +} + +#[test] +fn killed_and_interrupted_override_exit_code() { + let mut signals = exit_analysis::ExitSignals::new(1, 5_000, &[]); + signals.killed = true; + assert_eq!(exit_analysis::analyze(&signals).exit_type, ExitType::Killed); + + let mut signals = exit_analysis::ExitSignals::new(1, 5_000, &[]); + signals.interrupted = true; + assert_eq!( + exit_analysis::analyze(&signals).exit_type, + ExitType::Interrupted + ); +} + +#[test] +fn exit_type_strings_match_contract() { + // 与 M15 刻意保持一致的字符串集合(`launch-接口约定.md` §3)。 + assert_eq!(ExitType::Normal.as_str(), "normal"); + assert_eq!(ExitType::ApplicationError.as_str(), "application_error"); + assert_eq!(ExitType::JvmError.as_str(), "jvm_error"); + assert_eq!(ExitType::SigKill.as_str(), "sigkill"); + assert_eq!(ExitType::SigTerm.as_str(), "sigterm"); + assert_eq!(ExitType::Killed.as_str(), "killed"); + assert_eq!(ExitType::Interrupted.as_str(), "interrupted"); + assert!(!ExitType::Normal.is_abnormal()); + assert!(ExitType::JvmError.is_abnormal()); +} + +#[test] +fn summary_is_human_readable() { + let analysis = analyze(-1_073_741_819, 61_500, &[], false); + let summary = analysis.summary(); + assert!(summary.contains("-1073741819")); + assert!(summary.contains("61.5s")); + assert!(summary.contains("ACCESS_VIOLATION")); +} diff --git a/crates/core/tests/launch/graphics_backend_cases.rs b/crates/core/tests/launch/graphics_backend_cases.rs new file mode 100644 index 0000000..d8c5759 --- /dev/null +++ b/crates/core/tests/launch/graphics_backend_cases.rs @@ -0,0 +1,147 @@ +//! `--graphicsBackend` 的两套判据(能力位 vs 精确版本门)与周快照放行。 +//! +//! 事实基线(`research/05-mc-vulkan.md` §3.2/§4.2,实测): +//! - `--graphicsBackend` 从 **26.2-snapshot-2**(2026-04-09)才有; +//! - `org.lwjgl:lwjgl-vulkan` 从 **26.2-snapshot-1**(2026-04-07)就有 —— 差两天; +//! - 周快照(`26w14a`)无法与 `26.2-snapshot-N` 排序,只能靠库表判。 +//! +//! 结论:能力位**不能**替代精确版本门,两者的分工是本文件的断言对象。 + +use unlucky_core::launch::game_args::{graphics_backend_allowed, GRAPHICS_BACKEND_MIN_VERSION}; + +use crate::prelude::*; + +#[test] +fn known_versions_use_the_exact_threshold_and_ignore_the_capability_bit() { + // 26.2-snapshot-1:有 Vulkan 库却没有参数,绝不能因为能力位放行 + // (jopt-simple 未开 allowsUnrecognizedOptions,未声明选项会让游戏直接起不来)。 + assert!(!graphics_backend_allowed( + &GameVersion::parse("26.2-snapshot-1"), + true + )); + assert!(!graphics_backend_allowed( + &GameVersion::parse("26.2-snapshot-1"), + false + )); + // 门限上的那一版起放行(能力位无关)。 + assert!(graphics_backend_allowed( + &GameVersion::parse(GRAPHICS_BACKEND_MIN_VERSION), + false + )); + assert!(graphics_backend_allowed(&GameVersion::parse("26.2"), false)); + assert!(graphics_backend_allowed(&GameVersion::parse("26.3"), false)); + // 可解析的老版本:能力位再真也不放行。 + assert!(!graphics_backend_allowed( + &GameVersion::parse("1.20.1"), + true + )); +} + +#[test] +fn weekly_snapshots_fall_back_to_the_capability_bit() { + let weekly = GameVersion::parse("26w14a"); + assert!(!weekly.is_known(), "周快照不做数字排序(不猜)"); + assert!(weekly.is_weekly_snapshot()); + + // 26.1 线(含周快照)没有 lwjgl-vulkan → 丢弃,和以前一样。 + assert!(!graphics_backend_allowed(&weekly, false)); + // 带 Vulkan 库的周快照(26.2 之后的周快照)→ 不再一律丢弃(这就是被修的误伤)。 + assert!(graphics_backend_allowed(&weekly, true)); + assert!(graphics_backend_allowed( + &GameVersion::parse("26w30a"), + true + )); +} + +#[test] +fn non_weekly_unknown_versions_are_still_dropped() { + // 第三方自定义版本串没有可依据的语义,能力位为真也不猜。 + let custom = GameVersion::parse("custom-build"); + assert!(!custom.is_known()); + assert!(!custom.is_weekly_snapshot()); + assert!(!graphics_backend_allowed(&custom, true)); + assert!(!graphics_backend_allowed(&GameVersion::default(), true)); +} + +#[test] +fn weekly_snapshot_shape_is_parsed_narrowly() { + for shape in ["20w14a", "26w14a", "26W14A", "12w01a"] { + assert!( + GameVersion::parse(shape).is_weekly_snapshot(), + "{shape} 应识别为周快照" + ); + } + for other in ["26.2-snapshot-1", "26w14", "26ww14a", "w14a", "26w14ab", ""] { + assert!( + !GameVersion::parse(other).is_weekly_snapshot(), + "{other} 不该被当成周快照" + ); + } +} + +#[test] +fn assemble_lets_a_vulkan_weekly_snapshot_through() { + let (request, ctx) = weekly_request("26w30a", true); + let assembled = assemble(&request, &ctx).expect("组装应成功"); + assert!( + assembled + .args + .windows(2) + .any(|pair| pair[0] == "--graphicsBackend" && pair[1] == "vulkan"), + "带 Vulkan 库的周快照应下发 --graphicsBackend:{:?}", + assembled.args + ); + assert!(!assembled + .warnings + .iter() + .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. }))); +} + +#[test] +fn assemble_still_drops_it_for_a_pre_vulkan_weekly_snapshot() { + let (request, ctx) = weekly_request("26w14a", false); + let assembled = assemble(&request, &ctx).expect("组装应成功"); + assert!(!assembled.args.iter().any(|arg| arg == "--graphicsBackend")); + assert!(assembled + .warnings + .iter() + .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. }))); +} + +#[test] +fn adapter_derives_the_capability_bit_from_the_library_names() { + let dir = temp_dir("adapter-vulkan-capability"); + let with_vulkan = vec![ + LibraryInput::new(dir.join("libraries/lwjgl.jar"), "org.lwjgl:lwjgl:3.4.3"), + LibraryInput::new( + dir.join("libraries/lwjgl-vulkan.jar"), + "org.lwjgl:lwjgl-vulkan:3.4.3", + ), + ]; + let without_vulkan = vec![LibraryInput::new( + dir.join("libraries/lwjgl.jar"), + "org.lwjgl:lwjgl:3.4.3", + )]; + + for (libraries, expected) in [(&with_vulkan, true), (&without_vulkan, false)] { + let snapshot = SnapshotInput { + main_class: Some("net.minecraft.client.main.Main"), + libraries, + ..SnapshotInput::minimal("26.3", "") + }; + let output = build_request(&snapshot, AdapterOptions::strict()); + assert_eq!( + output.request.manifest.has_vulkan_backend, expected, + "库表 {libraries:?} 的能力位判定不对" + ); + } +} + +/// 一个只关心 `--graphicsBackend` 的请求(周快照版本 + 指定能力位)。 +fn weekly_request(version: &str, has_vulkan_backend: bool) -> (LaunchRequest, PlatformCtx) { + let (mut request, ctx) = legacy_forge_request(); + request.instance.version = GameVersion::parse(version); + request.manifest.has_vulkan_backend = has_vulkan_backend; + request.graphics_backend = Some("vulkan".to_string()); + (request, ctx) +} diff --git a/crates/core/tests/launch/main.rs b/crates/core/tests/launch/main.rs new file mode 100644 index 0000000..a4a2d7b --- /dev/null +++ b/crates/core/tests/launch/main.rs @@ -0,0 +1,15 @@ +//! launch 模块集成测试入口(`cargo test --test launch`)。 + +mod adapter_cases; +mod cmdline_snapshot; +mod command_builder_cases; +mod exit_cases; +mod graphics_backend_cases; +mod natives_args_cases; +mod natives_cases; +mod pipeline_cases; +mod prelude; +mod process_smoke; +mod pump_buffer; +mod session_events; +mod sink_cases; diff --git a/crates/core/tests/launch/natives_args_cases.rs b/crates/core/tests/launch/natives_args_cases.rs new file mode 100644 index 0000000..027345a --- /dev/null +++ b/crates/core/tests/launch/natives_args_cases.rs @@ -0,0 +1,136 @@ +//! natives 时代分界与参数接线:清单口径收集、`-Djava.library.path` 归属、1.19+ 四个目录属性。 +//! +//! 抽取行为本身在 `natives_cases.rs`;这里只验「参数从哪来、收集口径对不对」。 + +use unlucky_core::launch::natives; + +use crate::prelude::*; + +/// 参数接线:legacy 清单没有 `arguments.jvm`,`-Djava.library.path` 由启动器补且只补一次。 +#[test] +fn legacy_manifest_gets_single_java_library_path() { + let (request, ctx) = legacy_forge_request(); + let command = assemble(&request, &ctx).expect("组装应成功"); + let natives = request.dirs.natives_dir.display().to_string(); + let library_path: Vec<&String> = command + .args + .iter() + .filter(|arg| arg.starts_with("-Djava.library.path=")) + .collect(); + assert_eq!(library_path.len(), 1, "legacy 只该有一条:{library_path:?}"); + assert_eq!(library_path[0], &format!("-Djava.library.path={natives}")); +} + +/// 参数接线:1.13+ 清单自带 `-Djava.library.path=${natives_directory}`,启动器不得重复注入。 +#[test] +fn modern_manifest_keeps_its_own_library_path_once() { + let (mut request, ctx) = legacy_forge_request(); + let natives = request.dirs.natives_dir.display().to_string(); + request.manifest.jvm = vec![ + ArgumentEntry::plain(["-Djava.library.path=${natives_directory}".to_string()]), + ArgumentEntry::plain(["-cp".to_string(), "${classpath}".to_string()]), + ]; + request.manifest.legacy_arguments = None; + let command = assemble(&request, &ctx).expect("组装应成功"); + let library_path: Vec<&String> = command + .args + .iter() + .filter(|arg| arg.starts_with("-Djava.library.path=")) + .collect(); + assert_eq!(library_path.len(), 1, "清单 + 默认值不该叠加"); + assert_eq!(library_path[0], &format!("-Djava.library.path={natives}")); +} + +/// 参数接线:1.19+ 的四个目录属性都要渲染到同一目录(LWJGL 3.3 运行期往那里解压)。 +#[test] +fn classpath_native_era_renders_all_four_directory_properties() { + let (mut request, ctx) = legacy_forge_request(); + let natives = request.dirs.natives_dir.display().to_string(); + request.manifest.jvm = vec![ArgumentEntry::plain([ + "-Djava.library.path=${natives_directory}".to_string(), + "-Djna.tmpdir=${natives_directory}".to_string(), + "-Dorg.lwjgl.system.SharedLibraryExtractPath=${natives_directory}".to_string(), + "-Dio.netty.native.workdir=${natives_directory}".to_string(), + ])]; + request.manifest.legacy_arguments = None; + let command = assemble(&request, &ctx).expect("组装应成功"); + for key in [ + "-Djava.library.path=", + "-Djna.tmpdir=", + "-Dorg.lwjgl.system.SharedLibraryExtractPath=", + "-Dio.netty.native.workdir=", + ] { + assert!( + command + .args + .iter() + .any(|arg| arg == &format!("{key}{natives}")), + "缺少 {key}{natives}:{:?}", + command.args + ); + } +} + +/// 收集口径:`natives` 表按 OS 命中 → 分类器 jar;1.19+ 无表 → 空; +/// `hint=local` 走实例私有目录(HMCL `analyse/03` §6.8 口径)。 +/// +/// 注意:安装层当前的 natives 分类器分支**不认** `hint=local`(走共享 `libraries/`), +/// 所以这一条断言测的是 `collect` 的正确行为,不是「与安装层一致」——见 +/// `collect` 文档里的已知缺口说明。 +#[test] +fn collect_picks_classifier_jars_per_environment() { + use unlucky_core::game::{Environment, GameLayout, Library}; + + let libraries: Vec = serde_json::from_str( + r#"[ + {"name":"org.lwjgl:lwjgl:3.2.2", + "natives":{"linux":"natives-linux","windows":"natives-windows"}, + "extract":{"exclude":["META-INF/"]}, + "downloads":{"artifact":{"path":"org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2.jar","url":"https://libraries.minecraft.net/a.jar"}, + "classifiers":{"natives-windows":{"path":"org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-windows.jar","url":"https://libraries.minecraft.net/b.jar"}}}}, + {"name":"org.lwjgl:lwjgl-tinyfd:3.2.2","natives":{"windows":"natives-windows"}, + "hint":"local", + "downloads":{"classifiers":{"natives-windows":{"path":"org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2-natives-windows.jar","url":"https://libraries.minecraft.net/c.jar"}}}}, + {"name":"org.lwjgl:lwjgl:3.3.3:natives-linux", + "rules":[{"action":"allow","os":{"name":"linux"}}], + "downloads":{"artifact":{"path":"org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-linux.jar","url":"https://libraries.minecraft.net/d.jar"}}} + ]"#, + ) + .expect("库 JSON 应可解析"); + let layout = GameLayout::new("C:/mc"); + let environment = Environment { + os: "windows".to_string(), + os_version: "10.0.19045".to_string(), + arch: "x86_64".to_string(), + ..Environment::default() + }; + + let natives = natives::collect(&layout, "1.16.5", &libraries, &environment); + assert_eq!(natives.len(), 2, "只该收 windows 分类器:{natives:?}"); + assert!( + natives[0] + .path + .ends_with("libraries/org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-windows.jar"), + "{:?}", + natives[0].path + ); + assert_eq!(natives[0].name, "org.lwjgl:lwjgl:3.2.2:natives-windows"); + assert_eq!(natives[0].exclude, vec!["META-INF/".to_string()]); + // `hint=local`:按 HMCL 口径落实例私有目录(安装层尚未跟进,见函数文档)。 + assert!( + natives[1] + .path + .ends_with("versions/1.16.5/libraries/org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2-natives-windows.jar"), + "{:?}", + natives[1].path + ); + + // 1.19+ 形态(无 natives 表)→ 空表(natives jar 在 classpath 上)。 + let modern: Vec = serde_json::from_str( + r#"[{"name":"org.lwjgl:lwjgl:3.3.3:natives-windows", + "rules":[{"action":"allow","os":{"name":"windows"}}], + "downloads":{"artifact":{"path":"org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows.jar","url":"https://libraries.minecraft.net/e.jar"}}}]"#, + ) + .expect("库 JSON 应可解析"); + assert!(natives::collect(&layout, "1.20.1", &modern, &environment).is_empty()); +} diff --git a/crates/core/tests/launch/natives_cases.rs b/crates/core/tests/launch/natives_cases.rs new file mode 100644 index 0000000..d289cc2 --- /dev/null +++ b/crates/core/tests/launch/natives_cases.rs @@ -0,0 +1,281 @@ +//! natives 解压(`launch::natives`)的用例:解析正确性 / 排除项 / 幂等 / 时代参数 / 事件流。 +//! +//! 假 jar 用 `zip` 的 Stored 模式现造(不依赖真下载),真机验收另有人工步骤。 + +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipWriter}; + +use unlucky_core::launch::natives::{self, NativeJar, NativesEra, NativesPlan, STAMP_FILE}; + +use crate::prelude::*; + +/// 造一个 natives jar:`entries` 是(条目名, 内容)。 +fn make_jar(path: &Path, entries: &[(&str, &[u8])]) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("建 jar 目录"); + } + let file = File::create(path).expect("建 jar"); + let mut writer = ZipWriter::new(file); + let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + for (name, bytes) in entries { + writer.start_file(*name, options).expect("写条目"); + writer.write_all(bytes).expect("写条目内容"); + } + writer.finish().expect("收尾 jar"); +} + +/// 一份 1.16.5 风格的 natives jar 的条目集(原生库 + 该被排除的噪音)。 +fn lwjgl_jar_entries() -> Vec<(&'static str, &'static [u8])> { + vec![ + ("lwjgl.dll", b"DLL-BYTES".as_slice()), + ("OpenAL.dll", b"OPENAL-BYTES".as_slice()), + ("META-INF/MANIFEST.MF", b"manifest".as_slice()), + ("META-INF/LWJGL.SF", b"signature".as_slice()), + ("lwjgl.dll.sha1", b"deadbeef".as_slice()), + ("README.txt", b"noise".as_slice()), + ] +} + +fn plan(jars: Vec) -> NativesPlan { + NativesPlan::new(jars) +} + +#[test] +fn extracts_native_libraries_and_drops_noise() { + let root = temp_dir("natives-extract"); + let jar = root.join("libraries/lwjgl-natives-windows.jar"); + make_jar(&jar, &lwjgl_jar_entries()); + let target = root.join("versions/1.16.5/natives"); + + let report = + natives::extract(&target, &plan(vec![NativeJar::new(&jar, "lwjgl")])).expect("解压应成功"); + + assert_eq!(report.files, 2, "只该解出两个原生库:{report:?}"); + assert!(!report.up_to_date); + assert_eq!(report.jars, 1); + // META-INF/ 两条 + .sha1 + README.txt。 + assert_eq!(report.ignored, 4, "噪音条目数:{report:?}"); + assert_eq!( + std::fs::read(target.join("lwjgl.dll")).expect("读 dll"), + b"DLL-BYTES" + ); + assert!(target.join("OpenAL.dll").is_file()); + assert!(!target.join("META-INF").exists(), "META-INF/ 不该落地"); + assert!(!target.join("lwjgl.dll.sha1").exists(), "签名文件不该落地"); + assert!(!target.join("README.txt").exists(), "非原生库不该落地"); + assert!(target.join(STAMP_FILE).is_file(), "应写戳记"); +} + +#[test] +fn second_run_is_idempotent_and_source_change_reextracts() { + let root = temp_dir("natives-idempotent"); + let jar = root.join("natives-windows.jar"); + make_jar(&jar, &[("lwjgl.dll", b"V1".as_slice())]); + let target = root.join("natives"); + let extract_plan = plan(vec![NativeJar::new(&jar, "lwjgl")]); + + let first = natives::extract(&target, &extract_plan).expect("首次解压"); + assert_eq!(first.files, 1); + assert!(!first.up_to_date); + + // 第二次:戳记命中,零写盘(文件 mtime 不变)。 + let before = std::fs::metadata(target.join("lwjgl.dll")) + .expect("stat") + .modified() + .expect("mtime"); + let second = natives::extract(&target, &extract_plan).expect("二次解压"); + assert!(second.up_to_date, "应判定目录已就绪:{second:?}"); + assert_eq!(second.files, 0); + assert_eq!(second.skipped, 1); + let after = std::fs::metadata(target.join("lwjgl.dll")) + .expect("stat") + .modified() + .expect("mtime"); + assert_eq!(before, after, "幂等路径不该重写文件"); + + // 源 jar 变了(内容 + 长度都变):必须重解,且旧的原生库被清掉。 + make_jar(&jar, &[("lwjgl.dll", b"V2-LONGER".as_slice())]); + let third = natives::extract(&target, &extract_plan).expect("三次解压"); + assert!(!third.up_to_date, "源变了必须重解:{third:?}"); + assert_eq!( + std::fs::read(target.join("lwjgl.dll")).expect("读 dll"), + b"V2-LONGER" + ); +} + +#[test] +fn stale_native_libraries_are_cleaned_before_reextract() { + let root = temp_dir("natives-clean"); + let jar = root.join("natives-windows.jar"); + make_jar(&jar, &[("lwjgl.dll", b"NEW".as_slice())]); + let target = root.join("natives"); + std::fs::create_dir_all(&target).expect("建目录"); + // 上一代遗留(旧版本 / 旧 loader 解出来的),必须清掉,否则两代 dll 混在 java.library.path 里。 + std::fs::write(target.join("old-lwjgl.dll"), b"stale").expect("写旧 dll"); + std::fs::write(target.join("keep-me.txt"), b"user file").expect("写用户文件"); + + natives::extract(&target, &plan(vec![NativeJar::new(&jar, "lwjgl")])).expect("解压"); + + assert!(!target.join("old-lwjgl.dll").exists(), "旧原生库应被清理"); + assert!(target.join("keep-me.txt").is_file(), "非原生库文件不该被删"); + assert!(target.join("lwjgl.dll").is_file()); +} + +#[test] +fn empty_plan_still_creates_directory_for_1_19() { + // 1.19+ 没有 natives 分类器:目录仍要存在(SharedLibraryExtractPath 指向它)。 + let root = temp_dir("natives-empty"); + let target = root.join("versions/1.21.4/natives"); + let report = natives::extract(&target, &NativesPlan::default()).expect("建目录"); + assert!(target.is_dir()); + assert!(report.up_to_date); + assert_eq!(report.files, 0); + assert_eq!(report.jars, 0); + assert_eq!( + report.era, + NativesEra::ClasspathNative, + "默认时代(无 jar)" + ); +} + +#[test] +fn missing_directory_is_an_error_not_a_silent_pass() { + let plan = NativesPlan::new(vec![NativeJar::new("x.jar", "x")]); + let err = natives::extract(Path::new(""), &plan).expect_err("空目录必须报错"); + assert!(err.message().contains("natives 目录"), "{err}"); +} + +#[test] +fn broken_jar_reports_zip_error() { + let root = temp_dir("natives-broken"); + let jar = root.join("natives-broken.jar"); + std::fs::write(&jar, b"not a zip at all").expect("写坏 jar"); + let target = root.join("natives"); + let err = natives::extract(&target, &plan(vec![NativeJar::new(&jar, "broken")])) + .expect_err("坏 jar 必须报错"); + assert!(err.message().contains("zip"), "错误应点明 zip:{err}"); +} + +#[test] +fn era_detect_covers_three_generations() { + assert_eq!(NativesEra::detect(0, 3), NativesEra::Legacy); + assert_eq!(NativesEra::detect(9, 3), NativesEra::Lwjgl3); + assert_eq!(NativesEra::detect(9, 0), NativesEra::ClasspathNative); + assert_eq!(NativesEra::Legacy.as_str(), "legacy"); + assert_eq!(NativesEra::Lwjgl3.as_str(), "lwjgl3"); + assert_eq!(NativesEra::ClasspathNative.as_str(), "classpath_native"); +} + +/// 事件流:`natives_extracted` 落盘字段与接口约定一致(dir/files/skipped/custom)。 +#[test] +fn natives_extracted_event_carries_report_fields() { + let root = temp_dir("natives-event"); + let jar = root.join("natives-windows.jar"); + make_jar(&jar, &[("lwjgl.dll", b"DLL".as_slice())]); + let target = root.join("natives"); + let report = + natives::extract(&target, &plan(vec![NativeJar::new(&jar, "lwjgl")])).expect("解压"); + + let sessions = temp_sessions_root("natives-event-sessions"); + let mut writer = SessionWriter::create( + &sessions, + SessionMeta::new("inst", "1.16.5", "1.16.5"), + Redactor::new(), + ) + .expect("建会话"); + writer.natives_extracted(&report).expect("写事件"); + let path = writer.path().to_path_buf(); + writer.finish(EndReason::Exit).expect("收尾"); + + let events = read_events(&path); + let event = events + .iter() + .find(|event| event["kind"] == "natives_extracted") + .expect("应有 natives_extracted 事件"); + let payload = &event["payload"]; + assert_eq!(payload["files"], 1); + assert_eq!(payload["skipped"], 0); + assert_eq!(payload["custom"], false); + assert_eq!(payload["jars"], 1); + assert_eq!(payload["up_to_date"], false); + assert_eq!(payload["era"], "lwjgl3"); + assert!(payload["dir"] + .as_str() + .expect("dir 是字符串") + .ends_with("natives")); +} + +/// `--dry-run` 也做环境准备:第二次跑要看到 up_to_date(真机验收的判定依据)。 +#[test] +fn dry_run_prepares_natives_and_is_idempotent() { + let root = temp_dir("natives-dry-run"); + let jar = root.join("natives-windows.jar"); + make_jar(&jar, &[("lwjgl.dll", b"DLL".as_slice())]); + let natives_dir: PathBuf = root.join("versions/1.16.5/natives"); + + let (mut request, _) = legacy_forge_request(); + request.dirs.natives_dir = natives_dir.clone(); + request.natives = NativesPlan::new(vec![NativeJar::new(&jar, "lwjgl")]); + request.manifest.legacy_arguments = Some("--username ${auth_player_name}".to_string()); + + let sessions = temp_sessions_root("natives-dry-run-sessions"); + let launcher = Launcher::new(LauncherConfig::new(&sessions)); + + let first = launcher.dry_run(&request).expect("首次 dry-run"); + let report = first.natives.expect("应有 natives 报告"); + assert_eq!(report.files, 1, "{report:?}"); + assert!(!report.up_to_date); + assert!(natives_dir.join("lwjgl.dll").is_file()); + + let second = launcher.dry_run(&request).expect("二次 dry-run"); + let report = second.natives.expect("应有 natives 报告"); + assert!(report.up_to_date, "第二次应命中戳记:{report:?}"); + assert_eq!(report.skipped, 1); +} + +/// 缺 natives jar:适配层当成组件缺失(组装前显式失败),而不是等到 UnsatisfiedLinkError。 +#[test] +fn missing_native_jar_becomes_missing_component() { + let (mut request, ctx) = legacy_forge_request(); + request.natives = NativesPlan::new(vec![NativeJar::new( + "C:/mc/libraries/missing-natives-windows.jar", + "org.lwjgl:lwjgl:3.2.2:natives-windows", + )]); + // 直接验证适配层:给一个不存在的 natives jar,严格模式应产出 MissingNativeJar 告警。 + let libraries: Vec = request + .classpath + .iter() + .map(|path| LibraryInput::new(path.clone(), "x")) + .collect(); + let natives = request.natives.jars.clone(); + let snapshot = SnapshotInput { + version_id: "1.16.5", + main_class: Some("net.minecraft.client.main.Main"), + libraries: &libraries, + natives: &natives, + ..SnapshotInput::minimal("1.16.5", "net.minecraft.client.main.Main") + }; + let options = AdapterOptions { + java: JavaSelection::new("C:/java/bin/java.exe", 17, 64), + ..AdapterOptions::strict() + }; + let output = build_request(&snapshot, options); + assert!( + output + .warnings + .iter() + .any(|warning| matches!(warning, AdapterWarning::MissingNativeJar { .. })), + "应有 MissingNativeJar 告警:{:?}", + output.warnings + ); + assert_eq!(output.request.natives.era, NativesEra::Legacy); + // 组装按既有语义直接失败(missing_components 非空)。 + assert!(matches!( + assemble(&output.request, &ctx).err(), + Some(LaunchError::Assembly(AssemblyError::MissingComponents(_))) + )); +} diff --git a/crates/core/tests/launch/pipeline_cases.rs b/crates/core/tests/launch/pipeline_cases.rs new file mode 100644 index 0000000..b14020f --- /dev/null +++ b/crates/core/tests/launch/pipeline_cases.rs @@ -0,0 +1,347 @@ +//! 顶层启动管线测试:dry-run(D18)、完整会话、停止、组装失败、崩溃现场。 +//! +//! 真进程来自 shell(`cmd /C` 或 `sh -c`),所以这些是端到端用例: +//! 命令行组装 → 事件流落盘 → 退出判定 → 诊断挂钩 → 索引。 + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde_json::json; + +use crate::prelude::*; + +/// 记录调用并返回固定结论的诊断挂钩。 +#[derive(Default)] +struct RecordingHook { + calls: Mutex>, +} + +impl DiagnosisHook for RecordingHook { + fn analyze(&self, request: &DiagnosisRequest) -> Option { + self.calls.lock().unwrap().push(request.summary()); + Some(json!({ + "source": "rules", + "needs_more_info": false, + "candidates": [{ "id": "stub_candidate", "severity": "high", "confidence": 0.9 }], + })) + } +} + +fn launcher(sessions_root: &std::path::Path, hook: Option>) -> Launcher { + let mut config = LauncherConfig::new(sessions_root); + // 固定平台上下文,让命令行断言与运行平台无关。 + config.platform = PlatformCtx::new(OsFamily::Windows, "x86_64"); + config.process = ProcessConfig { + graceful_stop: Duration::from_millis(200), + ..ProcessConfig::default() + }; + let launcher = Launcher::new(config); + match hook { + Some(hook) => launcher.with_diagnosis_hook(hook), + None => launcher, + } +} + +/// 用 shell 冒充 java:只为验证管线,不验证 JVM 语义。 +fn shell_request(script: &str, run_dir: &std::path::Path) -> (LaunchRequest, LaunchRequest) { + let (mut request, ctx) = legacy_forge_request(); + let _ = ctx; + #[cfg(windows)] + { + request.wrapper = vec!["cmd".to_string(), "/C".to_string(), script.to_string()]; + } + #[cfg(not(windows))] + { + request.wrapper = vec!["/bin/sh".to_string(), "-c".to_string(), script.to_string()]; + } + request.java = JavaSelection::new("fake-java", 17, 64); + request.manifest.legacy_arguments = None; + request.dirs.game_dir = run_dir.to_path_buf(); + // natives 解压是管线第 6 段,会真写盘——指到临时目录,绝不碰 `C:/mc`。 + request.dirs.natives_dir = run_dir.join("natives"); + request.user.jvm = Vec::new(); + request.user.game = Vec::new(); + (request.clone(), request) +} + +fn echo_script() -> String { + // 命令里带上 access_token 明文,用来验证“落盘前脱敏”。 + #[cfg(windows)] + { + "echo LWJGL Version: 3.3.1 SECRET-TOKEN-123456".to_string() + } + #[cfg(not(windows))] + { + "echo 'LWJGL Version: 3.3.1 SECRET-TOKEN-123456'".to_string() + } +} + +fn long_running_script() -> String { + #[cfg(windows)] + { + "ping -n 30 127.0.0.1 >nul".to_string() + } + #[cfg(not(windows))] + { + "sleep 30".to_string() + } +} + +#[test] +fn dry_run_returns_command_without_spawning_or_writing() { + let sessions = temp_sessions_root("dry"); + let run_dir = temp_dir("dry-run"); + let launcher = launcher(&sessions, None); + let (request, _) = shell_request(&echo_script(), &run_dir); + + let dry: DryRun = launcher.dry_run(&request).expect("dry-run 应成功"); + assert!(!dry.argv.is_empty()); + assert!( + dry.display.contains("fake-java") + || dry.display.contains("cmd") + || dry.display.contains("/bin/sh") + ); + assert!( + dry.display.contains(""), + "命令行必须脱敏:{}", + dry.display + ); + assert!(!dry.display.contains("SECRET-TOKEN-123456")); + assert_eq!(dry.classpath_entries, 3); + assert_eq!(dry.line(), dry.display.as_str()); + // 不 spawn、不写会话文件。 + assert!(!sessions.exists(), "dry-run 不能创建会话目录"); +} + +#[test] +fn pipeline_records_session_events_diagnosis_and_index() { + let sessions = temp_sessions_root("pipeline"); + let run_dir = temp_dir("pipeline-run"); + let hook = Arc::new(RecordingHook::default()); + let launcher = launcher(&sessions, Some(hook.clone())); + let (request, _) = shell_request(&echo_script(), &run_dir); + + let handle: SessionHandle = launcher.launch(&request).expect("启动应成功"); + assert_eq!(handle.exit.exit_type, ExitType::Normal); + assert_eq!(handle.exit.code, 0); + // 存活 <30s → 静默失败 + 需要诊断。 + assert!(handle.exit.silent_failure.is_some()); + assert!(handle.needs_diagnosis()); + assert_eq!(hook.calls.lock().unwrap().len(), 1, "诊断挂钩应被调用一次"); + assert_eq!( + handle + .diagnostics + .as_ref() + .and_then(|value| value["candidates"][0]["id"].as_str()), + Some("stub_candidate") + ); + assert!( + handle.event_write_errors.is_empty(), + "{:?}", + handle.event_write_errors + ); + // 对外展示的 argv 已是脱敏版本(脚本里带着 token 明文)。 + assert!(handle + .redacted_argv() + .iter() + .any(|arg| arg.contains(""))); + assert!(!handle + .redacted_argv() + .iter() + .any(|arg| arg.contains("SECRET-TOKEN-123456"))); + assert!(handle.redacted_display().contains("")); + assert!(handle.index.is_some()); + assert!(handle.uptime_ms < 30_000); + + let events = read_events(&handle.log_path); + let kinds: Vec<&str> = events + .iter() + .filter_map(|event| event["kind"].as_str()) + .collect(); + for expected in [ + "session_start", + "cmd_built", + "proc_start", + "stdout", + "window_detected", + "exit", + "diagnosis", + "session_end", + ] { + assert!(kinds.contains(&expected), "缺少事件 {expected}:{kinds:?}"); + } + + // 日志行里的 access_token 必须已脱敏。 + let raw = std::fs::read_to_string(&handle.log_path).expect("读会话文件"); + assert!(!raw.contains("SECRET-TOKEN-123456"), "事件流不得含凭据明文"); + + // 索引落盘且字段正确。 + let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引"); + assert_eq!(index.len(), 1); + assert_eq!(index[0].session_id, handle.session_id); + assert!(index[0].silent_failure); + assert!(index[0].window_detected); + assert_eq!(index[0].exit_code, Some(0)); + assert_eq!(index[0].diagnosis_top.as_deref(), Some("stub_candidate")); +} + +#[test] +fn pipeline_stop_records_killed_exit() { + let sessions = temp_sessions_root("killed"); + let run_dir = temp_dir("killed-run"); + let launcher = launcher(&sessions, None); + let (request, _) = shell_request(&long_running_script(), &run_dir); + + let mut session = launcher.spawn(&request).expect("spawn 应成功"); + assert!(session.pid() > 0); + session.stop().expect("停止应成功"); + let handle = session.finish().expect("收尾应成功"); + + assert_eq!(handle.exit.exit_type, ExitType::Killed); + assert!(!handle.succeeded()); + let events = read_events(&handle.log_path); + let kinds: Vec<&str> = events + .iter() + .filter_map(|event| event["kind"].as_str()) + .collect(); + assert!(kinds.contains(&"killed"), "应记录 killed 事件:{kinds:?}"); + let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引"); + assert_eq!(index.len(), 1); + assert_eq!(index[0].exit_type.as_deref(), Some("killed")); +} + +#[test] +fn pipeline_assembly_failure_still_writes_evidence() { + let sessions = temp_sessions_root("assembly-fail"); + let run_dir = temp_dir("assembly-fail-run"); + let launcher = launcher(&sessions, None); + let (mut request, _) = shell_request(&echo_script(), &run_dir); + request.missing_components = vec![MissingComponent::named("org.lwjgl:lwjgl:3.3.1")]; + + let error = launcher.spawn(&request).expect_err("缺组件必须启动前失败"); + assert!(matches!( + error, + LaunchError::Assembly(AssemblyError::MissingComponents(_)) + )); + + let game_dir = sessions.join("game"); + let files: Vec = std::fs::read_dir(&game_dir) + .expect("会话目录应存在") + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().map(|ext| ext == "jsonl").unwrap_or(false)) + .filter(|path| { + path.file_stem() + .map(|stem| stem != "index") + .unwrap_or(false) + }) + .collect(); + assert_eq!(files.len(), 1, "应留下一个会话文件"); + let events = read_events(&files[0]); + let kinds: Vec<&str> = events + .iter() + .filter_map(|event| event["kind"].as_str()) + .collect(); + assert_eq!( + kinds, + vec![ + "session_start", + "natives_extracted", + "assembly_failed", + "session_end" + ] + ); + let missing = &events[2]["payload"]["missing_components"][0]["name"]; + assert_eq!(missing.as_str(), Some("org.lwjgl:lwjgl:3.3.1")); + + let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引"); + assert_eq!(index.len(), 1); + assert!(index[0].exit_type.is_none(), "没有进程就没有退出判定"); +} + +#[test] +fn pipeline_records_crash_report_artifact() { + let sessions = temp_sessions_root("crash"); + let run_dir = temp_dir("crash-run"); + let crash_dir = run_dir.join("crash-reports"); + std::fs::create_dir_all(&crash_dir).expect("建 crash-reports"); + std::fs::write( + crash_dir.join("crash-2026-10-29.txt"), + "---- Minecraft Crash Report ----\nDescription: Rendering screen\n", + ) + .expect("写崩溃报告"); + + let launcher = launcher(&sessions, None); + let (request, _) = shell_request(&echo_script(), &run_dir); + let handle = launcher.launch(&request).expect("启动应成功"); + + assert!( + handle + .artifacts + .iter() + .any(|artifact| artifact.source == CrashSource::CrashReport), + "应发现崩溃报告:{:?}", + handle.artifacts + ); + let events = read_events(&handle.log_path); + let crash = events + .iter() + .find(|event| event["kind"] == "crash_found") + .expect("应有 crash_found 事件"); + let path = crash["payload"]["path"].as_str().unwrap_or_default(); + assert!(path.ends_with("crash-2026-10-29.txt")); + assert!(crash["payload"]["excerpt"] + .as_str() + .unwrap_or_default() + .contains("Minecraft Crash Report")); +} + +#[test] +fn pipeline_spawn_failure_writes_session_end() { + let sessions = temp_sessions_root("spawn-fail"); + let run_dir = temp_dir("spawn-fail-run"); + let launcher = launcher(&sessions, None); + let (mut request, _) = shell_request(&echo_script(), &run_dir); + // 包装器就是 program:给一个不存在的可执行文件,走派生失败分支。 + request.wrapper = vec!["definitely-not-a-real-binary-ull".to_string()]; + + let error = launcher.spawn(&request).expect_err("程序不存在必须失败"); + assert!( + matches!(error, LaunchError::Spawn { .. }), + "错误类型:{error:?}" + ); + + let game_dir = sessions.join("game"); + let files: Vec = std::fs::read_dir(&game_dir) + .expect("会话目录应存在") + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().map(|ext| ext == "jsonl").unwrap_or(false)) + .filter(|path| { + path.file_stem() + .map(|stem| stem != "index") + .unwrap_or(false) + }) + .collect(); + assert_eq!(files.len(), 1, "应留下一个会话文件"); + let events = read_events(&files[0]); + let kinds: Vec<&str> = events + .iter() + .filter_map(|event| event["kind"].as_str()) + .collect(); + assert_eq!( + kinds, + vec![ + "session_start", + "natives_extracted", + "cmd_built", + "session_end" + ] + ); + assert_eq!(events[3]["payload"]["reason"], "spawn_failed"); + + let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引"); + assert_eq!(index.len(), 1, "派生失败也要进索引"); + assert!(index[0].exit_type.is_none()); +} diff --git a/crates/core/tests/launch/prelude.rs b/crates/core/tests/launch/prelude.rs new file mode 100644 index 0000000..a24d2b5 --- /dev/null +++ b/crates/core/tests/launch/prelude.rs @@ -0,0 +1,149 @@ +//! 测试共用设施。 +//! +//! **crate 名只在本文件出现**(tower 定案 `unlucky_core`;若 M11 改包名,只改这里)。 + +use std::path::PathBuf; + +pub use unlucky_core::launch::args_table::{assemble, AssembledCommand, AssemblyWarning}; +pub use unlucky_core::launch::argument::{ArgumentEntry, Rule}; +pub use unlucky_core::launch::command_builder::{self, CommandBuilder}; +pub use unlucky_core::launch::error::{AssemblyError, LaunchError, MissingComponent}; +pub use unlucky_core::launch::exit_analysis::{self, ExitType}; +pub use unlucky_core::launch::game_version::GameVersion; +pub use unlucky_core::launch::log_level::{self, Level}; +pub use unlucky_core::launch::platform::{OsFamily, PlatformCtx}; +pub use unlucky_core::launch::pump::{LineSink, LogLine, RingBuffer, RingBufferConfig, Stream}; +pub use unlucky_core::launch::redact::{Redactor, TOKEN_PLACEHOLDER}; +pub use unlucky_core::launch::request::*; +pub use unlucky_core::launch::session::{EndReason, SessionMeta, SessionSink, SessionWriter}; +pub use unlucky_core::launch::session_index; +pub use unlucky_core::launch::window_detect::WindowDetector; + +pub use unlucky_core::launch::adapter::{ + build_request, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput, +}; +pub use unlucky_core::launch::crash_scan::CrashSource; +pub use unlucky_core::launch::diagnosis_hook::{DiagnosisHook, DiagnosisRequest}; +pub use unlucky_core::launch::launch_session::SessionHandle; +pub use unlucky_core::launch::launcher::{DryRun, Launcher, LauncherConfig}; +pub use unlucky_core::launch::process::ProcessConfig; +pub use unlucky_core::launch::sink::{CompositeSink, CountingSink, FnSink}; + +/// 断言 argv 逐项相等,失败时打印全部实际值。 +pub fn assert_argv(command: &AssembledCommand, expected: &[&str]) { + let actual = command.argv(); + if actual.len() != expected.len() { + panic!( + "argv 长度 {} != 期望 {}\n实际:\n{:#?}", + actual.len(), + expected.len(), + actual + ); + } + for (index, (actual, expected)) in actual.iter().zip(expected.iter()).enumerate() { + assert_eq!(actual, expected, "第 {index} 个参数不同"); + } +} + +/// 一个“1.12.2 + Forge”的基准请求(Windows/x86_64)。 +pub fn legacy_forge_request() -> (LaunchRequest, PlatformCtx) { + let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64"); + let request = LaunchRequest { + instance: InstanceInfo { + id: "inst-1".to_string(), + name: "1.12.2-Forge".to_string(), + version: GameVersion::parse("1.12.2"), + version_type: "release".to_string(), + }, + launcher: LauncherInfo { + name: "UnluckyLauncher".to_string(), + version: "0.1.0".to_string(), + }, + java: JavaSelection::new("C:/java/bin/java.exe", 17, 64), + memory: MemoryOptions { + min_mb: Some(1024), + max_mb: Some(4096), + }, + manifest: ManifestArgs { + main_class: Some("net.minecraft.launchwrapper.Launch".to_string()), + ..ManifestArgs::default() + }, + dirs: LaunchDirs { + game_dir: PathBuf::from("C:/mc"), + assets_root: PathBuf::from("C:/mc/assets"), + game_assets: PathBuf::from("C:/mc/assets/virtual/legacy"), + assets_index: "1.12".to_string(), + natives_dir: PathBuf::from("C:/mc/versions/1.12.2/natives-windows-x86_64"), + library_dir: PathBuf::from("C:/mc/libraries"), + primary_jar: PathBuf::from("C:/mc/versions/1.12.2/1.12.2.jar"), + }, + classpath: vec![ + PathBuf::from("C:/mc/libraries/a.jar"), + PathBuf::from("C:/mc/libraries/b.jar"), + ], + auth: AuthPlaceholders { + player_name: "Steve".to_string(), + uuid: "0123456789abcdef0123456789abcdef".to_string(), + access_token: "SECRET-TOKEN-123456".to_string(), + user_type: "msa".to_string(), + user_properties: "{}".to_string(), + }, + log4j: Log4jOptions { + enabled: true, + config_file: Some(PathBuf::from("C:/mc/log4j2.xml")), + debug: false, + }, + // 大部分断言只关心“命令骨架”,优化参数会淹没差异;需要时单独打开。 + optimization: OptimizationOptions { + enabled: false, + physical_memory_mb: None, + }, + language: "zh-CN".to_string(), + features: FeatureSet { + has_custom_resolution: true, + ..FeatureSet::default() + }, + resolution: Some(Resolution { + width: 854, + height: 480, + }), + ..LaunchRequest::default() + }; + (request, ctx) +} + +/// 一个唯一的临时目录(不自动清理,便于失败时排查;OS 会回收)。 +pub fn temp_dir(name: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|value| value.as_nanos()) + .unwrap_or_default(); + let dir = + std::env::temp_dir().join(format!("ull-launch-{name}-{}-{nanos}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("创建临时目录"); + dir +} + +/// 一个唯一的临时会话根目录(测试结束不自动清理,便于失败时排查;OS 会回收)。 +pub fn temp_sessions_root(name: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|value| value.as_nanos()) + .unwrap_or_default(); + let root = std::env::temp_dir().join(format!( + "ull-launch-test-{name}-{}-{nanos}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + root +} + +/// 读回 jsonl 的每一行并解析成 JSON。 +pub fn read_events(path: &std::path::Path) -> Vec { + let text = std::fs::read_to_string(path).expect("读取会话文件"); + text.lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).expect("解析事件行")) + .collect() +} diff --git a/crates/core/tests/launch/process_smoke.rs b/crates/core/tests/launch/process_smoke.rs new file mode 100644 index 0000000..dc954b4 --- /dev/null +++ b/crates/core/tests/launch/process_smoke.rs @@ -0,0 +1,127 @@ +//! 进程冒烟测试:真实 spawn 一个 shell,验证泵、退出码、进程树 kill。 +//! +//! 这些用例跑的是真进程(不是 mock),是“事件流 + 退出分析 + 树 kill”端到端的最小证据。 + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use crate::prelude::*; + +use unlucky_core::launch::process::{self, ProcessConfig, RunningProcess}; +use unlucky_core::launch::pump::{LineSink, NullSink}; + +fn shell_command(script: &str) -> AssembledCommand { + #[cfg(windows)] + let (program, args) = ( + "cmd".to_string(), + vec!["/C".to_string(), script.to_string()], + ); + #[cfg(not(windows))] + let (program, args) = ( + "/bin/sh".to_string(), + vec!["-c".to_string(), script.to_string()], + ); + AssembledCommand { + program: PathBuf::from(program), + args, + classpath: Vec::new(), + encoding: "UTF-8".to_string(), + memory: MemoryOptions::default(), + env: Vec::new(), + warnings: Vec::new(), + main_class: String::new(), + } +} + +fn config() -> ProcessConfig { + ProcessConfig { + graceful_stop: Duration::from_millis(200), + ..ProcessConfig::default() + } +} + +#[test] +fn spawn_collects_both_streams_and_exit_code() { + let root = temp_sessions_root("smoke"); + let meta = SessionMeta::new("inst-1", "smoke", "1.20.1"); + let writer = + SessionWriter::create_with_id(&root, "smoke-1", meta, Redactor::new()).expect("创建会话"); + let path = writer.path().to_path_buf(); + let sink = Arc::new(SessionSink::new(writer)); + + let mut child = RunningProcess::spawn( + &shell_command("echo hello-from-stdout & echo hello-from-stderr 1>&2"), + None, + sink.clone(), + config(), + ) + .expect("spawn 应成功"); + + let status = child.wait().expect("wait 应成功"); + assert_eq!(process::exit_code(&status), 0); + assert!(child.pid() > 0); + + let verdict = child.supervise(); + assert!(verdict.is_complete(), "两条流都应在预算内结束"); + + let texts: Vec = child.lines().into_iter().map(|line| line.text).collect(); + assert!( + texts.iter().any(|text| text.contains("hello-from-stdout")), + "stdout 未收集到:{texts:?}" + ); + assert!( + texts.iter().any(|text| text.contains("hello-from-stderr")), + "stderr 未收集到:{texts:?}" + ); + + // 事件流里应有 stdout/stderr 行事件。 + let events = read_events(&path); + let stdout_lines = events + .iter() + .filter(|event| event["kind"] == "stdout") + .count(); + let stderr_lines = events + .iter() + .filter(|event| event["kind"] == "stderr") + .count(); + assert!(stdout_lines >= 1, "应有 stdout 事件"); + assert!(stderr_lines >= 1, "应有 stderr 事件"); +} + +#[test] +fn stop_kills_process_tree() { + #[cfg(windows)] + let script = "ping -n 30 127.0.0.1 >nul"; + #[cfg(not(windows))] + let script = "sleep 30"; + + let mut child = + RunningProcess::spawn(&shell_command(script), None, Arc::new(NullSink), config()) + .expect("spawn 应成功"); + assert!(child.is_running()); + + child.stop().expect("停止应成功"); + let status = child.wait().expect("wait 应成功"); + assert_ne!( + process::exit_code(&status), + 0, + "被结束的进程不应返回退出码 0" + ); + assert!(!child.is_running()); +} + +#[test] +fn spawn_reports_missing_program() { + let mut command = shell_command("echo hi"); + command.program = PathBuf::from("definitely-not-a-real-binary-ull"); + let error = RunningProcess::spawn(&command, None, Arc::new(NullSink), config()) + .expect_err("程序不存在必须报错"); + assert!(matches!(error, LaunchError::Spawn { .. })); +} + +#[test] +fn line_sink_trait_object_is_usable() { + let sink: Arc = Arc::new(NullSink); + sink.on_line(&LogLine::new(Stream::Stdout, "ignored")); +} diff --git a/crates/core/tests/launch/pump_buffer.rs b/crates/core/tests/launch/pump_buffer.rs new file mode 100644 index 0000000..9cd4523 --- /dev/null +++ b/crates/core/tests/launch/pump_buffer.rs @@ -0,0 +1,111 @@ +//! 环形缓冲 / 级别猜测 / 窗口检测。 + +use crate::prelude::*; + +fn line(stream: Stream, text: &str) -> LogLine { + LogLine::new(stream, text) +} + +#[test] +fn ring_buffer_respects_line_limit() { + let mut buffer = RingBuffer::new(RingBufferConfig { + max_lines: 3, + max_bytes: 1024 * 1024, + }); + for index in 0..5 { + buffer.push(line(Stream::Stdout, &format!("line-{index}"))); + } + assert_eq!(buffer.len(), 3); + let texts: Vec = buffer.lines().map(|item| item.text.clone()).collect(); + assert_eq!(texts, vec!["line-2", "line-3", "line-4"]); + assert_eq!(buffer.total_pushed(), 5); +} + +#[test] +fn ring_buffer_keeeps_key_lines_after_eviction() { + let mut buffer = RingBuffer::new(RingBufferConfig { + max_lines: 2, + max_bytes: 1024 * 1024, + }); + buffer.push(line( + Stream::Stderr, + "java.lang.ExceptionInInitializerError", + )); + buffer.push(line(Stream::Stdout, "noise-1")); + buffer.push(line(Stream::Stdout, "noise-2")); + buffer.push(line(Stream::Stdout, "noise-3")); + + let texts: Vec = buffer.lines().map(|item| item.text.clone()).collect(); + assert!(texts + .iter() + .any(|text| text.contains("ExceptionInInitializer"))); + assert!(texts.contains(&"noise-3".to_string())); + assert!(!texts.contains(&"noise-1".to_string())); + assert_eq!(buffer.error_lines().len(), 1); +} + +#[test] +fn ring_buffer_respects_byte_limit() { + let mut buffer = RingBuffer::new(RingBufferConfig { + max_lines: 1000, + max_bytes: 32, + }); + buffer.push(line(Stream::Stdout, "0123456789")); + buffer.push(line(Stream::Stdout, "abcdefghij")); + buffer.push(line(Stream::Stdout, "ABCDEFGHIJ")); + assert!(buffer.len() < 4, "字节上限应触发淘汰"); + assert!(buffer.text().contains("ABCDEFGHIJ")); +} + +#[test] +fn level_guessing_matches_hmcl_rules() { + assert_eq!( + log_level::guess(false, "[12:34:56] [Render thread/ERROR]: boom"), + Level::Error + ); + assert_eq!( + log_level::guess(false, "[12:34:56] [main/INFO]: hi"), + Level::Info + ); + assert_eq!( + log_level::guess(false, "[12:34:56] [Worker-Main-1/WARN] [mixin/]: careful"), + Level::Warn + ); + assert_eq!(log_level::guess(false, "[SEVERE] java error"), Level::Error); + assert_eq!(log_level::guess(false, "plain text"), Level::Unknown); + // stderr 一律当错误,authlib-injector 例外。 + assert_eq!(log_level::guess(true, "anything"), Level::Error); + assert_eq!( + log_level::guess(true, "[authlib-injector] loaded"), + Level::Info + ); +} + +#[test] +fn line_level_uses_stream_and_text() { + assert_eq!(line(Stream::Stderr, "boom").level(), Level::Error); + assert_eq!( + line(Stream::Stdout, "[12:34:56] [main/DEBUG]: detail").level(), + Level::Debug + ); +} + +#[test] +fn window_detector_reports_heuristic_once() { + let mut detector = WindowDetector::new(); + assert!(detector + .observe("[12:00:00] [main/INFO]: Loading LWJGL") + .is_none()); + let detection = detector + .observe("[12:00:01] [Render thread/INFO]: LWJGL Version: 3.3.1") + .expect("应命中 lwjgl version"); + assert_eq!(detection.heuristic, "lwjgl_version"); + assert!(detection.evidence.contains("LWJGL Version")); + assert!(detector.is_detected()); + // 只报一次。 + assert!(detector.observe("LWJGL Version: 3.3.1").is_none()); + assert_eq!( + detector.detection().map(|d| d.heuristic.as_str()), + Some("lwjgl_version") + ); +} diff --git a/crates/core/tests/launch/session_events.rs b/crates/core/tests/launch/session_events.rs new file mode 100644 index 0000000..b6ffab4 --- /dev/null +++ b/crates/core/tests/launch/session_events.rs @@ -0,0 +1,257 @@ +//! 会话事件流(JSONL)与索引测试。 +//! +//! 校验 `DevDocs/SubAgentsTalk/launch-接口约定.md` 的硬约定: +//! `seq` 单调、写前脱敏、索引同 id 取最后一行。 + +use std::sync::Arc; + +use serde_json::json; +use unlucky_core::launch::pump::LineSink; + +use crate::prelude::*; + +fn command_with_token() -> AssembledCommand { + let (mut request, ctx) = legacy_forge_request(); + request.manifest.legacy_arguments = + Some("--accessToken ${auth_session} --username ${auth_player_name}".to_string()); + assemble(&request, &ctx).expect("组装应成功") +} + +#[test] +fn session_stream_records_all_event_kinds() { + let root = temp_sessions_root("events"); + let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64"); + let meta = SessionMeta::new("inst-1", "1.12.2-Forge", "1.12.2") + .with_launcher("UnluckyLauncher", "0.1.0") + .with_platform(&ctx); + let redactor = Redactor::new().with_secret("SECRET-TOKEN-123456"); + let mut writer = SessionWriter::create_with_id(&root, "20261029T120000-abcdef", meta, redactor) + .expect("创建会话"); + + writer.session_start().expect("session_start"); + writer.cmd_built(&command_with_token()).expect("cmd_built"); + writer.proc_start(4242).expect("proc_start"); + writer + .line(&LogLine::new(Stream::Stdout, "LWJGL Version: 3.3.1")) + .expect("stdout"); + writer + .line(&LogLine::new( + Stream::Stderr, + "java.lang.IllegalStateException: boom", + )) + .expect("stderr"); + writer + .window_detected(&unlucky_core::launch::window_detect::Detection { + heuristic: "lwjgl_version".to_string(), + took_ms: 820, + evidence: "LWJGL Version: 3.3.1".to_string(), + }) + .expect("window_detected"); + + let lines = vec![LogLine::new(Stream::Stderr, "java.lang.Exception: boom")]; + let analysis = exit_analysis::analyze(&exit_analysis::ExitSignals::new(1, 40_000, &lines)); + writer.exit(&analysis).expect("exit"); + writer + .diagnosis(json!({ + "source": "rules", + "needs_more_info": false, + "candidates": [{ "id": "mixin_apply_failed", "confidence": 0.9 }], + })) + .expect("diagnosis"); + + let entry = writer.finish(EndReason::Exit).expect("session_end"); + assert!(writer.is_finished()); + assert_eq!(entry.session_id, "20261029T120000-abcdef"); + assert_eq!(entry.exit_code, Some(1)); + assert_eq!(entry.exit_type.as_deref(), Some("application_error")); + assert!(entry.window_detected); + assert_eq!(entry.diagnosis_top.as_deref(), Some("mixin_apply_failed")); + assert_eq!(entry.log_path, "game/20261029T120000-abcdef.jsonl"); + + let events = read_events(writer.path()); + let kinds: Vec<&str> = events + .iter() + .filter_map(|event| event.get("kind").and_then(|value| value.as_str())) + .collect(); + assert_eq!( + kinds, + vec![ + "session_start", + "cmd_built", + "proc_start", + "stdout", + "stderr", + "window_detected", + "exit", + "diagnosis", + "session_end", + ] + ); + + // seq 从 0 单调递增,v/ts/t_ms/session_id 齐全。 + for (index, event) in events.iter().enumerate() { + assert_eq!(event["v"], 1); + assert_eq!(event["seq"], index as u64); + assert_eq!(event["session_id"], "20261029T120000-abcdef"); + assert!(event["ts"].as_str().unwrap_or_default().ends_with('Z')); + assert!(event["t_ms"].is_u64()); + } + assert_eq!(entry.event_count, events.len() as u64); + + // 索引落盘且可读回。 + let index = session_index::index_path(&root); + let listed = session_index::read_all(&index).expect("读索引"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].session_id, entry.session_id); +} + +#[test] +fn cmd_built_argv_is_redacted_before_writing() { + let root = temp_sessions_root("redact"); + let meta = SessionMeta::new("inst-1", "1.12.2-Forge", "1.12.2"); + let redactor = Redactor::new().with_secret("SECRET-TOKEN-123456"); + let mut writer = + SessionWriter::create_with_id(&root, "redact-1", meta, redactor).expect("创建会话"); + writer.cmd_built(&command_with_token()).expect("cmd_built"); + + let raw = std::fs::read_to_string(writer.path()).expect("读文件"); + assert!(!raw.contains("SECRET-TOKEN-123456"), "命令行必须脱敏后落盘"); + assert!(raw.contains(TOKEN_PLACEHOLDER)); + + let events = read_events(writer.path()); + let argv = events[0]["payload"]["argv"] + .as_array() + .cloned() + .unwrap_or_default(); + assert!(argv + .iter() + .any(|arg| arg.as_str() == Some(TOKEN_PLACEHOLDER))); +} + +#[test] +fn diagnosis_payload_is_redacted_before_writing() { + let root = temp_sessions_root("diagnosis-redact"); + let meta = SessionMeta::new("inst-1", "1.20.1", "1.20.1"); + let redactor = Redactor::new() + .with_secret("SECRET-TOKEN-123456") + .with_home_str("C:/Users/aska"); + let mut writer = + SessionWriter::create_with_id(&root, "diag-redact-1", meta, redactor).expect("创建会话"); + writer + .diagnosis(json!({ + "source": "rules", + "needs_more_info": false, + "candidates": [{ + "id": "mixin_failed", + "confidence": 0.9, + "message_key": "launch.crash.reason.mixin_failed", + "evidence": [ + "hs_err:C:/Users/aska/.minecraft/hs_err_pid123.log: java.lang.Exception", + "token=SECRET-TOKEN-123456", + ], + }], + })) + .expect("写 diagnosis"); + + let raw = std::fs::read_to_string(writer.path()).expect("读文件"); + assert!(!raw.contains("C:/Users/aska"), "诊断证据里的主目录必须脱敏"); + assert!( + !raw.contains("SECRET-TOKEN-123456"), + "诊断证据里的凭据必须脱敏" + ); + assert!(raw.contains("")); + assert!(raw.contains(""), "同一个值内也允许混合脱敏"); + + // 字段名/数值/布尔原样保留(launch 只脱敏、不改写字段)。 + let events = read_events(writer.path()); + let payload = &events[0]["payload"]; + assert_eq!(payload["needs_more_info"], json!(false)); + assert_eq!(payload["candidates"][0]["id"], json!("mixin_failed")); + assert_eq!(payload["candidates"][0]["confidence"], json!(0.9)); + assert_eq!( + payload["candidates"][0]["message_key"], + json!("launch.crash.reason.mixin_failed") + ); + assert_eq!(payload["source"], json!("rules")); + // diagnosis_top 仍能从脱敏后的 candidates 取出。 + assert_eq!( + writer + .finish(EndReason::Exit) + .expect("结束会话") + .diagnosis_top + .as_deref(), + Some("mixin_failed") + ); +} + +#[test] +fn index_keeps_last_line_per_session_id() { + let root = temp_sessions_root("index"); + let mut entry = session_index::SessionIndexEntry { + v: 1, + session_id: "same-id".to_string(), + instance_id: "inst-1".to_string(), + instance_name: "one".to_string(), + game_version: "1.20.1".to_string(), + started_at: "2026-10-29T12:00:00.000Z".to_string(), + ended_at: "2026-10-29T12:01:00.000Z".to_string(), + duration_ms: 60_000, + exit_code: Some(0), + exit_type: Some("normal".to_string()), + ..session_index::SessionIndexEntry::default() + }; + let index = session_index::index_path(&root); + session_index::append(&index, &entry).expect("第一次写索引"); + + // 崩溃重启后重写同一会话的索引行。 + entry.exit_code = Some(1); + entry.exit_type = Some("application_error".to_string()); + entry.duration_ms = 30_000; + session_index::append(&index, &entry).expect("第二次写索引"); + + let listed = session_index::read_all(&index).expect("读索引"); + assert_eq!(listed.len(), 1, "同 id 只保留一条"); + assert_eq!(listed[0].exit_code, Some(1)); + assert_eq!(listed[0].duration_ms, 30_000); + + // 追加另一实例的会话,按实例过滤生效。 + let mut other = listed[0].clone(); + other.session_id = "other-id".to_string(); + other.instance_id = "inst-2".to_string(); + other.started_at = "2026-10-30T12:00:00.000Z".to_string(); + session_index::append(&index, &other).expect("第三次写索引"); + let only = session_index::list(&index, 10, Some("inst-2")).expect("过滤"); + assert_eq!(only.len(), 1); + assert_eq!(only[0].session_id, "other-id"); + // 倒序:最新的在前。 + let all = session_index::list(&index, 10, None).expect("全部"); + assert_eq!( + all.first().map(|item| item.session_id.as_str()), + Some("other-id") + ); +} + +#[test] +fn session_sink_writes_lines_and_detects_window() { + let root = temp_sessions_root("sink"); + let meta = SessionMeta::new("inst-1", "1.20.1", "1.20.1"); + let writer = + SessionWriter::create_with_id(&root, "sink-1", meta, Redactor::new()).expect("创建会话"); + let path = writer.path().to_path_buf(); + let sink = Arc::new(SessionSink::new(writer)); + + sink.on_line(&LogLine::new(Stream::Stdout, "preparing")); + sink.on_line(&LogLine::new(Stream::Stdout, "LWJGL Version: 3.3.1")); + sink.on_line(&LogLine::new(Stream::Stderr, "warn: something")); + + let kinds: Vec = read_events(&path) + .iter() + .filter_map(|event| event["kind"].as_str().map(str::to_string)) + .collect(); + assert!( + kinds.contains(&"window_detected".to_string()), + "sink 应自动检测窗口" + ); + assert_eq!(kinds.iter().filter(|kind| *kind == "stdout").count(), 2); + assert_eq!(kinds.iter().filter(|kind| *kind == "stderr").count(), 1); +} diff --git a/crates/core/tests/launch/sink_cases.rs b/crates/core/tests/launch/sink_cases.rs new file mode 100644 index 0000000..62887bf --- /dev/null +++ b/crates/core/tests/launch/sink_cases.rs @@ -0,0 +1,73 @@ +//! sink 组合测试(复合 sink 是接 M15 采集器 / UI 日志页的接缝)。 + +use std::sync::{Arc, Mutex}; + +use crate::prelude::*; + +fn line(stream: Stream, text: &str) -> LogLine { + LogLine::new(stream, text) +} + +#[test] +fn composite_sink_broadcasts_in_registration_order() { + let order = Arc::new(Mutex::new(Vec::::new())); + let first = { + let order = order.clone(); + FnSink::new(move |line: &LogLine| { + order.lock().unwrap().push(format!("first:{}", line.text)); + }) + }; + let second = { + let order = order.clone(); + FnSink::new(move |line: &LogLine| { + order.lock().unwrap().push(format!("second:{}", line.text)); + }) + }; + let composite = CompositeSink::new() + .with(first.shared()) + .with(second.shared()); + assert_eq!(composite.len(), 2); + composite.on_line(&line(Stream::Stdout, "hello")); + + let recorded = order.lock().unwrap().clone(); + assert_eq!(recorded, vec!["first:hello", "second:hello"]); +} + +#[test] +fn composite_sink_passes_stream_and_text() { + let seen = Arc::new(Mutex::new(Vec::<(Stream, String)>::new())); + let recorder = { + let seen = seen.clone(); + FnSink::new(move |line: &LogLine| { + seen.lock().unwrap().push((line.stream, line.text.clone())); + }) + }; + // 模拟 diagnose 侧:只记文本 + 流标记(不解析 jsonl、不 import launch 类型)。 + let mut composite = CompositeSink::new(); + composite.push(recorder.shared()); + composite.on_line(&line(Stream::Stdout, "out")); + composite.on_line(&line(Stream::Stderr, "err")); + + let seen = seen.lock().unwrap().clone(); + assert_eq!(seen.len(), 2); + assert_eq!(seen[0], (Stream::Stdout, "out".to_string())); + assert_eq!(seen[1], (Stream::Stderr, "err".to_string())); +} + +#[test] +fn counting_sink_counts_per_stream() { + let counter = Arc::new(CountingSink::new()); + counter.on_line(&line(Stream::Stdout, "a")); + counter.on_line(&line(Stream::Stdout, "b")); + counter.on_line(&line(Stream::Stderr, "c")); + assert_eq!(counter.stdout_lines(), 2); + assert_eq!(counter.stderr_lines(), 1); + assert_eq!(counter.total_lines(), 3); +} + +#[test] +fn empty_composite_is_harmless() { + let composite = CompositeSink::new(); + assert!(composite.is_empty()); + composite.on_line(&line(Stream::Stdout, "ignored")); +} diff --git a/crates/core/tests/platform/linux_caps_cases.rs b/crates/core/tests/platform/linux_caps_cases.rs new file mode 100644 index 0000000..9bab8f9 --- /dev/null +++ b/crates/core/tests/platform/linux_caps_cases.rs @@ -0,0 +1,136 @@ +//! Linux 探测:`/etc/os-release`、内核 release、会话类型、glibc 版本。 + +use std::collections::HashMap; + +use crate::prelude::*; + +const UBUNTU_OS_RELEASE: &str = "\ +PRETTY_NAME=\"Ubuntu 24.04.1 LTS\" +NAME=\"Ubuntu\" +VERSION_ID=\"24.04\" +ID=ubuntu +"; + +#[test] +fn os_release_prefers_pretty_name() { + assert_eq!( + linux_caps::parse_os_release(UBUNTU_OS_RELEASE), + Some("Ubuntu 24.04.1 LTS".to_string()) + ); + // 没有 PRETTY_NAME 的裁剪发行版:NAME + VERSION_ID 拼一个(不够精确但够日志用)。 + let minimal = "NAME=\"Alpine Linux\"\nVERSION_ID=3.19\n"; + assert_eq!( + linux_caps::parse_os_release(minimal), + Some("Alpine Linux 3.19".to_string()) + ); + assert_eq!(linux_caps::parse_os_release(""), None); + assert_eq!(linux_caps::parse_os_release("ID=arch\n"), None); +} + +#[test] +fn kernel_release_yields_major_minor() { + assert_eq!( + linux_caps::parse_kernel_version("6.8.0-45-generic"), + Some((6, 8)) + ); + assert_eq!(linux_caps::parse_kernel_version("5.15.0"), Some((5, 15))); + assert_eq!(linux_caps::parse_kernel_version("3.2"), Some((3, 2))); + assert_eq!(linux_caps::parse_kernel_version("6.8"), Some((6, 8))); + assert_eq!(linux_caps::parse_kernel_version(""), None); + assert_eq!(linux_caps::parse_kernel_version("not-a-version"), None); +} + +#[test] +fn session_detection_order_is_wayland_then_x11_then_headless() { + fn detect(pairs: &[(&str, &str)]) -> SessionKind { + let map: HashMap = pairs + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect(); + linux_caps::session_from_env(|key| map.get(key).cloned()) + } + + assert_eq!( + detect(&[("WAYLAND_DISPLAY", "wayland-0")]), + SessionKind::Wayland + ); + assert_eq!( + detect(&[("XDG_SESSION_TYPE", "wayland"), ("DISPLAY", ":0")]), + SessionKind::Wayland + ); + assert_eq!( + detect(&[("XDG_SESSION_TYPE", "x11"), ("DISPLAY", ":0")]), + SessionKind::X11 + ); + assert_eq!(detect(&[("DISPLAY", ":0")]), SessionKind::X11); + // 空值不算有会话(SSH 转发里 `WAYLAND_DISPLAY=` 是常见的空串形态)。 + assert_eq!( + detect(&[("WAYLAND_DISPLAY", " "), ("DISPLAY", ":0")]), + SessionKind::X11 + ); + assert_eq!(detect(&[]), SessionKind::Headless); +} + +#[test] +fn session_kind_helpers() { + assert!(SessionKind::Wayland.has_display()); + assert!(SessionKind::X11.has_display()); + assert!(!SessionKind::Headless.has_display()); + assert!(!SessionKind::Unknown.has_display()); + assert_eq!(SessionKind::Wayland.as_str(), "wayland"); + assert_eq!(SessionKind::Headless.as_str(), "headless"); + assert_eq!(GlProbe::Unknown.as_str(), "unknown"); + assert_eq!(GlProbe::Missing.as_str(), "missing"); +} + +#[test] +fn glibc_version_comes_from_the_first_ldd_line() { + assert_eq!( + linux_caps::parse_glibc_version( + "ldd (Ubuntu GLIBC 2.39-0ubuntu8.3) 2.39\nCopyright (C) 2024\n" + ), + Some((2, 39)) + ); + assert_eq!( + linux_caps::parse_glibc_version("ldd (GNU libc) 2.31"), + Some((2, 31)) + ); + assert_eq!( + linux_caps::parse_glibc_version("ldd (Debian GLIBC 2.36-9+deb12u4) 2.36"), + Some((2, 36)) + ); + // musl / 命令不存在:不能瞎猜,交给上层「不阻塞」。 + assert_eq!(linux_caps::parse_glibc_version("ldd: not found"), None); + assert_eq!(linux_caps::parse_glibc_version(""), None); +} + +#[test] +fn format_version_is_dotted() { + assert_eq!(linux_caps::format_version((2, 31)), "2.31"); + assert_eq!(linux_caps::format_version((6, 8)), "6.8"); +} + +#[test] +fn gl_probe_is_a_hook_returning_unknown_for_now() { + // P1 只留钩子:真探测要 dlopen(FFI),core 禁 unsafe,故恒为 Unknown 且不阻塞。 + assert_eq!(linux_caps::probe_gl(), GlProbe::Unknown); +} + +#[test] +fn caps_probe_matches_the_host() { + let caps: LinuxCaps = linux_caps::probe(); + if cfg!(target_os = "linux") { + // 容器里可能全空,但会话类型至少是「探过了」。 + assert_ne!(caps.session, SessionKind::Unknown); + if let Some(kernel) = &caps.kernel { + assert!( + caps.kernel_version.is_some(), + "内核串 {kernel} 应能取 major.minor" + ); + } + } else { + assert_eq!(caps.session, SessionKind::Unknown); + assert!(caps.kernel.is_none()); + assert!(caps.distro.is_none()); + } +} diff --git a/crates/core/tests/platform/macos_cases.rs b/crates/core/tests/platform/macos_cases.rs new file mode 100644 index 0000000..f645fb3 --- /dev/null +++ b/crates/core/tests/platform/macos_cases.rs @@ -0,0 +1,58 @@ +//! macOS 探测:`sw_vers -productVersion` 与 `sysctl hw.optional.arm64`。 + +use crate::prelude::*; + +#[test] +fn product_version_parses_sw_vers_output() { + assert_eq!( + macos_version::parse_product_version("12.3.1\n"), + Some((12, 3, 1)) + ); + assert_eq!( + macos_version::parse_product_version("12.3"), + Some((12, 3, 0)) + ); + assert_eq!(macos_version::parse_product_version("12"), Some((12, 0, 0))); + assert_eq!( + macos_version::parse_product_version(" 14.2.1 "), + Some((14, 2, 1)) + ); + assert_eq!(macos_version::parse_product_version(""), None); + assert_eq!(macos_version::parse_product_version("ProductVersion"), None); +} + +#[test] +fn sysctl_arm64_flag() { + assert!(macos_version::parse_sysctl_bool("1\n")); + assert!(!macos_version::parse_sysctl_bool("0\n")); + // Intel 机器上 sysctl 以非零码退出 → stdout 为空 → 不是 arm64。 + assert!(!macos_version::parse_sysctl_bool("")); +} + +#[test] +fn version_strings_and_runtime_arch() { + let apple_silicon = MacOsVersion::new(12, 3, 1, true); + assert_eq!(apple_silicon.version_string(), "12.3.1"); + assert_eq!(apple_silicon.runtime_arch(), "arm64"); + assert_eq!(apple_silicon.display_name(), "macOS 12.3.1 (arm64)"); + + // Rosetta 2 下编译目标是 x86_64,但真实架构仍是 arm64: + // 探到 arm64 时 runtime_arch 必须是 arm64(MC 原生库与渲染路径都按它选)。 + let rosetta = MacOsVersion::new(12, 3, 1, true); + assert_eq!(rosetta.runtime_arch(), "arm64"); + + let intel = MacOsVersion::new(15, 0, 0, false); + assert_eq!(intel.runtime_arch(), "x86_64"); + assert_eq!(intel.display_name(), "macOS 15.0.0 (x86_64)"); +} + +#[test] +fn probe_matches_the_host() { + let probed = macos_version::probe(); + if cfg!(target_os = "macos") { + let version = probed.expect("macOS 宿主必须能探到产品版本"); + assert!(version.major >= 10, "探到 {version:?}"); + } else { + assert!(probed.is_none(), "非 macOS 平台不应探到 macOS 版本"); + } +} diff --git a/crates/core/tests/platform/main.rs b/crates/core/tests/platform/main.rs new file mode 100644 index 0000000..06767ae --- /dev/null +++ b/crates/core/tests/platform/main.rs @@ -0,0 +1,17 @@ +//! platform 模块集成测试入口(`cargo test -p unlucky-core --test platform`)。 +//! +//! 覆盖 D19(`PlanDoc/11-os-detection.md`)的运行期探测: +//! - 等级映射 / 文案:注入式假数据,不改机器真实版本; +//! - 各平台探测的**解析**(Windows 注册表与 `ver`、Linux `/etc/os-release`+内核+glibc、 +//! macOS `sw_vers`+`sysctl`):解析函数跨平台编译,所以能在任意宿主上单测; +//! - `rules.os.version` 用真实探测值求值的接线; +//! - 系统主题探测(`theme_probe`:注册表 / `defaults` / `gsettings` 解析 + 三态契约)。 + +mod linux_caps_cases; +mod macos_cases; +mod os_info_cases; +mod prelude; +mod rules_wiring; +mod theme_cases; +mod vk_cases; +mod windows_version_cases; diff --git a/crates/core/tests/platform/os_info_cases.rs b/crates/core/tests/platform/os_info_cases.rs new file mode 100644 index 0000000..ab5e107 --- /dev/null +++ b/crates/core/tests/platform/os_info_cases.rs @@ -0,0 +1,299 @@ +//! 支持等级映射与双语文案:全部用注入式假数据,不改机器真实版本。 +//! +//! 基线(用户拍板):Windows 10+ / macOS 12+ / Linux 达标 → 一等,其余一律不支持; +//! Win8.1/8/7 也不承诺 CLI,所以没有中间档。 + +use crate::prelude::*; + +fn windows_info(major_minor: Option<(u32, u32)>) -> OsInfo { + let product = match major_minor { + Some(version) if version >= os_info::MIN_WINDOWS => "Windows 10", + Some(_) => "Windows 7", + None => "Windows", + }; + OsInfo { + family: OsFamily::Windows, + arch: "x86_64".to_string(), + major_minor, + build: Some(19_045), + product: product.to_string(), + version_string: "10.0.19045".to_string(), + session: SessionKind::Unknown, + gl: GlProbe::Unknown, + vk: VkProbe::Unknown, + kernel: None, + glibc: None, + support_level: os_info::support_level_for(OsFamily::Windows, major_minor, None, None), + } +} + +fn linux_info(kernel: Option<(u32, u32)>, glibc: Option<(u32, u32)>) -> OsInfo { + OsInfo { + family: OsFamily::Linux, + arch: "x86_64".to_string(), + major_minor: kernel, + build: None, + product: "Ubuntu 24.04.1 LTS".to_string(), + version_string: "6.8.0-45-generic".to_string(), + session: SessionKind::Wayland, + gl: GlProbe::Unknown, + vk: VkProbe::Unknown, + kernel, + glibc, + support_level: os_info::support_level_for(OsFamily::Linux, kernel, kernel, glibc), + } +} + +#[test] +fn windows_levels_require_windows_10() { + // Win10 / Win11(都报 10.0,靠 build 区分产品名)→ 一等。 + assert_eq!(os_info::windows_level(Some((10, 0))), SupportLevel::One); + // Win8.1 / Win8 / Win7:低于基线,GUI 与 CLI 都不承诺。 + assert_eq!( + os_info::windows_level(Some((6, 3))), + SupportLevel::Unsupported + ); + assert_eq!( + os_info::windows_level(Some((6, 2))), + SupportLevel::Unsupported + ); + assert_eq!( + os_info::windows_level(Some((6, 1))), + SupportLevel::Unsupported + ); + // Vista / XP。 + assert_eq!( + os_info::windows_level(Some((6, 0))), + SupportLevel::Unsupported + ); + assert_eq!( + os_info::windows_level(Some((5, 1))), + SupportLevel::Unsupported + ); + // 探测失败 → 保守(无法确认 Win10+ 就不放行)。 + assert_eq!(os_info::windows_level(None), SupportLevel::Unsupported); +} + +#[test] +fn macos_levels_use_twelve_as_the_floor() { + assert_eq!(os_info::macos_level(Some(12)), SupportLevel::One); + assert_eq!(os_info::macos_level(Some(15)), SupportLevel::One); + assert_eq!(os_info::macos_level(Some(11)), SupportLevel::Unsupported); + assert_eq!(os_info::macos_level(None), SupportLevel::Unsupported); +} + +#[test] +fn linux_levels_gate_on_kernel_and_glibc() { + assert_eq!( + os_info::linux_level(Some((6, 8)), Some((2, 39))), + SupportLevel::One + ); + assert_eq!( + os_info::linux_level(Some((2, 6)), Some((2, 39))), + SupportLevel::Unsupported + ); + assert_eq!( + os_info::linux_level(Some((6, 8)), Some((2, 16))), + SupportLevel::Unsupported + ); + // musl / Termux / 容器里取不到 glibc 与内核 → 不阻塞。 + assert_eq!(os_info::linux_level(None, None), SupportLevel::One); + assert_eq!(os_info::linux_level(Some((3, 2)), None), SupportLevel::One); + // 恰好等于下限也算支持。 + assert_eq!( + os_info::linux_level(Some((6, 8)), Some(os_info::MIN_GLIBC)), + SupportLevel::One + ); +} + +#[test] +fn support_level_for_dispatches_by_family() { + assert_eq!( + os_info::support_level_for(OsFamily::Windows, Some((10, 0)), None, None), + SupportLevel::One + ); + assert_eq!( + os_info::support_level_for(OsFamily::Windows, Some((6, 1)), None, None), + SupportLevel::Unsupported + ); + assert_eq!( + os_info::support_level_for(OsFamily::MacOs, Some((12, 3)), None, None), + SupportLevel::One + ); + assert_eq!( + os_info::support_level_for(OsFamily::MacOs, Some((11, 7)), None, None), + SupportLevel::Unsupported + ); + // Linux 的 major_minor 不参与判定,看 kernel/glibc。 + assert_eq!( + os_info::support_level_for(OsFamily::Linux, Some((10, 0)), Some((2, 6)), None), + SupportLevel::Unsupported + ); + assert_eq!( + os_info::support_level_for(OsFamily::Linux, None, Some((6, 8)), Some((2, 39))), + SupportLevel::One + ); +} + +#[test] +fn windows_7_is_reported_as_unsupported_with_the_new_baseline() { + let info = windows_info(Some((6, 1))); + assert_eq!(info.support_level, SupportLevel::Unsupported); + let text = info.describe(); + assert!(text.zh.contains("不支持"), "zh: {}", text.zh); + assert!(text.zh.contains("Windows 10"), "zh: {}", text.zh); + // 不再有「受限模式 / HeadlessOnly」文案。 + assert!(!text.zh.contains("受限模式"), "zh: {}", text.zh); + assert!(text.en.contains("Unsupported"), "en: {}", text.en); + assert!(text.en.contains("Windows 10 or newer"), "en: {}", text.en); + // combined() 是 CLI 打印形态:中文在前、英文在后。 + let combined = text.combined(); + let (zh, en) = combined.split_once('\n').expect("两行"); + assert!(zh.contains("不支持")); + assert!(en.contains("Unsupported")); +} + +#[test] +fn unsupported_macos_and_linux_name_their_own_minimums() { + let mac = OsInfo { + family: OsFamily::MacOs, + arch: "arm64".to_string(), + major_minor: Some((11, 7)), + build: None, + product: "macOS 11.7.0 (arm64)".to_string(), + version_string: "11.7.0".to_string(), + session: SessionKind::Unknown, + gl: GlProbe::Unknown, + vk: VkProbe::Unknown, + kernel: None, + glibc: None, + support_level: SupportLevel::Unsupported, + }; + let text = mac.describe(); + assert!(text.zh.contains("macOS 12"), "zh: {}", text.zh); + assert!(text.en.contains("macOS 12"), "en: {}", text.en); + assert!(!text.zh.contains("Windows"), "zh: {}", text.zh); + + let linux = linux_info(Some((2, 6)), None); + assert_eq!(linux.support_level, SupportLevel::Unsupported); + let text = linux.describe(); + assert!(text.zh.contains("内核"), "zh: {}", text.zh); + assert!(text.en.contains("kernel"), "en: {}", text.en); +} + +#[test] +fn unknown_windows_version_is_unsupported_and_says_so() { + let info = windows_info(None); + assert_eq!(info.support_level, SupportLevel::Unsupported); + let text = info.describe(); + assert!(text.zh.contains("未能探测到系统版本"), "zh: {}", text.zh); + assert!(text.zh.contains("Windows 10"), "zh: {}", text.zh); +} + +#[test] +fn supported_linux_without_display_adds_a_hint() { + let mut info = linux_info(Some((6, 8)), Some((2, 39))); + info.session = SessionKind::Headless; + let text = info.describe(); + assert!(text.zh.contains("图形会话"), "zh: {}", text.zh); + assert!(text.en.contains("graphical session"), "en: {}", text.en); + + // 有显示服务器时不加提示。 + let text = linux_info(Some((6, 8)), Some((2, 39))).describe(); + assert!(!text.zh.contains("图形会话"), "zh: {}", text.zh); +} + +#[test] +fn summary_line_carries_the_diagnostic_fields() { + let line = linux_info(Some((6, 8)), Some((2, 39))).summary_line(); + assert!(line.contains("os=linux"), "{line}"); + assert!(line.contains("version=6.8.0-45-generic"), "{line}"); + assert!(line.contains("arch=x86_64"), "{line}"); + assert!(line.contains("level=one"), "{line}"); + assert!(line.contains("session=wayland"), "{line}"); + assert!(line.contains("gl=unknown"), "{line}"); + assert!(line.contains("vk=unknown"), "{line}"); +} + +#[test] +fn summary_line_reports_the_vulkan_probe() { + let mut info = linux_info(Some((6, 8)), Some((2, 39))); + info.vk = VkProbe::LoaderOnly; + assert!(info.summary_line().contains("vk=loader_only")); + info.vk = VkProbe::Available; + assert!(info.summary_line().contains("vk=available")); + info.vk = VkProbe::Missing; + assert!(info.summary_line().contains("vk=missing")); +} + +#[test] +fn summary_line_marks_missing_version_as_unknown() { + let mut info = windows_info(None); + info.version_string = String::new(); + let line = info.summary_line(); + assert!(line.contains("version=unknown"), "{line}"); + assert!(line.contains("level=unsupported"), "{line}"); +} + +#[test] +fn support_level_helpers() { + assert_eq!(SupportLevel::One.as_str(), "one"); + assert_eq!(SupportLevel::Unsupported.as_str(), "unsupported"); + assert!(SupportLevel::One.is_usable()); + assert!(!SupportLevel::Unsupported.is_usable()); +} + +#[test] +fn os_family_facts_and_launch_reexport_are_the_same_type() { + // launch::platform 只是把 OsFamily 从 platform::os_info 再导出(避免两套枚举)。 + let family: unlucky_core::platform::os_info::OsFamily = OsFamily::Windows; + assert_eq!(family, OsFamily::Windows); + assert_eq!(OsFamily::Windows.manifest_name(), "windows"); + assert_eq!(OsFamily::MacOs.manifest_name(), "osx"); + assert_eq!(OsFamily::Linux.manifest_name(), "linux"); + assert!(!OsFamily::Windows.is_posix()); + assert!(OsFamily::Linux.is_posix()); + assert!(!OsFamily::current().manifest_arch().is_empty()); + assert_eq!( + OsFamily::current().manifest_arch(), + OsFamily::Linux.manifest_arch() + ); +} + +#[test] +fn detect_reports_the_host_it_runs_on() { + let info = os_info::detect(); + // 家族与当前编译目标一致。 + assert_eq!(info.family, OsFamily::current()); + assert!(!info.arch.is_empty()); + assert!(!info.product.is_empty()); + // 缓存入口返回同一份事实。 + let cached = os_info::current(); + assert_eq!(cached, &info); + assert_eq!(cached.family, info.family); +} + +#[test] +fn windows_host_probes_a_real_version() { + if !cfg!(target_os = "windows") { + return; + } + let info = os_info::detect(); + let (major, _minor) = info.major_minor.expect("Windows 上必须探到 major.minor"); + assert!(major >= 6, "探到的版本不合理: {:?}", info.major_minor); + assert!(!info.os_version().is_empty(), "os_version 不能是空串"); + assert!(info.build.is_some(), "Windows 上应有 build 号"); + // 非 Windows 分支的事实不应泄漏进来。 + assert!(info.kernel.is_none()); + assert!(info.glibc.is_none()); + assert_eq!(info.session, SessionKind::Unknown); +} + +#[test] +fn check_returns_level_message_and_os_info() { + let check: SupportCheck = os_info::check(); + assert_eq!(check.level, os_info::current().support_level); + assert_eq!(check.is_usable(), check.level.is_usable()); + assert!(check.message.contains('\n'), "文案应为中英双语多行"); + assert_eq!(check.message, check.os.describe().combined()); +} diff --git a/crates/core/tests/platform/prelude.rs b/crates/core/tests/platform/prelude.rs new file mode 100644 index 0000000..f22c9ff --- /dev/null +++ b/crates/core/tests/platform/prelude.rs @@ -0,0 +1,53 @@ +//! 测试共用设施(crate 名只出现在这里)。 + +use std::path::PathBuf; + +pub use unlucky_core::launch::args_table::assemble; +pub use unlucky_core::launch::argument::{ArgumentEntry, OsRule, Rule, RuleContext}; +pub use unlucky_core::launch::game_version::GameVersion; +pub use unlucky_core::launch::platform::{OsFamily, PlatformCtx}; +pub use unlucky_core::launch::request::*; +pub use unlucky_core::platform::linux_caps::{self, GlProbe, LinuxCaps, SessionKind}; +pub use unlucky_core::platform::macos_version::{self, MacOsVersion}; +pub use unlucky_core::platform::os_info::{self, OsInfo, SupportCheck, SupportLevel}; +pub use unlucky_core::platform::vk_probe::{ + classify, count_icd_manifests, env_icd_path, ldconfig_has_libvulkan, loader_candidates, + probe_vk, registry_has_driver_entry, VkEvidence, VkProbe, ICD_DIRS, ICD_ENV_VARS, + VULKAN_DRIVERS_KEY, +}; +pub use unlucky_core::platform::windows_version::{self, WindowsVersion}; + +/// 一个最小可组装请求:现代清单,游戏参数由调用方给。 +pub fn request_with_game(game: Vec) -> LaunchRequest { + LaunchRequest { + instance: InstanceInfo { + id: "inst-1".to_string(), + name: "1.20.1".to_string(), + version: GameVersion::parse("1.20.1"), + version_type: "release".to_string(), + }, + launcher: LauncherInfo { + name: "UnluckyLauncher".to_string(), + version: "0.1.0".to_string(), + }, + java: JavaSelection::new("C:/java/bin/java.exe", 17, 64), + manifest: ManifestArgs { + main_class: Some("net.minecraft.client.main.Main".to_string()), + game, + ..ManifestArgs::default() + }, + classpath: vec![PathBuf::from("C:/mc/libraries/a.jar")], + ..LaunchRequest::default() + } +} + +/// 一条带 `rules.os.version` 的游戏参数项。 +pub fn version_ruled_entry(pattern: &str, value: &str) -> ArgumentEntry { + let mut rule = Rule::allow(); + rule.os = Some(OsRule { + name: None, + arch: None, + version: Some(pattern.to_string()), + }); + ArgumentEntry::ruled(vec![rule], [value]) +} diff --git a/crates/core/tests/platform/rules_wiring.rs b/crates/core/tests/platform/rules_wiring.rs new file mode 100644 index 0000000..7b592f3 --- /dev/null +++ b/crates/core/tests/platform/rules_wiring.rs @@ -0,0 +1,96 @@ +//! 接线:`rules.os.version` 用真实探测值求值(D19 §2 —— 之前 `os_version` 是空串, +//! 带版本规则的参数项永远命中不了)。 + +use crate::prelude::*; + +fn argv_of(entries: Vec, ctx: &PlatformCtx) -> Vec { + let request = request_with_game(entries); + assemble(&request, ctx).expect("组装失败").argv().to_vec() +} + +#[test] +fn windows_10_rule_hits_with_detected_version() { + let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64").with_os_version("10.0.19045"); + let entries = vec![version_ruled_entry(r"^10\.", "--demo-windows10")]; + let argv = argv_of(entries, &ctx); + assert!( + argv.iter().any(|arg| arg == "--demo-windows10"), + "argv 应包含带版本规则的参数: {argv:?}" + ); +} + +#[test] +fn same_rule_misses_when_version_is_empty() { + // 空版本串是修复前的常态:规则求值走保守分支(不放参数)。 + let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64"); + assert!(ctx.os_version.is_empty()); + let entries = vec![version_ruled_entry(r"^10\.", "--demo-windows10")]; + let argv = argv_of(entries, &ctx); + assert!( + !argv.iter().any(|arg| arg == "--demo-windows10"), + "空版本串不该命中: {argv:?}" + ); +} + +#[test] +fn escaped_dot_patterns_match_real_versions() { + // `^10\.15` 是 macOS 上常见的写法:反转义后按 `10.15` 前缀比较。 + let ctx = PlatformCtx::new(OsFamily::MacOs, "x86_64").with_os_version("10.15.7"); + let entries = vec![version_ruled_entry(r"^10\.15", "--demo-catalina")]; + let argv = argv_of(entries, &ctx); + assert!(argv.iter().any(|arg| arg == "--demo-catalina"), "{argv:?}"); +} + +#[test] +fn regex_shaped_patterns_stay_conservative() { + let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64").with_os_version("10.0.19045"); + // 需要真正则的模式:宁可少加参数,也不猜。 + let entries = vec![version_ruled_entry(r"^10\.[0-9]+", "--demo-regex")]; + let argv = argv_of(entries, &ctx); + assert!(!argv.iter().any(|arg| arg == "--demo-regex"), "{argv:?}"); +} + +#[test] +fn rule_context_uses_the_injected_version() { + let features = FeatureSet::default(); + let entry = version_ruled_entry(r"^6\.1\.", "--demo-win7"); + assert!(entry.applies(&RuleContext::new( + "windows", "x86_64", "6.1.7601", &features + ))); + assert!(!entry.applies(&RuleContext::new( + "windows", + "x86_64", + "10.0.19045", + &features + ))); + // 空版本串:任何版本规则都不命中。 + assert!(!entry.applies(&RuleContext::new("windows", "x86_64", "", &features))); +} + +#[test] +fn platform_ctx_current_carries_the_probed_version() { + let ctx = PlatformCtx::current(); + let info = os_info::current(); + assert_eq!(ctx.os, info.family); + assert_eq!(ctx.arch, info.arch); + assert_eq!(ctx.os_version, info.os_version()); + if cfg!(target_os = "windows") { + assert!(!ctx.os_version.is_empty(), "Windows 上版本串不能为空"); + // 版本串应能被规则前缀匹配到(形如 10.0.19045)。 + assert!( + ctx.os_version.starts_with(|c: char| c.is_ascii_digit()), + "版本串形态异常: {}", + ctx.os_version + ); + } +} + +#[test] +fn default_matches_current() { + let ctx = PlatformCtx::default(); + assert_eq!(ctx.os_version, PlatformCtx::current().os_version); + assert_eq!( + ctx.path_separator(), + PlatformCtx::current().path_separator() + ); +} diff --git a/crates/core/tests/platform/theme_cases.rs b/crates/core/tests/platform/theme_cases.rs new file mode 100644 index 0000000..6fc20c5 --- /dev/null +++ b/crates/core/tests/platform/theme_cases.rs @@ -0,0 +1,102 @@ +//! 系统主题探测的解析与判定(`core::platform::theme_probe`)。 +//! +//! 解析函数跨平台编译,所以本用例在任意宿主上都能跑;真实探测只是把同一份解析套到 +//! `reg query` / `defaults` / `gsettings` 的输出上(`probe()` 的非确定性不进断言)。 + +use unlucky_core::platform::theme_probe::{ + self, parse_apps_use_light_theme, parse_gsettings_color_scheme, parse_gtk_theme, + parse_macos_interface_style, SystemTheme, WINDOWS_PERSONALIZE_KEY, +}; + +#[test] +fn windows_registry_apps_use_light_theme_maps_dword() { + // 真实 `reg query` 输出(REG_DWORD 以 0x 十六进制打印)。 + let light = "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize\n \ + AppsUseLightTheme REG_DWORD 0x1\n SystemUsesLightTheme REG_DWORD 0x0\n"; + assert_eq!(parse_apps_use_light_theme(light), Some(SystemTheme::Light)); + + let dark = " AppsUseLightTheme REG_DWORD 0x0\n"; + assert_eq!(parse_apps_use_light_theme(dark), Some(SystemTheme::Dark)); + + // 十进制形式的输出(某些区域设置/工具会把 DWORD 打成十进制)。 + assert_eq!( + parse_apps_use_light_theme(" AppsUseLightTheme REG_DWORD 1"), + Some(SystemTheme::Light) + ); + + // 键不存在(老系统 / 被策略删掉)与别的值都不猜。 + assert_eq!(parse_apps_use_light_theme(""), None); + assert_eq!( + parse_apps_use_light_theme(" SystemUsesLightTheme REG_DWORD 0x0"), + None + ); + assert_eq!( + parse_apps_use_light_theme(" AppsUseLightTheme REG_SZ yes"), + None + ); +} + +#[test] +fn windows_key_points_at_personalize_hive() { + assert!(WINDOWS_PERSONALIZE_KEY.contains("Themes\\Personalize")); +} + +#[test] +fn macos_interface_style_maps_dark_and_empty() { + assert_eq!( + parse_macos_interface_style("Dark\n"), + Some(SystemTheme::Dark) + ); + assert_eq!( + parse_macos_interface_style("Light\n"), + Some(SystemTheme::Light) + ); + // 浅色时 `AppleInterfaceStyle` 键不存在,defaults 无输出。 + assert_eq!(parse_macos_interface_style(""), None); +} + +#[test] +fn gsettings_color_scheme_prefers_explicit_preference() { + assert_eq!( + parse_gsettings_color_scheme("'prefer-dark'\n"), + Some(SystemTheme::Dark) + ); + assert_eq!( + parse_gsettings_color_scheme("'prefer-light'\n"), + Some(SystemTheme::Light) + ); + // `default` = 未表态,交给 gtk-theme 名字兜底。 + assert_eq!(parse_gsettings_color_scheme("'default'\n"), None); +} + +#[test] +fn gtk_theme_name_falls_back_to_dark_suffix() { + assert_eq!(parse_gtk_theme("'Yaru-dark'\n"), Some(SystemTheme::Dark)); + assert_eq!(parse_gtk_theme("'Adwaita-dark'\n"), Some(SystemTheme::Dark)); + assert_eq!(parse_gtk_theme("'Yaru'\n"), Some(SystemTheme::Light)); + assert_eq!(parse_gtk_theme("''\n"), None); +} + +#[test] +fn system_theme_tristate_contract() { + assert_eq!(SystemTheme::Light.is_dark(), Some(false)); + assert_eq!(SystemTheme::Dark.is_dark(), Some(true)); + assert_eq!( + SystemTheme::Unknown.is_dark(), + None, + "Unknown 必须让调用方自己决定回落(不假装浅色)" + ); + assert_eq!(SystemTheme::Light.as_str(), "light"); + assert_eq!(SystemTheme::Dark.as_str(), "dark"); + assert_eq!(SystemTheme::Unknown.as_str(), "unknown"); +} + +#[test] +fn probe_never_panics_and_reports_one_of_three_states() { + // 真实探测(Windows 读注册表,其余平台问 DE);不断言具体值,只要求落在三态内。 + let probed = theme_probe::probe(); + assert!(matches!( + probed, + SystemTheme::Light | SystemTheme::Dark | SystemTheme::Unknown + )); +} diff --git a/crates/core/tests/platform/vk_cases.rs b/crates/core/tests/platform/vk_cases.rs new file mode 100644 index 0000000..44c9ae3 --- /dev/null +++ b/crates/core/tests/platform/vk_cases.rs @@ -0,0 +1,240 @@ +//! Vulkan 能力探测(`platform::vk_probe`)的三平台注入样本。 +//! +//! 全部用 [`VkEvidence`] 注入,不读真机、不起子进程: +//! 三平台解析函数是纯函数且跨平台编译,任意宿主都能跑这几个用例。 +//! +//! 关键语义(`research/05-mc-vulkan.md` §4.3 的真机证据): +//! **Windows 注册表 `HKLM\SOFTWARE\Khronos\Vulkan\Drivers` 缺失不等于没有 Vulkan** +//! (实测机 Vulkan 1.3.217 可用但该键不存在),所以只能降级到 `LoaderOnly`,不能判 `Missing`。 + +use std::path::PathBuf; + +use crate::prelude::*; + +#[test] +fn windows_loader_without_registry_key_is_only_loader_only() { + // 真机证据的复刻:vulkan-1.dll 在,注册表键缺失 → 只能说「loader 在、驱动未确认」。 + let probe = classify(VkEvidence::new(OsFamily::Windows, true, false)); + assert_eq!(probe, VkProbe::LoaderOnly); + assert!(probe.has_loader()); + assert!(!probe.is_missing(), "注册表缺失被误判成没有 Vulkan"); + assert!(!probe.is_available(), "没有驱动信号不该承诺可用"); +} + +#[test] +fn windows_loader_plus_registry_entry_is_available() { + assert_eq!( + classify(VkEvidence::new(OsFamily::Windows, true, true)), + VkProbe::Available + ); +} + +#[test] +fn windows_without_loader_is_missing() { + assert_eq!( + classify(VkEvidence::new(OsFamily::Windows, false, true)), + VkProbe::Missing + ); +} + +#[test] +fn linux_loader_with_icd_manifests_is_available() { + assert_eq!( + classify(VkEvidence::new(OsFamily::Linux, true, true)), + VkProbe::Available + ); + // 只有 loader、没有 ICD 目录/环境变量:同样不否定(驱动可能由 loader 自寻)。 + assert_eq!( + classify(VkEvidence::new(OsFamily::Linux, true, false)), + VkProbe::LoaderOnly + ); + assert_eq!( + classify(VkEvidence::new(OsFamily::Linux, false, false)), + VkProbe::Missing + ); +} + +#[test] +fn macos_molten_vk_needs_no_separate_icd_signal() { + // macOS 没有系统 Vulkan:MoltenVK 既是 loader 又是 ICD,没有 LoaderOnly 这一档。 + assert_eq!( + classify(VkEvidence::new(OsFamily::MacOs, true, false)), + VkProbe::Available + ); + assert_eq!( + classify(VkEvidence::new(OsFamily::MacOs, false, true)), + VkProbe::Missing + ); +} + +#[test] +fn probe_names_are_stable_and_lower_snake() { + assert_eq!(VkProbe::Unknown.as_str(), "unknown"); + assert_eq!(VkProbe::LoaderOnly.as_str(), "loader_only"); + assert_eq!(VkProbe::Available.as_str(), "available"); + assert_eq!(VkProbe::Missing.as_str(), "missing"); + assert_eq!(VkProbe::default(), VkProbe::Unknown); + assert!(!VkProbe::Unknown.has_loader()); +} + +#[test] +fn loader_candidates_cover_each_platform_signature() { + let windows = loader_candidates(OsFamily::Windows); + assert!( + windows + .iter() + .any(|path| path.ends_with("System32/vulkan-1.dll") + || path.ends_with(r"System32\vulkan-1.dll")), + "Windows 判据应是 System32\\vulkan-1.dll:{windows:?}" + ); + + let linux = loader_candidates(OsFamily::Linux); + assert!( + linux + .iter() + .any(|path| path.to_string_lossy().contains("libvulkan.so.1")), + "Linux 判据应含 libvulkan.so.1:{linux:?}" + ); + + let macos = loader_candidates(OsFamily::MacOs); + let macos_names = macos + .iter() + .map(|path| { + path.file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned() + }) + .collect::>(); + assert!( + macos_names.iter().any(|name| name == "libvulkan.1.dylib"), + "macOS 判据应含 libvulkan.1.dylib:{macos_names:?}" + ); + assert!( + macos_names.iter().any(|name| name == "libMoltenVK.dylib"), + "macOS 判据应含 MoltenVK:{macos_names:?}" + ); +} + +#[test] +fn registry_query_output_is_parsed_for_driver_entries() { + // 键不存在:只有本地化错误行(中英各一例,真机输出)。 + let missing_en = + "ERROR: The system was unable to find the specified registry key or value.\r\n"; + let missing_zh = "\r\n错误: 系统找不到指定的注册表项或值。\r\n"; + assert!(!registry_has_driver_entry(missing_en)); + assert!(!registry_has_driver_entry(missing_zh)); + assert!(!registry_has_driver_entry("")); +} + +#[test] +fn registry_query_real_format_is_recognised() { + // 真实 `reg query KEY`(不带 /s)格式,本机实测: + // 空行 → 键头行(顶格、完整 hive 名、无尾反斜杠)→ 缩进的值行 + // ` <值名> REG_DWORD 0x0` + // Vulkan 的 ICD 注册是「值名 = manifest 绝对路径」,名字里可能有空格,所以列宽不定。 + let present = "\r\n\ +HKEY_LOCAL_MACHINE\\SOFTWARE\\Khronos\\Vulkan\\Drivers\r\n \ +C:\\Windows\\System32\\DriverStore\\FileRepository\\amdvlk64.inf_amd64_1b2c\\amdvlk64.json REG_DWORD 0x0\r\n \ +C:\\vendor a\\vk_vendor_a.json REG_DWORD 0x0\r\n"; + assert!(registry_has_driver_entry(present)); + + // 值可能是 REG_SZ(第三方 loader/层注册的写法)。 + let reg_sz = "\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\Khronos\\Vulkan\\Drivers\r\n \ +C:\\vendor\\vk.json REG_SZ \r\n"; + assert!(registry_has_driver_entry(reg_sz)); +} + +#[test] +fn registry_key_without_values_is_not_a_driver_signal() { + // 键存在但没有任何值项:只有键头行。 + let header_only = "\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\Khronos\\Vulkan\\Drivers\r\n"; + assert!(!registry_has_driver_entry(header_only)); + + // 键存在但只有子键(无 /s 时子键也缩进打印,且不带 REG_ 类型标记)。 + let subkeys_only = "\r\n\ +HKEY_LOCAL_MACHINE\\SOFTWARE\\Khronos\\Vulkan\\Drivers\r\n \ +LayerOne\r\n LayerTwo\r\n"; + assert!(!registry_has_driver_entry(subkeys_only)); + + // 键头是**别的**键时,其值行不能算数(防止把上层/邻居键的值当成 ICD 注册)。 + let other_key = "\r\n\ +HKEY_LOCAL_MACHINE\\SOFTWARE\\Khronos\\Vulkan\r\n \ +ImplicitLayers REG_DWORD 0x0\r\n"; + assert!(!registry_has_driver_entry(other_key)); +} + +#[test] +fn ldconfig_output_is_parsed_for_the_loader() { + let with_loader = + "\tlibvulkan.so.1 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libvulkan.so.1\n\ +\tlibvulkan.so (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libvulkan.so\n"; + assert!(ldconfig_has_libvulkan(with_loader)); + assert!(!ldconfig_has_libvulkan( + "\tlibGL.so.1 (libc6,x86-64) => /usr/lib/libGL.so.1\n" + )); +} + +#[test] +fn icd_env_prefers_the_legacy_variable_and_skips_blanks() { + let vars = |pairs: &'static [(&'static str, &'static str)]| { + env_icd_path(move |key| { + pairs + .iter() + .find(|(name, _)| *name == key) + .map(|(_, value)| (*value).to_string()) + }) + }; + assert_eq!( + vars(&[("VK_ICD_FILENAMES", "/a/nvidia_icd.json")]).as_deref(), + Some("/a/nvidia_icd.json") + ); + assert_eq!( + vars(&[("VK_DRIVER_FILES", "/b/intel_icd.json")]).as_deref(), + Some("/b/intel_icd.json") + ); + // 旧名优先(两者都设时 Khronos loader 也是旧名优先)。 + assert_eq!( + vars(&[ + ("VK_ICD_FILENAMES", "/a/one.json"), + ("VK_DRIVER_FILES", "/b/two.json"), + ]) + .as_deref(), + Some("/a/one.json") + ); + // 空白值当作没设。 + assert_eq!(vars(&[("VK_ICD_FILENAMES", " ")]), None); + assert_eq!(vars(&[]), None); + assert_eq!(ICD_ENV_VARS.len(), 2); + assert_eq!(ICD_DIRS.len(), 3); + assert!(VULKAN_DRIVERS_KEY.contains("Khronos")); +} + +#[test] +fn icd_manifest_count_ignores_non_json_and_missing_dirs() { + let dir = temp_dir("vk-icd"); + assert_eq!(count_icd_manifests(&dir), 0); + std::fs::write(dir.join("nvidia_icd.json"), "{}").expect("写 json"); + std::fs::write(dir.join("intel_icd.json"), "{}").expect("写 json"); + std::fs::write(dir.join("README"), "x").expect("写非 json"); + assert_eq!(count_icd_manifests(&dir), 2); + assert_eq!(count_icd_manifests(&dir.join("nope")), 0); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn probe_vk_on_this_host_is_not_unknown() { + // 真探一次:只断言「给了确定结论」(本机有没有 Vulkan 都合法,不许恒 Unknown)。 + assert_ne!(probe_vk(), VkProbe::Unknown); +} + +/// 唯一临时目录(不引 tempfile 依赖)。 +fn temp_dir(tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + let index = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("ull-vk-{tag}-{}-{index}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("创建临时目录"); + dir +} diff --git a/crates/core/tests/platform/windows_version_cases.rs b/crates/core/tests/platform/windows_version_cases.rs new file mode 100644 index 0000000..0a91068 --- /dev/null +++ b/crates/core/tests/platform/windows_version_cases.rs @@ -0,0 +1,179 @@ +//! Windows 版本探测:注册表 / `ver` 输出的解析 + 本机真实探测。 + +use crate::prelude::*; + +/// Win11(`CurrentMajorVersionNumber` 是 REG_DWORD,`reg query` 输出 0x..)。 +const WIN11_REG: &str = "\ +HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion + CurrentBuildNumber REG_SZ 22631 + CurrentMajorVersionNumber REG_DWORD 0xa + CurrentMinorVersionNumber REG_DWORD 0x0 + CurrentVersion REG_SZ 6.3 + ProductName REG_SZ Windows 10 Pro +"; + +/// Win10:数字键存在,`CurrentVersion` 是过期的 6.3,不能拿它当版本。 +const WIN10_REG: &str = "\ +HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion + CurrentBuildNumber REG_SZ 19045 + CurrentMajorVersionNumber REG_DWORD 0xa + CurrentMinorVersionNumber REG_DWORD 0x0 + CurrentVersion REG_SZ 6.3 +"; + +/// Win7:没有数字键,只有 `CurrentVersion` 字符串(`CurrentBuildNumber` Win7 也有)。 +const WIN7_REG: &str = "\ +HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion + CurrentBuildNumber REG_SZ 7601 + CurrentVersion REG_SZ 6.1 + ProductName REG_SZ Windows 7 Ultimate +"; + +#[test] +fn reg_query_parses_windows_11() { + let version = windows_version::parse_reg_query(WIN11_REG).expect("解析失败"); + assert_eq!(version, WindowsVersion::new(10, 0, 22631)); + assert_eq!(version.version_string(), "10.0.22631"); + assert_eq!(version.display_name(), "Windows 11"); +} + +#[test] +fn reg_query_prefers_numeric_keys_over_stale_current_version() { + let version = windows_version::parse_reg_query(WIN10_REG).expect("解析失败"); + assert_eq!(version, WindowsVersion::new(10, 0, 19045)); + assert_eq!(version.display_name(), "Windows 10"); +} + +#[test] +fn reg_query_falls_back_to_current_version_on_windows_7() { + let version = windows_version::parse_reg_query(WIN7_REG).expect("解析失败"); + assert_eq!(version, WindowsVersion::new(6, 1, 7601)); + assert_eq!(version.major_minor(), (6, 1)); + assert_eq!(version.display_name(), "Windows 7"); +} + +#[test] +fn reg_query_without_build_number_is_unparsable() { + let text = " CurrentVersion REG_SZ 6.1\n"; + assert_eq!(windows_version::parse_reg_query(text), None); + // 空输出(reg 不可用 / 被策略禁用)也不能崩。 + assert_eq!(windows_version::parse_reg_query(""), None); + assert_eq!( + windows_version::parse_reg_query("ERROR: 找不到指定的注册表项"), + None + ); +} + +#[test] +fn ver_output_parses_english_and_chinese() { + let english = + windows_version::parse_ver_output("Microsoft Windows [Version 10.0.19045.4046]\r\n") + .expect("解析失败"); + assert_eq!(english, WindowsVersion::new(10, 0, 19045)); + + let chinese = windows_version::parse_ver_output( + "Microsoft Windows [\u{7248}\u{672c} 10.0.19045.4046]\r\n", + ) + .expect("解析失败"); + assert_eq!(chinese, english); + + let win7 = windows_version::parse_ver_output("Microsoft Windows [Version 6.1.7601]") + .expect("解析失败"); + assert_eq!(win7, WindowsVersion::new(6, 1, 7601)); + assert_eq!(win7.display_name(), "Windows 7"); +} + +#[test] +fn ver_output_rejects_garbage() { + assert_eq!(windows_version::parse_ver_output(""), None); + assert_eq!(windows_version::parse_ver_output("no brackets here"), None); + assert_eq!( + windows_version::parse_ver_output("Microsoft Windows [Version ]"), + None + ); +} + +#[test] +fn build_number_maps_to_version_families() { + // 快路径:单值查询只给 build,major.minor 由 build 族反推。 + assert_eq!( + windows_version::from_build(7601), + Some(WindowsVersion::new(6, 1, 7601)) + ); + assert_eq!( + windows_version::from_build(9200).map(|v| v.major_minor()), + Some((6, 2)) + ); + assert_eq!( + windows_version::from_build(9600).map(|v| v.major_minor()), + Some((6, 3)) + ); + assert_eq!( + windows_version::from_build(19045).map(|v| v.major_minor()), + Some((10, 0)) + ); + assert_eq!( + windows_version::from_build(22631).map(|v| v.display_name()), + Some("Windows 11") + ); + assert_eq!( + windows_version::from_build(26100).map(|v| v.major_minor()), + Some((10, 0)) + ); + // Vista 之前不猜:交给整键/`ver` 解析兜底。 + assert_eq!(windows_version::from_build(3790), None); + assert_eq!(windows_version::from_build(0), None); +} + +#[test] +fn single_value_query_yields_build_number() { + let text = "\ +HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion + CurrentBuildNumber REG_SZ 26100 +"; + assert_eq!(windows_version::parse_build_number(text), Some(26100)); + assert_eq!( + windows_version::parse_build_number("ERROR: \u{627e}\u{4e0d}\u{5230}\u{6307}\u{5b9a}\u{7684}\u{6ce8}\u{518c}\u{8868}\u{9879}"), + None + ); + assert_eq!(windows_version::parse_build_number(""), None); +} + +#[test] +fn display_name_covers_the_matrix() { + assert_eq!( + WindowsVersion::new(10, 0, 22_000).display_name(), + "Windows 11" + ); + assert_eq!( + WindowsVersion::new(10, 0, 21_999).display_name(), + "Windows 10" + ); + assert_eq!( + WindowsVersion::new(6, 3, 9600).display_name(), + "Windows 8.1" + ); + assert_eq!(WindowsVersion::new(6, 2, 9200).display_name(), "Windows 8"); + assert_eq!(WindowsVersion::new(6, 1, 7601).display_name(), "Windows 7"); + assert_eq!( + WindowsVersion::new(6, 0, 6002).display_name(), + "Windows Vista" + ); + assert_eq!(WindowsVersion::new(5, 1, 2600).display_name(), "Windows XP"); +} + +#[test] +fn probe_matches_the_host() { + let probed = windows_version::probe(); + if cfg!(target_os = "windows") { + let version = probed.expect("Windows 宿主必须能探到真实版本"); + // 真实的 Windows 至少是 6.x(XP 是 5.x,不会被这个仓库支持)。 + assert!(version.major >= 6, "探到 {version:?}"); + assert!(!version.display_name().is_empty()); + // 重新探一次结果稳定(reg 与 ver 两条路径应给同一个 major.minor)。 + let again = windows_version::probe().expect("二次探测失败"); + assert_eq!(again.major_minor(), version.major_minor()); + } else { + assert!(probed.is_none(), "非 Windows 平台不应探到 Windows 版本"); + } +} diff --git a/crates/core/tests/setting/appearance_keys.rs b/crates/core/tests/setting/appearance_keys.rs new file mode 100644 index 0000000..e66fba0 --- /dev/null +++ b/crates/core/tests/setting/appearance_keys.rs @@ -0,0 +1,196 @@ +//! 「外观(高级)」键簇(`setting::appearance` + `keys::launcher_schema` 的 appearance.* 段)。 +//! +//! 守三条设计约束(09-ui-layout §111): +//! 1. 一切高级特性**默认关闭**(出厂 Metro 纯色)——默认值即出厂态; +//! 2. 全部走 D6 schema 且 **AI 可写**(白名单内,可被 agent 调节); +//! 3. 值域有硬边界(滑条夹取),坏值/手改配置不崩(回落默认)。 + +use serde_json::json; +use unlucky_core::setting::appearance::{ + hex_to_rgb, normalize_hex, relative_luminance, Appearance, BgFill, DEFAULT_ACCENT, + PANEL_ALPHA_MIN, +}; +use unlucky_core::setting::document::SettingDocument; +use unlucky_core::setting::keys::{launcher_schema, launcher_version}; +use unlucky_core::setting::spec::ValueKind; + +fn doc(raw: serde_json::Value) -> SettingDocument { + SettingDocument::load(&launcher_schema(), &raw).0 +} + +#[test] +fn factory_defaults_are_all_advanced_features_off() { + let appearance = Appearance::from_store(&unlucky_core::setting::store::SettingStore::load( + std::env::temp_dir().join(format!("ull-appearance-none-{}.json", std::process::id())), + launcher_schema(), + )); + assert_eq!(appearance.theme_mode, "auto", "D17:出厂跟随系统"); + assert!(!appearance.bg_image_enabled, "背景图默认关"); + assert!(!appearance.bg_image_active()); + assert_eq!(appearance.bg_fill, BgFill::Cover); + assert_eq!(appearance.panel_alpha, 1.0, "出厂完全不透明(Metro 纯色)"); + assert!(!appearance.glass, "玻璃拟态默认关"); + assert_eq!(appearance.accent_color, "", "空串 = 未自定义"); + assert_eq!(appearance.accent_or_default(), DEFAULT_ACCENT); +} + +#[test] +fn reading_a_configured_document_reflects_each_key() { + let document = doc(json!({ + "appearance": { + "theme_mode": "dark", + "bg_image_enabled": true, + "bg_image": "D:/wall/paper.png", + "bg_fill": "tile", + "bg_dim": 0.6, + "panel_alpha": 0.8, + "glass": true, + "accent_color": "#ff5722", + } + })); + let appearance = Appearance::read(&document); + assert_eq!(appearance.theme_mode, "dark"); + assert!(appearance.bg_image_active()); + assert_eq!(appearance.bg_image, "D:/wall/paper.png"); + assert_eq!(appearance.bg_fill, BgFill::Tile); + assert!((appearance.bg_dim - 0.6).abs() < 1e-9); + assert!((appearance.panel_alpha - 0.8).abs() < 1e-9); + assert!(appearance.glass); + assert_eq!(appearance.accent_color, "#FF5722", "读入即规范化大写"); + assert_eq!(appearance.accent_or_default(), "#FF5722"); +} + +#[test] +fn bad_values_fall_back_instead_of_panicking() { + // 手改坏的配置:枚举拼错 / 开关类型错 / accent 不是 #RRGGBB。 + let document = doc(json!({ + "appearance": { + "bg_fill": "stretch", + "bg_image_enabled": "yes", + "accent_color": "rebeccapurple", + } + })); + let appearance = Appearance::read(&document); + assert_eq!(appearance.bg_fill, BgFill::Cover); + assert!(!appearance.bg_image_enabled); + assert_eq!(appearance.accent_color, ""); + assert_eq!(appearance.accent_or_default(), DEFAULT_ACCENT); +} + +#[test] +fn panel_alpha_is_clamped_to_the_contrast_floor() { + // schema 下限 0.4;读侧再夹一次(挡手改文件绕过 schema 的情况)。 + let document = doc(json!({ "appearance": { "panel_alpha": 0.05 } })); + assert!((Appearance::read(&document).panel_alpha - PANEL_ALPHA_MIN).abs() < 1e-9); +} + +#[test] +fn appearance_keys_are_all_ai_writable_and_registered() { + let schema = launcher_schema(); + // 主题三态是 D17 已定案的最轻量键(silent/Low),其余高级键留审计痕迹(audited/Medium)。 + let audited_keys = [ + "appearance.bg_image_enabled", + "appearance.bg_image", + "appearance.bg_fill", + "appearance.bg_dim", + "appearance.panel_alpha", + "appearance.glass", + "appearance.accent_color", + ]; + for key in audited_keys { + let spec = schema.spec(key).unwrap_or_else(|| panic!("{key} 必须登记")); + assert!(spec.ai.writable, "{key} 必须在 AI 可写白名单内"); + // 纯视觉、可一键复原 → 不要求弹窗确认(High 级留给凭据/数据根/代理)。 + assert!( + !spec.ai.sensitivity.requires_confirmation(), + "{key} 不该要确认" + ); + assert_eq!( + spec.ai.sensitivity.as_str(), + "medium", + "{key} 要留审计痕迹(AI 改过什么可回看)" + ); + } + // 主题三态仍是 silent(D17 已定案的最轻量键)。 + assert_eq!( + schema + .spec("appearance.theme_mode") + .unwrap() + .ai + .sensitivity + .as_str(), + "low" + ); +} + +#[test] +fn schema_registration_shape_is_right() { + assert_eq!(launcher_version().minor, 3, "1.3.0 登记外观键"); + let schema = launcher_schema(); + assert_eq!( + schema.spec("appearance.bg_fill").unwrap().kind, + ValueKind::Enum(&["cover", "contain", "tile"]) + ); + assert!(matches!( + schema.spec("appearance.panel_alpha").unwrap().kind, + ValueKind::Float { min, max } if (min - PANEL_ALPHA_MIN).abs() < 1e-9 && (max - 1.0).abs() < 1e-9 + )); + assert_eq!( + schema.spec("appearance.accent_color").unwrap().kind, + ValueKind::Text { max_len: 9 }, + "#RRGGBB 最多 7 字符,留 9 容错带井号与空白" + ); + // 默认值 = 出厂态 + assert_eq!( + schema.spec("appearance.panel_alpha").unwrap().default, + serde_json::Value::from(1.0) + ); + assert_eq!( + schema.spec("appearance.glass").unwrap().default, + serde_json::Value::from(false) + ); +} + +#[test] +fn write_round_trips_through_the_store() { + let temp = + std::env::temp_dir().join(format!("ull-appearance-{}-{}", std::process::id(), line!())); + std::fs::create_dir_all(temp.join("config")).expect("临时目录"); + let path = temp.join("config/launcher.json"); + let _ = std::fs::remove_file(&path); + + let mut store = unlucky_core::setting::store::SettingStore::load(&path, launcher_schema()); + let mut appearance = Appearance::from_store(&store); + appearance.theme_mode = "dark".to_owned(); + appearance.panel_alpha = 0.8; + appearance.bg_image_enabled = true; + appearance.bg_image = "D:/wall/p.png".to_owned(); + appearance.accent_color = "#0f6cbd".to_owned(); + appearance.write(&mut store).expect("写外观键"); + store.save().expect("落盘"); + + let reloaded = Appearance::from_store(&unlucky_core::setting::store::SettingStore::load( + &path, + launcher_schema(), + )); + assert_eq!(reloaded.theme_mode, "dark"); + assert!((reloaded.panel_alpha - 0.8).abs() < 1e-9); + assert!(reloaded.bg_image_active()); + assert_eq!(reloaded.accent_color, "#0F6CBD"); + + std::fs::remove_dir_all(&temp).ok(); +} + +#[test] +fn hex_helpers_validate_and_convert() { + assert_eq!(normalize_hex(" 0f6cbd "), Some("#0F6CBD".to_owned())); + assert_eq!(normalize_hex("#0F6CBD"), Some("#0F6CBD".to_owned())); + assert_eq!(normalize_hex("#0F6CB"), None); + assert_eq!(normalize_hex("#GGGGGG"), None); + assert_eq!(hex_to_rgb("#0078D4"), Some((0x00, 0x78, 0xD4))); + // WCAG 相对亮度:纯黑 0、纯白 1、Metro 蓝居中偏暗。 + assert!(relative_luminance("#000000").unwrap().abs() < 1e-9); + assert!((relative_luminance("#FFFFFF").unwrap() - 1.0).abs() < 1e-9); + let metro = relative_luminance(DEFAULT_ACCENT).unwrap(); + assert!(metro > 0.05 && metro < 0.3, "Metro 蓝亮度 {metro}"); +} diff --git a/crates/core/tests/setting/atomic_write.rs b/crates/core/tests/setting/atomic_write.rs new file mode 100644 index 0000000..bd39df2 --- /dev/null +++ b/crates/core/tests/setting/atomic_write.rs @@ -0,0 +1,49 @@ +//! 原子写:覆盖语义、父目录创建、不留临时文件、可选读。 + +use unlucky_core::platform::atomic::{read_optional, tmp_path, write_atomic}; + +use crate::common::{dir_entries, TempDir}; + +#[test] +fn writes_and_replaces_in_place() { + let temp = TempDir::new("atomic"); + let path = temp.child("config/launcher.json"); + + write_atomic(&path, b"{\"a\":1}").expect("首次写"); + assert_eq!(temp.read("config/launcher.json"), "{\"a\":1}"); + + write_atomic(&path, b"{\"a\":2}").expect("覆盖写"); + assert_eq!(temp.read("config/launcher.json"), "{\"a\":2}"); + + // 目录里只该有目标文件,临时文件必须被改名掉。 + assert_eq!(dir_entries(&temp.child("config")), vec!["launcher.json"]); +} + +#[test] +fn read_optional_distinguishes_missing_from_failure() { + let temp = TempDir::new("atomic-read"); + let path = temp.child("nope.json"); + assert!(read_optional(&path).expect("读缺失文件").is_none()); + + temp.write("here.json", "内容"); + let bytes = read_optional(&temp.child("here.json")) + .expect("读文件") + .expect("有内容"); + assert_eq!(bytes, "内容".as_bytes()); +} + +#[test] +fn tmp_name_stays_in_same_directory() { + let temp = TempDir::new("atomic-tmp"); + let path = temp.child("config/profiles.json"); + let tmp = tmp_path(&path); + assert_eq!( + tmp.parent(), + path.parent(), + "临时文件必须同目录(rename 才原子)" + ); + assert_ne!(tmp, path); + let first = tmp_path(&path); + let second = tmp_path(&path); + assert_ne!(first, second, "并发写要能拿到不同的临时名"); +} diff --git a/crates/core/tests/setting/common.rs b/crates/core/tests/setting/common.rs new file mode 100644 index 0000000..4975416 --- /dev/null +++ b/crates/core/tests/setting/common.rs @@ -0,0 +1,81 @@ +//! 测试公用小工具(临时目录 + 文件读写)。 +//! +//! 不引 `tempfile`:M11 的依赖面只有 serde/serde_json,测试自己管临时目录。 + +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +/// 进程内唯一的临时目录,`Drop` 时递归删除。 +pub struct TempDir { + path: PathBuf, +} + +impl TempDir { + /// 建一个临时目录。 + pub fn new(label: &str) -> Self { + let index = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "ull-setting-{label}-{}-{index}", + std::process::id() + )); + std::fs::remove_dir_all(&path).ok(); + std::fs::create_dir_all(&path).expect("创建临时目录"); + Self { path } + } + + /// 根路径。 + pub fn path(&self) -> &Path { + &self.path + } + + /// 拼接子路径(不创建)。 + pub fn child(&self, name: &str) -> PathBuf { + self.path.join(name) + } + + /// 写一个文件(自动建父目录)。 + pub fn write(&self, name: &str, contents: &str) -> PathBuf { + let path = self.child(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("创建父目录"); + } + std::fs::write(&path, contents).expect("写文件"); + path + } + + /// 读一个文件。 + pub fn read(&self, name: &str) -> String { + std::fs::read_to_string(self.child(name)).expect("读文件") + } + + /// 子路径是否存在。 + pub fn exists(&self, name: &str) -> bool { + self.child(name).exists() + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.path).ok(); + } +} + +/// 目录里的文件名(排序后返回,便于断言没留下临时文件)。 +pub fn dir_entries(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .expect("读目录") + .map(|entry| { + entry + .expect("目录项") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect(); + names.sort(); + names +} diff --git a/crates/core/tests/setting/debounce.rs b/crates/core/tests/setting/debounce.rs new file mode 100644 index 0000000..be91d1c --- /dev/null +++ b/crates/core/tests/setting/debounce.rs @@ -0,0 +1,128 @@ +//! 保存通道:200ms 去抖、同路径覆盖、攒批落盘、后台线程 flush/关停。 + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use unlucky_core::setting::error::SettingResult; +use unlucky_core::setting::save::{CoalescingWriter, DebounceQueue, JobSink, DEFAULT_DEBOUNCE}; +use unlucky_core::setting::save_worker::SaveWorker; + +use crate::common::TempDir; + +/// 记录下来的 (路径, 内容) 列表。 +type Written = Vec<(PathBuf, Vec)>; + +#[derive(Clone, Default)] +struct RecordingSink { + written: Arc>, +} + +impl RecordingSink { + fn entries(&self) -> Vec<(PathBuf, Vec)> { + self.written.lock().expect("锁").clone() + } +} + +impl JobSink for RecordingSink { + fn write(&self, path: &Path, payload: &[u8]) -> SettingResult<()> { + self.written + .lock() + .expect("锁") + .push((path.to_path_buf(), payload.to_vec())); + Ok(()) + } +} + +#[test] +fn default_debounce_is_200ms() { + assert_eq!(DEFAULT_DEBOUNCE, Duration::from_millis(200)); +} + +#[test] +fn waits_for_the_window_before_writing() { + let start = Instant::now(); + let mut queue = DebounceQueue::::new(DEFAULT_DEBOUNCE); + assert!(queue.is_empty()); + + queue.enqueue("a".to_owned(), b"1".to_vec(), start); + queue.enqueue("b".to_owned(), b"2".to_vec(), start); + assert_eq!(queue.len(), 2); + assert!(queue.contains(&"a".to_owned())); + assert_eq!(queue.next_due(), Some(start + DEFAULT_DEBOUNCE)); + + assert!(queue + .drain_due(start + Duration::from_millis(100)) + .is_empty()); + let due = queue.drain_due(start + DEFAULT_DEBOUNCE); + assert_eq!(due.len(), 2); + assert_eq!(due[0].0, "a", "同一批按键排序,写盘顺序稳定"); + assert!(queue.is_empty()); +} + +#[test] +fn same_key_collapses_and_keeps_first_deadline() { + let start = Instant::now(); + let mut queue = DebounceQueue::::new(DEFAULT_DEBOUNCE); + queue.enqueue("a".to_owned(), b"1".to_vec(), start); + queue.enqueue( + "a".to_owned(), + b"2".to_vec(), + start + Duration::from_millis(150), + ); + + assert_eq!(queue.len(), 1); + assert_eq!( + queue.next_due(), + Some(start + DEFAULT_DEBOUNCE), + "窗口从首次改动开始算,避免连续改动无限推迟写盘" + ); + assert_eq!( + queue.drain_due(start + DEFAULT_DEBOUNCE), + vec![("a".to_owned(), b"2".to_vec())] + ); +} + +#[test] +fn coalescing_writer_batches_into_one_flush() { + let sink = RecordingSink::default(); + let mut writer = CoalescingWriter::new(sink.clone(), DEFAULT_DEBOUNCE); + let start = Instant::now(); + + writer.queue("/tmp/a.json", b"1".to_vec(), start); + writer.queue( + "/tmp/a.json", + b"2".to_vec(), + start + Duration::from_millis(50), + ); + writer.queue("/tmp/b.json", b"3".to_vec(), start); + assert_eq!(writer.pending_len(), 2); + assert!(!writer.is_empty()); + + assert_eq!(writer.flush_due(start).expect("未到窗口"), 0); + assert_eq!( + writer.flush_due(start + DEFAULT_DEBOUNCE).expect("到窗口"), + 2 + ); + assert!(writer.is_empty()); + assert_eq!(writer.flush_all().expect("空队列兜底"), 0); + + let written = sink.entries(); + assert_eq!(written.len(), 2); + assert_eq!(written[0].0, PathBuf::from("/tmp/a.json")); + assert_eq!(written[0].1, b"2".to_vec(), "只写最新内容"); +} + +#[test] +fn save_worker_flushes_and_shuts_down() { + let temp = TempDir::new("save-worker"); + let target = temp.child("config/launcher.json"); + let mut worker = SaveWorker::spawn(Duration::from_millis(30)).expect("起线程"); + + worker.queue(&target, b"{\"a\":1}").expect("入队"); + worker.queue(&target, b"{\"a\":2}").expect("入队"); + worker.flush().expect("同步落盘"); + + assert_eq!(temp.read("config/launcher.json"), "{\"a\":2}"); + worker.shutdown().expect("关停"); +} diff --git a/crates/core/tests/setting/deprecated_keys.rs b/crates/core/tests/setting/deprecated_keys.rs new file mode 100644 index 0000000..69845a7 --- /dev/null +++ b/crates/core/tests/setting/deprecated_keys.rs @@ -0,0 +1,142 @@ +//! 键废弃机制:`ui.theme` → `appearance.theme_mode`(tower 键冲突裁决)。 +//! +//! 规则:**读兼容**(旧值按 `THEME_ALIASES` 映射,新键已在则新键优先)+ **写永远走新键** +//! (`set` 旧键直接报错,落盘不含旧键,AI 白名单与工具 JSON Schema 也不含旧键)。 + +use serde_json::{json, Value}; +use unlucky_core::setting::document::{LoadProblem, SettingDocument}; +use unlucky_core::setting::keys::{launcher_schema, LEGACY_THEMES, THEME_ALIASES, THEME_MODES}; +use unlucky_core::setting::schema::{SchemaId, SchemaRef, SchemaVersion, SCHEMA_KEY}; +use unlucky_core::setting::store::SettingStore; + +use crate::common::TempDir; + +fn legacy_file(raw_ui: Value) -> Value { + json!({ + SCHEMA_KEY: SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 0, 0)).url(), + "ui": raw_ui + }) +} + +fn deprecated_problem(problems: &[LoadProblem]) -> Option<&'static str> { + problems.iter().find_map(|problem| match problem { + LoadProblem::DeprecatedKey { + key, replaced_by, .. + } if key == "ui.theme" => Some(*replaced_by), + _ => None, + }) +} + +#[test] +fn legacy_theme_values_are_translated_on_read() { + assert_eq!(THEME_MODES.len(), 3); + assert!(THEME_MODES.contains(&"auto") && THEME_MODES.contains(&"light")); + assert!(THEME_MODES.contains(&"dark")); + assert_eq!( + LEGACY_THEMES.len(), + THEME_ALIASES.len(), + "三个旧值都要有映射" + ); + + for (legacy, expected) in [ + ("system", "auto"), + ("metro_light", "light"), + ("metro_dark", "dark"), + ] { + let (doc, problems) = + SettingDocument::load(&launcher_schema(), &legacy_file(json!({ "theme": legacy }))); + + assert_eq!( + doc.effective_str("appearance.theme_mode"), + Some(expected), + "旧值 {legacy} 应映射成 {expected}" + ); + assert_eq!( + doc.legacy_value("ui.theme").and_then(Value::as_str), + Some(legacy), + "旧键原文要留着给 UI 提示/回滚" + ); + assert_eq!(deprecated_problem(&problems), Some("appearance.theme_mode")); + assert!(!doc.is_dirty(), "读兼容不算用户改动"); + } +} + +#[test] +fn new_key_wins_when_both_present() { + let raw = json!({ + SCHEMA_KEY: SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 0, 0)).url(), + "ui": { "theme": "metro_dark" }, + "appearance": { "theme_mode": "light" } + }); + let (doc, _) = SettingDocument::load(&launcher_schema(), &raw); + assert_eq!(doc.effective_str("appearance.theme_mode"), Some("light")); +} + +#[test] +fn unmapped_legacy_value_is_reported() { + let (doc, problems) = + SettingDocument::load(&launcher_schema(), &legacy_file(json!({ "theme": "neon" }))); + assert_eq!(doc.effective_str("appearance.theme_mode"), Some("neon")); + assert!( + problems.iter().any(|problem| matches!( + problem, + LoadProblem::InvalidValue { key, .. } if key == "appearance.theme_mode" + )), + "映射不过去的旧值要报出来: {problems:?}" + ); +} + +#[test] +fn writing_the_deprecated_key_is_refused() { + let (mut doc, _) = SettingDocument::load(&launcher_schema(), &json!({})); + let err = doc + .set("ui.theme", json!("metro_dark")) + .expect_err("旧键禁止写"); + assert!(err.to_string().contains("appearance.theme_mode"), "{err}"); + assert!(!doc.is_dirty()); +} + +#[test] +fn save_drops_the_old_key_and_writes_the_new_one() { + let temp = TempDir::new("deprecated-save"); + temp.write( + "config/launcher.json", + &legacy_file(json!({ "theme": "metro_dark", "language": "en_us" })).to_string(), + ); + + let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema()); + assert_eq!( + store.effective("appearance.theme_mode"), + Some(&json!("dark")) + ); + + // 随便改一个别的键触发一次保存:旧键消失、新键(映射值)落盘。 + store.set("ui.scale", json!(1.25)).expect("改缩放"); + store.save().expect("保存"); + + let saved: Value = serde_json::from_str(&temp.read("config/launcher.json")).expect("JSON 合法"); + assert_eq!(saved["appearance"]["theme_mode"], json!("dark")); + assert!( + saved["ui"].get("theme").is_none(), + "废弃键不再落盘: {saved}" + ); + assert_eq!(saved["ui"]["language"], json!("en_us")); +} + +#[test] +fn whitelist_and_tool_schema_exclude_the_old_key() { + let schema = launcher_schema(); + assert_eq!(schema.deprecated_keys(), vec!["ui.theme"]); + assert_eq!( + schema.replacement_of("ui.theme"), + Some("appearance.theme_mode") + ); + assert_eq!(schema.replacement_of("appearance.theme_mode"), None); + assert!(!schema.accepts_ai_write("ui.theme"), "废弃键不许 AI 写"); + assert!(!schema.ai_writable_keys().contains(&"ui.theme")); + + let projected = schema.to_json_schema(); + let properties = projected["properties"].as_object().expect("properties"); + assert!(properties.contains_key("appearance.theme_mode")); + assert!(!properties.contains_key("ui.theme")); +} diff --git a/crates/core/tests/setting/dir_migration.rs b/crates/core/tests/setting/dir_migration.rs new file mode 100644 index 0000000..8a0d0c0 --- /dev/null +++ b/crates/core/tests/setting/dir_migration.rs @@ -0,0 +1,185 @@ +//! 数据目录迁移:计划校验、逐分区搬动、收据 + 指针、失败逆序回滚。 + +use std::path::{Path, PathBuf}; + +use unlucky_core::platform::data_dir::{DataMode, ResolvedData}; +use unlucky_core::platform::dir_migration::{self, DirMigrationOptions}; +use unlucky_core::platform::error::PlatformError; +use unlucky_core::platform::layout::{LauncherLayout, Partition}; +use unlucky_core::platform::receipt::{self, ReceiptKind}; +use unlucky_core::platform::redirect; + +use crate::common::TempDir; + +fn resolved(home: &Path) -> ResolvedData { + ResolvedData { + mode: DataMode::Custom, + launcher_home: home.to_path_buf(), + game_home: home.join(".minecraft"), + redirected_from: None, + } +} + +fn seed(home: &Path) { + let layout = LauncherLayout::new(home.to_path_buf()); + layout.ensure().expect("建四分区"); + std::fs::create_dir_all(layout.auth_data()).expect("auth 目录"); + std::fs::create_dir_all(layout.sha1_cache("ab")).expect("缓存分片"); + std::fs::write(layout.launcher_config(), b"{\"x\":1}").expect("写配置"); + std::fs::write(layout.auth_data().join("token.json"), b"{}").expect("写凭据"); + std::fs::write(layout.sha1_cache("ab").join("hash"), b"payload").expect("写缓存"); + std::fs::write(layout.logs().join("launcher.log"), b"log").expect("写日志"); +} + +#[test] +fn plan_orders_partitions_and_skips_missing_ones() { + let temp = TempDir::new("dir-migration-plan"); + seed(&temp.child("old/.ULL")); + let from = resolved(&temp.child("old/.ULL")); + let to = resolved(&temp.child("new/.ULL")); + + let plan = dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect("计划"); + let partitions: Vec = plan.steps.iter().map(|step| step.partition).collect(); + assert_eq!( + partitions, + vec![ + Partition::Config, + Partition::Data, + Partition::Logs, + Partition::Cache + ], + "配置最先、缓存最后" + ); + + let cache_off = DirMigrationOptions { + migrate_cache: false, + }; + let plan = dir_migration::plan(&from, &to, &cache_off).expect("计划"); + assert_eq!(plan.steps.len(), 3); + assert!(plan + .steps + .iter() + .all(|step| step.partition != Partition::Cache)); +} + +#[test] +fn execute_moves_everything_and_leaves_a_pointer() { + let temp = TempDir::new("dir-migration-run"); + let old_home = temp.child("old/.ULL"); + let new_home = temp.child("new/.ULL"); + seed(&old_home); + let from = resolved(&old_home); + let to = resolved(&new_home); + + let plan = dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect("计划"); + let outcome = dir_migration::execute(&plan, 1_700_000_000_000).expect("执行"); + + assert_eq!( + outcome.moved, + vec![ + Partition::Config, + Partition::Data, + Partition::Logs, + Partition::Cache + ] + ); + + let moved = LauncherLayout::new(new_home.clone()); + assert!(moved.launcher_config().is_file(), "配置要搬过去"); + assert!(moved.auth_data().join("token.json").is_file()); + assert!(moved.logs().join("launcher.log").is_file()); + assert!(moved.sha1_cache("ab").join("hash").is_file()); + assert!(!LauncherLayout::new(old_home.clone()).config().exists()); + + let stored = receipt::read(&outcome.receipt_path) + .expect("读收据") + .expect("有收据"); + assert_eq!(stored.kind, ReceiptKind::DataDirMove); + assert_eq!(stored.source, old_home); + assert_eq!(stored.target, new_home); + assert_eq!(stored.applied_at_ms, 1_700_000_000_000); + + let pointer = redirect::read(&old_home) + .expect("读指针") + .expect("旧目录要留指针"); + assert_eq!(pointer.launcher_home, new_home); + assert_eq!(pointer.mode, DataMode::Custom); + assert_eq!(outcome.pointer_path, old_home.join("redirect.json")); +} + +#[test] +fn refuses_same_source_and_target() { + let temp = TempDir::new("dir-migration-same"); + seed(&temp.child("only/.ULL")); + let home = resolved(&temp.child("only/.ULL")); + let err = dir_migration::plan(&home, &home, &DirMigrationOptions::default()) + .expect_err("源目标相同要拒绝"); + assert!( + matches!(err, PlatformError::MigrationRefused { .. }), + "{err}" + ); +} + +#[test] +fn refuses_missing_source_and_non_empty_target() { + let temp = TempDir::new("dir-migration-refuse"); + let from = resolved(&temp.child("gone/.ULL")); + let to = resolved(&temp.child("new/.ULL")); + assert!(matches!( + dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect_err("源不存在"), + PlatformError::MigrationRefused { .. } + )); + + seed(&temp.child("old/.ULL")); + let from = resolved(&temp.child("old/.ULL")); + std::fs::create_dir_all(to.launcher_home.clone()).expect("建目标"); + std::fs::write(to.launcher_home.join("something"), b"x").expect("占位"); + let err = dir_migration::plan(&from, &to, &DirMigrationOptions::default()) + .expect_err("目标非空要拒绝"); + assert!(err.to_string().contains("已有数据"), "{err}"); +} + +#[test] +fn rolls_back_moved_partitions_when_a_later_step_fails() { + let temp = TempDir::new("dir-migration-rollback"); + let old_home = temp.child("old/.ULL"); + let new_home = temp.child("new/.ULL"); + seed(&old_home); + let from = resolved(&old_home); + let to = resolved(&new_home); + + let plan = dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect("计划"); + // 计划之后在目标侧造障碍:data 位置先占成普通文件 → 搬 data 必失败。 + std::fs::create_dir_all(&new_home).expect("建目标根"); + std::fs::write(new_home.join("data"), b"obstruction").expect("造障碍"); + + let err = dir_migration::execute(&plan, 42).expect_err("应当失败"); + assert!( + matches!(err, PlatformError::MigrationRefused { .. }), + "{err}" + ); + + let old = LauncherLayout::new(old_home); + assert!(old.launcher_config().is_file(), "config 必须被回滚搬回来"); + assert!(old.auth_data().join("token.json").is_file()); + assert!(!old.redirect_path().exists(), "失败不该留指针"); + assert!(!new_home.join("config").exists(), "目标侧不该残留已搬分区"); + assert!(!new_home.join("data").is_dir(), "障碍文件保持原样"); +} + +#[test] +fn receipt_anti_replay_uses_source_target_signature() { + let temp = TempDir::new("dir-migration-receipt"); + let receipt_path: PathBuf = temp.child("config/migration-receipt.json"); + let receipt = receipt::MigrationReceipt::data_dir_move( + PathBuf::from("/old/.ULL"), + PathBuf::from("/new/.ULL"), + 1_700_000_000_000, + ); + receipt::write(&receipt_path, &receipt).expect("写收据"); + let loaded = receipt::read(&receipt_path).expect("读").expect("有"); + assert_eq!( + receipt::decide(Some(&loaded), &receipt.source_fingerprint), + receipt::ReplayDecision::SkipSameSource + ); +} diff --git a/crates/core/tests/setting/dirs.rs b/crates/core/tests/setting/dirs.rs new file mode 100644 index 0000000..8579a0a --- /dev/null +++ b/crates/core/tests/setting/dirs.rs @@ -0,0 +1,205 @@ +//! 三模式数据目录解析:Portable / Standard / Custom + 平台落点 + 高级覆盖 + redirect 跟随。 + +use std::path::PathBuf; + +use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, ResolvedData, Resolver}; +use unlucky_core::platform::error::PlatformError; +use unlucky_core::platform::paths::{standard_game_home, standard_launcher_home, MapEnv, Platform}; +use unlucky_core::platform::redirect::{self, RedirectPointer, REDIRECT_SCHEMA_VERSION}; + +use crate::common::TempDir; + +#[test] +fn portable_puts_dot_ull_and_minecraft_beside_exe() { + let env = MapEnv::new().with("APPDATA", "/roaming"); + let resolver = Resolver::with_env(Platform::Windows, Some(PathBuf::from("/games/ULL")), &env); + assert_eq!(resolver.default_mode(), DataMode::Portable); + + let resolved = resolver.resolve(&DataDirRequest::default()).expect("解析"); + assert_eq!(resolved.mode, DataMode::Portable); + assert_eq!(resolved.launcher_home, PathBuf::from("/games/ULL/.ULL")); + assert_eq!(resolved.game_home, PathBuf::from("/games/ULL/.minecraft")); + assert!(resolved.redirected_from.is_none()); +} + +#[test] +fn portable_without_exe_dir_errors() { + let env = MapEnv::new(); + let resolver = Resolver::with_env(Platform::Linux, None, &env); + let err = resolver + .resolve(&DataDirRequest::default()) + .expect_err("应当报错"); + assert!(matches!(err, PlatformError::NoExecutableDir)); +} + +#[test] +fn standard_uses_per_os_locations() { + let windows = MapEnv::new().with("APPDATA", "/roaming"); + assert_eq!( + standard_launcher_home(Platform::Windows, &windows), + Some(PathBuf::from("/roaming/ULL")) + ); + assert_eq!( + standard_game_home(Platform::Windows, &windows), + Some(PathBuf::from("/roaming/.minecraft")) + ); + + let mac = MapEnv::new().with("HOME", "/Users/u"); + assert_eq!( + standard_launcher_home(Platform::MacOs, &mac), + Some(PathBuf::from("/Users/u/Library/Application Support/ULL")) + ); + assert_eq!( + standard_game_home(Platform::MacOs, &mac), + Some(PathBuf::from( + "/Users/u/Library/Application Support/minecraft" + )) + ); + + // XDG 规范要求绝对路径;测试用 temp_dir(两种平台都算绝对路径)。 + let xdg_root = std::env::temp_dir().join("ull-xdg"); + let xdg_text = xdg_root.to_string_lossy().into_owned(); + let linux = MapEnv::new() + .with("HOME", "/home/u") + .with("XDG_DATA_HOME", &xdg_text); + assert_eq!( + standard_launcher_home(Platform::Linux, &linux), + Some(xdg_root.join("ull")) + ); + assert_eq!( + standard_game_home(Platform::Linux, &linux), + Some(PathBuf::from("/home/u/.minecraft")) + ); + + // 相对 XDG_DATA_HOME 不合法 → 回落 ~/.local/share/ull。 + let relative_xdg = MapEnv::new() + .with("HOME", "/home/u") + .with("XDG_DATA_HOME", "relative/xdg"); + assert_eq!( + standard_launcher_home(Platform::Linux, &relative_xdg), + Some(PathBuf::from("/home/u/.local/share/ull")) + ); +} + +#[test] +fn standard_falls_back_when_env_missing() { + let env = MapEnv::new() + .with("HOME", "/home/u") + .with("USERPROFILE", "/home/u"); + assert_eq!( + standard_launcher_home(Platform::Linux, &env), + Some(PathBuf::from("/home/u/.local/share/ull")) + ); + assert_eq!( + standard_launcher_home(Platform::Windows, &env), + Some(PathBuf::from("/home/u/AppData/Roaming/ULL")) + ); + + let empty = MapEnv::new(); + assert_eq!(standard_launcher_home(Platform::Windows, &empty), None); +} + +#[test] +fn standard_mode_resolution_and_macos_default() { + let env = MapEnv::new().with("HOME", "/Users/u"); + let resolver = Resolver::with_env(Platform::MacOs, None, &env); + assert_eq!(resolver.default_mode(), DataMode::Standard); + + let request = DataDirRequest { + mode: DataMode::Standard, + ..DataDirRequest::default() + }; + let resolved = resolver.resolve(&request).expect("解析"); + assert_eq!( + resolved.launcher_home, + PathBuf::from("/Users/u/Library/Application Support/ULL") + ); + assert_eq!( + resolved.game_home, + PathBuf::from("/Users/u/Library/Application Support/minecraft") + ); +} + +#[test] +fn custom_requires_absolute_existing_root_choice() { + let env = MapEnv::new(); + let resolver = Resolver::with_env(Platform::Windows, Some(PathBuf::from("/games")), &env); + let root = std::env::temp_dir().join("ull-custom-root"); + + let ok = DataDirRequest { + mode: DataMode::Custom, + custom_root: Some(root.clone()), + ..DataDirRequest::default() + }; + let resolved = resolver.resolve(&ok).expect("解析"); + assert_eq!(resolved.launcher_home, root.join(".ULL")); + assert_eq!(resolved.game_home, root.join(".minecraft")); + + let relative = DataDirRequest { + mode: DataMode::Custom, + custom_root: Some(PathBuf::from("relative/dir")), + ..DataDirRequest::default() + }; + assert!(matches!( + resolver.resolve(&relative).expect_err("相对路径应报错"), + PlatformError::InvalidCustomRoot { .. } + )); + + let missing = DataDirRequest { + mode: DataMode::Custom, + custom_root: None, + ..DataDirRequest::default() + }; + assert!(resolver.resolve(&missing).is_err()); +} + +#[test] +fn advanced_overrides_split_launcher_and_game_home() { + let env = MapEnv::new(); + let resolver = Resolver::with_env(Platform::Windows, Some(PathBuf::from("/games")), &env); + let request = DataDirRequest { + launcher_home: Some(PathBuf::from("/config/ULL")), + game_home: Some(PathBuf::from("/bigdisk/.minecraft")), + ..DataDirRequest::default() + }; + let resolved = resolver.resolve(&request).expect("解析"); + assert_eq!(resolved.launcher_home, PathBuf::from("/config/ULL")); + assert_eq!(resolved.game_home, PathBuf::from("/bigdisk/.minecraft")); + assert!(!resolved.same_target(&ResolvedData { + mode: DataMode::Portable, + launcher_home: PathBuf::from("/other"), + game_home: PathBuf::from("/bigdisk/.minecraft"), + redirected_from: None, + })); +} + +#[test] +fn effective_resolution_follows_redirect_pointer() { + let temp = TempDir::new("data-dir-redirect"); + let exe_dir = temp.child("portable"); + let moved_to = temp.child("after-move"); + std::fs::create_dir_all(&exe_dir).expect("建目录"); + + let pointer = RedirectPointer { + schema_version: REDIRECT_SCHEMA_VERSION, + mode: DataMode::Custom, + launcher_home: moved_to.clone(), + game_home: moved_to.join(".minecraft"), + migrated_at_ms: 1_700_000_000_000, + }; + redirect::write(&exe_dir.join(".ULL"), &pointer).expect("写指针"); + + let env = MapEnv::new(); + let resolver = Resolver::with_env(Platform::Windows, Some(exe_dir.clone()), &env); + let resolved = resolver + .resolve_default(&DataDirRequest::default()) + .expect("解析"); + + assert_eq!(resolved.launcher_home, moved_to); + assert_eq!(resolved.mode, DataMode::Portable); + assert_eq!( + resolved.redirected_from, + Some(exe_dir.join(".ULL")), + "旧目录要留在记录里,便于 UI 提示「数据已迁走」" + ); +} diff --git a/crates/core/tests/setting/document.rs b/crates/core/tests/setting/document.rs new file mode 100644 index 0000000..6a0b391 --- /dev/null +++ b/crates/core/tests/setting/document.rs @@ -0,0 +1,220 @@ +//! 文档模型:脏字段、未知字段保留、默认值不落盘、嵌套点路径、坏值容错、`$schema` 保留。 + +use serde_json::{json, Value}; +use unlucky_core::setting::document::{LoadProblem, RenderMode, SettingDocument}; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::schema::{SchemaRef, SchemaVersion, SCHEMA_KEY, SCHEMA_VERSION_KEY}; + +fn schema_url(version: SchemaVersion) -> String { + SchemaRef::new(unlucky_core::setting::schema::SchemaId::LAUNCHER, version).url() +} + +fn loaded(raw: Value) -> (SettingDocument, Vec) { + SettingDocument::load(&launcher_schema(), &raw) +} + +#[test] +fn reads_nested_keys_and_keeps_unknown_subtrees() { + let raw = json!({ + SCHEMA_KEY: schema_url(launcher_version()), + SCHEMA_VERSION_KEY: 1, + "download": { "concurrency": 16 }, + "ui": { "language": "en_us", "unknown_ui": true }, + "future_section": { "nested": { "flag": true } } + }); + + let (doc, problems) = loaded(raw); + assert_eq!(doc.effective_i64("download.concurrency"), Some(16)); + assert_eq!(doc.effective_str("ui.language"), Some("en_us")); + + let unknown: Vec<&str> = doc.unknown_keys().collect(); + assert!(unknown.contains(&"ui.unknown_ui"), "{unknown:?}"); + assert!( + unknown.contains(&"future_section.nested.flag"), + "未知键清单是叶子粒度: {unknown:?}" + ); + + let reported: Vec<&str> = problems + .iter() + .filter_map(|problem| match problem { + LoadProblem::UnknownKey { key } => Some(key.as_str()), + _ => None, + }) + .collect(); + assert!(reported.contains(&"ui.unknown_ui"), "{problems:?}"); + assert!( + reported.contains(&"future_section"), + "陌生子树按最上层节点报一次,不做逐叶展开: {problems:?}" + ); + + let rendered = doc.to_value(); + assert_eq!(rendered[SCHEMA_VERSION_KEY], json!(1)); + assert_eq!( + rendered["future_section"]["nested"]["flag"], + json!(true), + "未知字段必须原样回写" + ); + assert_eq!(rendered["ui"]["unknown_ui"], json!(true)); +} + +#[test] +fn defaults_never_reach_the_file() { + let (doc, _) = loaded(json!({ SCHEMA_KEY: schema_url(launcher_version()) })); + // 生效值有默认,但落盘视图里没有这些键。 + assert_eq!(doc.effective_i64("download.concurrency"), Some(8)); + assert_eq!(doc.effective_bool("ai.enabled"), Some(true)); + assert!(doc.get("download.concurrency").is_none()); + let rendered = doc.to_value(); + assert!(rendered.get("download").is_none(), "{rendered}"); + // 无默认值的路径键 → None。 + assert!(doc.effective_str("java.selected_path").is_none()); +} + +#[test] +fn tracks_dirty_keys_and_renders_patches() { + let raw = json!({ + SCHEMA_KEY: schema_url(launcher_version()), + "download": { "concurrency": 8, "retry_limit": 2 } + }); + let (mut doc, _) = loaded(raw); + assert!(!doc.is_dirty()); + + doc.set("download.concurrency", json!(32)).expect("写"); + doc.set("appearance.theme_mode", json!("dark")).expect("写"); + + let mut dirty: Vec<&str> = doc.dirty_keys().collect(); + dirty.sort_unstable(); + assert_eq!(dirty, vec!["appearance.theme_mode", "download.concurrency"]); + + let patch = doc.dirty_patch(); + assert_eq!(patch["download"]["concurrency"], json!(32)); + assert_eq!(patch["appearance"]["theme_mode"], json!("dark")); + assert!( + patch["download"].get("retry_limit").is_none(), + "非脏字段不进补丁" + ); + + let dirty_view = doc.render(RenderMode::DirtyOnly); + assert_eq!(dirty_view["download"]["concurrency"], json!(32)); + assert!(dirty_view["download"].get("retry_limit").is_none()); + + doc.mark_clean(); + assert!(!doc.is_dirty()); + assert_eq!(doc.dirty_patch(), json!({})); +} + +#[test] +fn invalid_values_are_preserved_with_a_problem() { + let raw = json!({ + SCHEMA_KEY: schema_url(launcher_version()), + "download": { "concurrency": 9999, "retry_limit": "many" } + }); + let (doc, problems) = loaded(raw); + assert_eq!( + doc.get("download.concurrency").and_then(Value::as_i64), + Some(9999), + "坏值原样保留(用户能看见自己写坏了什么)" + ); + let invalid: Vec<&str> = problems + .iter() + .filter_map(|problem| match problem { + LoadProblem::InvalidValue { key, .. } => Some(key.as_str()), + _ => None, + }) + .collect(); + assert!(invalid.contains(&"download.concurrency"), "{problems:?}"); + assert!(invalid.contains(&"download.retry_limit"), "{problems:?}"); +} + +#[test] +fn schema_version_mirror_mismatch_is_reported_but_schema_wins() { + let raw = json!({ + SCHEMA_KEY: schema_url(launcher_version()), + SCHEMA_VERSION_KEY: 7 + }); + let (doc, problems) = loaded(raw); + assert_eq!(doc.version(), launcher_version()); + assert!(problems.iter().any(|problem| matches!( + problem, + LoadProblem::SchemaVersionMismatch { + declared: 7, + expected: 1 + } + ))); +} + +#[test] +fn newer_patch_preserves_original_schema_string() { + // 同 minor、更高 patch(launcher schema 当前 1.3.0)。 + let newer = SchemaVersion::new(1, 3, 7); + let url = schema_url(newer); + let (doc, _) = loaded(json!({ SCHEMA_KEY: url.clone() })); + assert_eq!( + doc.preserve_schema(), + Some(url.as_str()), + "同 minor 更高 patch 要保留原 $schema" + ); + assert_eq!(doc.to_value()[SCHEMA_KEY], json!(url)); + assert_eq!(doc.version(), newer); +} + +#[test] +fn wrong_shape_for_known_prefix_is_reported() { + let raw = json!({ + SCHEMA_KEY: schema_url(launcher_version()), + "download": "oops" + }); + let (doc, problems) = loaded(raw); + assert!( + problems.iter().any(|problem| matches!( + problem, + LoadProblem::InvalidShape { key, .. } if key == "download" + )), + "{problems:?}" + ); + assert!(doc.get("download").is_none()); + // 原始坏结构不进 values,也不会被当成未知键回写。 + assert!(doc.to_value().get("download").is_none()); +} + +#[test] +fn non_object_json_yields_problem_and_empty_doc() { + let (doc, problems) = loaded(json!([1, 2, 3])); + assert!(!problems.is_empty()); + assert_eq!(doc.effective_i64("download.concurrency"), Some(8)); +} + +#[test] +fn set_rejects_unknown_keys_and_out_of_range_values() { + let (mut doc, _) = loaded(json!({})); + assert!(doc.set("nope.key", json!(1)).is_err()); + assert!(doc.set("download.concurrency", json!(0)).is_err()); + assert!(doc + .set("appearance.theme_mode", json!("solarized")) + .is_err()); + assert!(!doc.is_dirty(), "校验失败不能标脏"); +} + +#[test] +fn unknown_schema_prefix_reports_problem_and_keeps_values() { + let raw = json!({ + SCHEMA_KEY: "https://unlucky-launcher.local/schema/launcher/1.2.0.json", + "core": { "data_mode": "standard" }, + }); + let (doc, problems) = loaded(raw); + // 值照读(旧实现按「没有声明」整份装默认值)。 + assert_eq!(doc.effective_str("core.data_mode"), Some("standard")); + let found = problems.iter().find_map(|problem| match problem { + LoadProblem::UnknownSchema { url } => Some(url.as_str()), + _ => None, + }); + assert_eq!( + found, + Some("https://unlucky-launcher.local/schema/launcher/1.2.0.json"), + "{problems:?}" + ); +} + +fn launcher_version() -> SchemaVersion { + unlucky_core::setting::keys::launcher_version() +} diff --git a/crates/core/tests/setting/isolation_keys.rs b/crates/core/tests/setting/isolation_keys.rs new file mode 100644 index 0000000..5efe2d2 --- /dev/null +++ b/crates/core/tests/setting/isolation_keys.rs @@ -0,0 +1,250 @@ +//! D20 隔离键的 schema 契约:全局 `instances.mode`、实例 `isolation`、`lastModeChange`、 +//! 恒共享层不可配、旧配置缺键即默认值(不需要迁移步骤)。 + +use serde_json::json; +use unlucky_core::game::install::instance::{InstanceConfig, Isolation, OverrideKey}; +use unlucky_core::game::isolation::{observe_mode, IsolationMode}; +use unlucky_core::game::GameLayout; +use unlucky_core::setting::document::{LoadProblem, RenderMode, SettingDocument}; +use unlucky_core::setting::instance_keys::{ + instance_schema, instance_version, ISOLATION_OVERRIDES, ISOLATION_RESOLVED, OVERRIDE_KEYS, +}; +use unlucky_core::setting::keys::{launcher_schema, launcher_version, INSTANCE_MODES}; +use unlucky_core::setting::schema::{SchemaId, SchemaVersion}; +use unlucky_core::setting::spec::{Sensitivity, ValueKind}; +use unlucky_core::setting::store::SettingStore; + +use super::common::TempDir; + +#[test] +fn global_mode_key_defaults_to_isolated() { + let schema = launcher_schema(); + let spec = schema.spec("instances.mode").expect("instances.mode 键"); + assert_eq!(spec.default, json!("isolated"), "D20 默认隔离"); + assert_eq!(spec.kind, ValueKind::Enum(INSTANCE_MODES)); + assert_eq!( + spec.ai.sensitivity, + Sensitivity::High, + "切档会改变存档可见性,AI 改必须弹窗确认" + ); + assert!(schema.validate("instances.mode", &json!("shared")).is_ok()); + assert!(schema + .validate("instances.mode", &json!("isolated")) + .is_ok()); + assert!(schema + .validate("instances.mode", &json!("per_version")) + .is_err()); + assert!(schema.validate("instances.mode", &json!("custom")).is_err()); +} + +#[test] +fn shared_layers_are_not_configurable() { + let schema = launcher_schema(); + for key in ["shared.versions", "shared.assets", "shared.libraries"] { + assert!( + !schema.is_known(key), + "{key} 是恒共享层,不该出现在可配键表里" + ); + } +} + +#[test] +fn instance_isolation_defaults_to_inherit() { + let schema = instance_schema(); + let spec = schema.spec("isolation").expect("isolation 键"); + assert_eq!(spec.default, json!("inherit")); + assert_eq!(spec.kind, ValueKind::Enum(ISOLATION_OVERRIDES)); + for value in ["inherit", "isolated", "shared", "custom"] { + assert!( + schema.validate("isolation", &json!(value)).is_ok(), + "{value}" + ); + } + assert!(schema.validate("isolation", &json!("per_version")).is_err()); +} + +#[test] +fn inherit_never_reaches_the_file() { + // 默认值不落盘:空文档渲染出来只应该有 $schema / schemaVersion。 + let schema = instance_schema(); + let (document, problems) = SettingDocument::load(&schema, &json!({})); + assert!(problems.is_empty(), "{problems:?}"); + let rendered = document.render(RenderMode::Full); + assert!(rendered.get("isolation").is_none(), "{rendered}"); + assert!(rendered.get("lastModeChange").is_none(), "{rendered}"); + let dirty = document.render(RenderMode::DirtyOnly); + assert!(dirty.get("isolation").is_none(), "{dirty}"); +} + +#[test] +fn last_mode_change_keys_are_bookkeeping_only() { + let schema = instance_schema(); + for key in [ + "lastModeChange.at", + "lastModeChange.from", + "lastModeChange.to", + "lastModeChange.probe.isolatedSide", + "lastModeChange.probe.sharedSide", + "lastModeChange.warned", + ] { + assert!(schema.is_known(key), "{key} 必须登记"); + assert!(!schema.accepts_ai_write(key), "{key} 不许 AI 写"); + } + assert!(schema + .validate("lastModeChange.probe.isolatedSide", &json!(12)) + .is_ok()); + assert!(schema + .validate("lastModeChange.probe.isolatedSide", &json!(-1)) + .is_err()); + assert!(schema + .validate("lastModeChange.warned", &json!(true)) + .is_ok()); + assert!(schema.validate("lastModeChange.warned", &json!(1)).is_err()); + assert!(schema + .validate("lastModeChange.from", &json!("shared")) + .is_ok()); + assert!(schema + .validate("lastModeChange.from", &json!("inherit")) + .is_err()); + assert_eq!(ISOLATION_RESOLVED, ["isolated", "shared", "custom"]); +} + +#[test] +fn overrides_key_is_an_enum_whitelist() { + let schema = instance_schema(); + assert!(schema.validate("overrides", &json!([])).is_ok()); + assert!(schema + .validate("overrides", &json!(["maxMemory", "runningDirectory"])) + .is_ok()); + assert!( + schema + .validate("overrides", &json!(["max_memory"])) + .is_err(), + "旧 snake_case 拼写不许过(拼错会静默失效)" + ); + assert!(schema.validate("overrides", &json!([1])).is_err()); + assert_eq!(OVERRIDE_KEYS, ["maxMemory", "javaType", "runningDirectory"]); +} + +#[test] +fn schema_versions_register_the_new_keys_without_migration() { + // 1.3.0 登记 D17 的「外观(高级)」键(1.2.0 是 D23 的 JDK 落点/优先级键)。 + assert_eq!(launcher_version(), SchemaVersion::new(1, 3, 0)); + assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0)); + assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER); + // major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。 + assert_eq!(launcher_version().major, 1); + assert_eq!(instance_version().major, 1); +} + +#[test] +fn old_launcher_file_without_new_keys_loads_with_defaults() { + let temp = std::env::temp_dir().join(format!( + "ull-setting-d20-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) + )); + std::fs::create_dir_all(&temp).expect("临时目录"); + let path = temp.join("launcher.json"); + // 旧版本写的文件:只有 $schema 1.0.0 和一个老键,没有 instances.mode。 + std::fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "$schema": "https://schemas.unlucky-launcher.dev/ull/launcher/1.0.0", + "schemaVersion": 1, + "ui": { "scale": 1.25 } + })) + .expect("序列化"), + ) + .expect("写文件"); + + let store = SettingStore::load(&path, launcher_schema()); + assert!(store.access().can_read(), "1.0.0 文件必须可读"); + assert!(store.problems().is_empty(), "{:?}", store.problems()); + assert_eq!( + store.doc().effective_str("instances.mode"), + Some("isolated"), + "缺键取 D20 默认值" + ); + assert_eq!(store.doc().effective_f64("ui.scale"), Some(1.25)); + let _ = std::fs::remove_dir_all(&temp); +} + +#[test] +fn setting_lists_agree_with_the_game_enums() { + // schema 白名单与 game 侧枚举必须一一对应(两处字面量漂移就是静默不生效)。 + let mut modes: Vec<&str> = [IsolationMode::Isolated, IsolationMode::Shared] + .iter() + .map(|mode| mode.as_str()) + .collect(); + modes.sort_unstable(); + let mut declared = INSTANCE_MODES.to_vec(); + declared.sort_unstable(); + assert_eq!(modes, declared); + + for value in ISOLATION_OVERRIDES + .iter() + .filter(|value| **value != "inherit") + { + assert!( + Isolation::parse(value).is_some(), + "{value} 在 game 侧解析不出来" + ); + assert_eq!(Isolation::parse(value).map(Isolation::as_str), Some(*value)); + } + for value in ISOLATION_RESOLVED { + assert!(Isolation::parse(value).is_some(), "{value}"); + } +} + +#[test] +fn writer_output_loads_without_unknown_or_invalid_keys() { + // 回归 P2-1(review r1):写入者(InstanceConfig + observe_mode)产出的键必须被 schema 全认。 + // 之前 `lastModeChange.warned` 没登记,document 层会把真实文件标成 UnknownKey—— + // 这正是「schema 只做写入者白名单视图」这条契约的裂缝。 + let temp = TempDir::new("d20-writer-shape"); + let game = GameLayout::new(temp.child("game")); + let mut config = InstanceConfig::new("11111111-2222-4333-8444-555555555555", "1.20.1"); + config.isolation = Some(Isolation::Custom); + config.game_dir = Some(temp.child("elsewhere")); + config.overrides = vec![OverrideKey::MaxMemory]; + observe_mode( + &mut config, + &game, + "2026-10-30T00:00:00Z", + Isolation::Custom, + ) + .expect("观察"); + + let raw: serde_json::Value = + serde_json::from_str(&config.to_json_string().expect("序列化")).expect("合法 JSON"); + // 写入者确实输出了 warned(P2-1 的前提)。 + assert_eq!(raw["lastModeChange"]["warned"], json!(true)); + + let (document, problems) = SettingDocument::load(&instance_schema(), &raw); + let mut unknown: Vec = Vec::new(); + let mut invalid: Vec = Vec::new(); + for problem in &problems { + match problem { + LoadProblem::UnknownKey { key } => unknown.push(key.clone()), + LoadProblem::InvalidValue { key, reason } => invalid.push(format!("{key}: {reason}")), + _ => {} + } + } + assert!(unknown.is_empty(), "写入者的键没登记进 schema:{unknown:?}"); + assert!( + invalid.is_empty(), + "写入者的值过不了 schema 校验:{invalid:?}" + ); + assert_eq!( + document.get("lastModeChange.warned"), + Some(&json!(true)), + "记账键要能读回" + ); + // 落盘视图不能把它当「未知键」重复输出。 + let rendered = document.render(RenderMode::Full); + assert_eq!(rendered["lastModeChange"]["warned"], json!(true)); + assert_eq!(rendered["overrides"], json!(["maxMemory"])); +} diff --git a/crates/core/tests/setting/java_install_keys.rs b/crates/core/tests/setting/java_install_keys.rs new file mode 100644 index 0000000..f5d6cfb --- /dev/null +++ b/crates/core/tests/setting/java_install_keys.rs @@ -0,0 +1,119 @@ +//! D23 新增的两个设置键:`java.installRoot` / `java.vendorPriority`。 +//! +//! 三件事要说清: +//! 1. 两个键都登记在 launcher schema 里(`ull config get` 能看到、AI 白名单受策略约束); +//! 2. `java.vendorPriority` 的允许值集合与 `game::java::vendor::VENDOR_IDS` **同源** +//! (setting 层不依赖 game 层,靠这条契约测试固定,避免两处漂移); +//! 3. schema 版本推到 1.2.0 但 major 仍为 1 → 不需要迁移步骤(默认值不落盘)。 + +use serde_json::json; +use unlucky_core::game::java::vendor::VENDOR_IDS; +use unlucky_core::setting::keys::{ + launcher_schema, launcher_version, JAVA_INSTALL_ROOT_UNSET, JDK_VENDORS, +}; +use unlucky_core::setting::schema::{SchemaId, SchemaRef, SchemaVersion}; +use unlucky_core::setting::store::SettingStore; + +#[test] +fn both_d23_keys_are_registered_with_their_defaults() { + let schema = launcher_schema(); + assert_eq!( + schema.default_value("java.installRoot"), + Some(&json!(JAVA_INSTALL_ROOT_UNSET)) + ); + assert_eq!( + schema.default_value("java.vendorPriority"), + Some(&json!([])) + ); + // 空串/空数组都有明确语义:installRoot 空 = 按数据目录模式派生; + // vendorPriority 空 = 按地区默认序(国内 Temurin→Oracle→MS→Zulu)。 + assert!(schema.spec("java.installRoot").is_some()); + assert!(schema.spec("java.vendorPriority").is_some()); +} + +#[test] +fn vendor_priority_whitelist_matches_the_vendor_module() { + assert_eq!(JDK_VENDORS, VENDOR_IDS, "两处必须同一份集合"); + let schema = launcher_schema(); + assert!(schema + .validate("java.vendorPriority", &json!(["oracle"])) + .is_ok()); + assert!(schema + .validate("java.vendorPriority", &json!(["temurin", "zulu"])) + .is_ok()); + assert!(schema + .validate("java.vendorPriority", &json!(["graalvm"])) + .is_err()); + assert!(schema + .validate("java.vendorPriority", &json!([1, 2])) + .is_err()); + assert!(schema + .validate("java.vendorPriority", &json!("microsoft")) + .is_err()); +} + +#[test] +fn install_root_accepts_a_path_and_keeps_empty_as_unset() { + let schema = launcher_schema(); + assert!(schema + .validate("java.installRoot", &json!("D:/jdk")) + .is_ok()); + // 空串就是默认值,语义是「没设覆盖键」(不是错误):所以类型是 text 而不是 path + // (Path 类型不接受空串,而这里需要一个合法的「未设置」值)。 + assert!(schema.validate("java.installRoot", &json!("")).is_ok()); + assert!(schema.validate("java.installRoot", &json!(5)).is_err()); +} + +#[test] +fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() { + assert_eq!(launcher_version(), SchemaVersion::new(1, 3, 0)); + assert_eq!( + launcher_version().major, + 1, + "major 不变 → 迁移链(按 major 走)不需要新步骤" + ); +} + +#[test] +fn an_old_config_file_without_the_new_keys_still_loads() { + let dir = std::env::temp_dir().join(format!( + "ull-java-keys-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()) + )); + std::fs::create_dir_all(&dir).expect("建临时目录"); + let path = dir.join("launcher.json"); + // 1.1.0 的文件(真实 $schema 前缀):没有 java.installRoot / java.vendorPriority, + // 且 core.data_mode 故意写非默认值 standard —— 证明值是「读出来的」不是「默认值撞上的」。 + let old_url = SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 1, 0)).url(); + std::fs::write( + &path, + format!(r#"{{"$schema":"{old_url}","schemaVersion":1,"core":{{"data_mode":"standard"}}}}"#), + ) + .expect("写旧配置"); + + let store = SettingStore::load(&path, launcher_schema()); + assert_eq!( + store.doc().effective_str("core.data_mode"), + Some("standard"), + "旧文件的值必须照读(不是碰巧等于默认值)" + ); + // 缺键取默认值,不报错、不写盘(「默认值不落盘」)。 + assert_eq!( + store.doc().effective_str("java.installRoot"), + Some(JAVA_INSTALL_ROOT_UNSET) + ); + assert_eq!( + store.effective("java.vendorPriority"), + Some(&json!([])), + "缺键 → 空数组 → 按地区默认序" + ); + assert!( + store.problems().is_empty(), + "只缺新键不算问题:{:?}", + store.problems() + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/core/tests/setting/layout.rs b/crates/core/tests/setting/layout.rs new file mode 100644 index 0000000..897099b --- /dev/null +++ b/crates/core/tests/setting/layout.rs @@ -0,0 +1,128 @@ +//! 四分区布局、路径拼接、实例 id 白名单、可搬运路径字符串。 + +use std::path::PathBuf; + +use unlucky_core::platform::layout::{ + is_valid_instance_id, LauncherLayout, Partition, CONFIG_DIR, GAME_HOME_DIR, INSTANCES_DIR, + LAUNCHER_CONFIG_FILE, LAUNCHER_HOME_DIR, MIGRATION_RECEIPT_FILE, PROFILES_CONFIG_FILE, + REDIRECT_FILE, +}; + +use crate::common::TempDir; + +#[test] +fn home_dir_names_match_design() { + assert_eq!(LAUNCHER_HOME_DIR, ".ULL"); + assert_eq!(GAME_HOME_DIR, ".minecraft"); + assert_eq!(CONFIG_DIR, "config"); + assert_eq!(INSTANCES_DIR, "instances"); + assert_eq!(LAUNCHER_CONFIG_FILE, "launcher.json"); + assert_eq!(PROFILES_CONFIG_FILE, "profiles.json"); + assert_eq!(REDIRECT_FILE, "redirect.json"); + assert_eq!(MIGRATION_RECEIPT_FILE, "migration-receipt.json"); +} + +#[test] +fn exposes_four_partitions() { + let layout = LauncherLayout::new("/home/u/.ULL"); + assert_eq!(layout.home(), std::path::Path::new("/home/u/.ULL")); + assert_eq!(layout.config(), PathBuf::from("/home/u/.ULL/config")); + assert_eq!(layout.data(), PathBuf::from("/home/u/.ULL/data")); + assert_eq!(layout.cache(), PathBuf::from("/home/u/.ULL/cache")); + assert_eq!(layout.logs(), PathBuf::from("/home/u/.ULL/logs")); + + assert_eq!(Partition::ALL.len(), 4); + assert_eq!(Partition::Config.dir_name(), "config"); + assert!(Partition::Cache.is_disposable()); + assert!(!Partition::Config.is_disposable()); +} + +#[test] +fn builds_config_and_cache_paths() { + let layout = LauncherLayout::new("/home/u/.ULL"); + assert_eq!( + layout.launcher_config(), + PathBuf::from("/home/u/.ULL/config/launcher.json") + ); + assert_eq!( + layout.profiles_config(), + PathBuf::from("/home/u/.ULL/config/profiles.json") + ); + assert_eq!( + layout.sha1_cache("ab"), + PathBuf::from("/home/u/.ULL/cache/sha1/ab") + ); + assert_eq!( + layout.etag_cache(), + PathBuf::from("/home/u/.ULL/cache/etag.json") + ); + assert_eq!( + layout.meta_cache(), + PathBuf::from("/home/u/.ULL/cache/meta") + ); + assert_eq!( + layout.java_managed(), + PathBuf::from("/home/u/.ULL/data/java-managed") + ); + assert_eq!(layout.auth_data(), PathBuf::from("/home/u/.ULL/data/auth")); + assert_eq!( + layout.agent_sessions(), + PathBuf::from("/home/u/.ULL/data/agent/sessions") + ); + assert_eq!( + layout.redirect_path(), + PathBuf::from("/home/u/.ULL/redirect.json") + ); + assert_eq!( + layout.migration_receipt(), + PathBuf::from("/home/u/.ULL/config/migration-receipt.json") + ); +} + +#[test] +fn instance_config_rejects_path_traversal() { + let layout = LauncherLayout::new("/home/u/.ULL"); + let id = "6f9619ff-8b86-d011-b42d-00c04fc964ff"; + assert!(is_valid_instance_id(id)); + assert_eq!( + layout.instance_config(id).expect("合法 id"), + PathBuf::from(format!("/home/u/.ULL/config/instances/{id}.json")) + ); + assert!(layout.instance_config("../escape").is_err()); + assert!(layout.instance_config("").is_err()); + assert!(layout.instance_config("a/b").is_err()); +} + +#[test] +fn ensure_creates_all_partitions() { + let temp = TempDir::new("layout"); + let layout = LauncherLayout::new(temp.child(".ULL")); + layout.ensure().expect("建目录"); + for partition in Partition::ALL { + assert!( + layout.partition(partition).is_dir(), + "{}", + partition.as_str() + ); + } +} + +#[test] +fn portable_path_strings_keep_absolute_and_normalise_relative() { + use unlucky_core::platform::paths::{from_portable_string, to_portable_string}; + + assert_eq!( + to_portable_string(std::path::Path::new(".minecraft")), + ".minecraft" + ); + assert_eq!( + to_portable_string(std::path::Path::new("versions\\1.20.1")), + "versions/1.20.1" + ); + let absolute = std::env::temp_dir(); + assert_eq!(to_portable_string(&absolute), absolute.to_string_lossy()); + assert_eq!( + from_portable_string("versions/1.20.1"), + PathBuf::from("versions/1.20.1") + ); +} diff --git a/crates/core/tests/setting/main.rs b/crates/core/tests/setting/main.rs new file mode 100644 index 0000000..4f54b61 --- /dev/null +++ b/crates/core/tests/setting/main.rs @@ -0,0 +1,21 @@ +//! M12(core::setting + platform)集成测试入口。 +//! +//! cargo 把 `tests//main.rs` 识别为独立的 test target(target 名 = `setting`), +//! 因此本目录下每个子模块就是一个测试主题,避免单文件超过 400 行。 + +mod appearance_keys; +mod atomic_write; +mod common; +mod debounce; +mod deprecated_keys; +mod dir_migration; +mod dirs; +mod document; +mod isolation_keys; +mod java_install_keys; +mod layout; +mod migration_chain; +mod redirect; +mod schema_compat; +mod spec_ai; +mod store; diff --git a/crates/core/tests/setting/migration_chain.rs b/crates/core/tests/setting/migration_chain.rs new file mode 100644 index 0000000..3b03401 --- /dev/null +++ b/crates/core/tests/setting/migration_chain.rs @@ -0,0 +1,295 @@ +//! 迁移链:N→N+1 顺序校验、真实迁移跑通、收据闸门防重放、store 接线。 + +use std::path::PathBuf; + +use serde_json::{json, Map, Value}; +use unlucky_core::platform::receipt::{ + decide, fingerprint_bytes, fingerprint_file, read as read_receipt, MigrationReceipt, + ReceiptKind, ReplayDecision, +}; +use unlucky_core::setting::catalog::SettingSchema; +use unlucky_core::setting::migrate::{ + MigrationChain, MigrationGate, MigrationReport, SettingMigration, +}; +use unlucky_core::setting::schema::{ + SchemaId, SchemaRef, SchemaVersion, SCHEMA_KEY, SCHEMA_VERSION_KEY, +}; +use unlucky_core::setting::spec::{AiPolicy, SettingSpec}; +use unlucky_core::setting::store::SettingStore; + +use crate::common::TempDir; + +/// 1.0.0 → 2.0.0:`download.concurrency` 改名 `download.threads`。 +struct RenameThreads; + +impl SettingMigration for RenameThreads { + fn from(&self) -> SchemaVersion { + SchemaVersion::new(1, 0, 0) + } + + fn to(&self) -> SchemaVersion { + SchemaVersion::new(2, 0, 0) + } + + fn describe(&self) -> &'static str { + "download.concurrency → download.threads" + } + + fn apply(&self, root: &mut Map) -> Result<(), String> { + let Some(download) = root.get_mut("download").and_then(Value::as_object_mut) else { + return Ok(()); + }; + if let Some(value) = download.remove("concurrency") { + download.insert("threads".to_owned(), value); + } + Ok(()) + } +} + +/// 2.0.0 → 3.0.0:补一个镜像偏好默认值。 +struct AddMirrorPreference; + +impl SettingMigration for AddMirrorPreference { + fn from(&self) -> SchemaVersion { + SchemaVersion::new(2, 0, 0) + } + + fn to(&self) -> SchemaVersion { + SchemaVersion::new(3, 0, 0) + } + + fn describe(&self) -> &'static str { + "补齐 download.mirror_preference" + } + + fn apply(&self, root: &mut Map) -> Result<(), String> { + let Some(download) = root.get_mut("download").and_then(Value::as_object_mut) else { + return Err("缺少 download 对象".to_owned()); + }; + download + .entry("mirror_preference".to_owned()) + .or_insert_with(|| json!("auto")); + Ok(()) + } +} + +fn two_step_chain() -> MigrationChain { + let mut chain = MigrationChain::new(); + chain.push(Box::new(RenameThreads)).expect("第一环"); + chain.push(Box::new(AddMirrorPreference)).expect("第二环"); + chain +} + +#[test] +fn chain_requires_contiguous_major_steps() { + let mut chain = MigrationChain::new(); + chain + .push(Box::new(AddMirrorPreference)) + .expect("首环随意起点"); + assert_eq!(chain.len(), 1); + + let err = chain.push(Box::new(RenameThreads)).expect_err("逆序要报错"); + assert!(err.to_string().contains("顺序错误"), "{err}"); + assert_eq!(chain.latest(), Some(SchemaVersion::new(3, 0, 0))); + assert_eq!(chain.describe().len(), 1); + assert!(!chain.is_empty()); +} + +#[test] +fn duplicate_and_skipping_steps_are_rejected() { + let mut chain = MigrationChain::new(); + chain.push(Box::new(RenameThreads)).expect("首环"); + assert!(chain.push(Box::new(RenameThreads)).is_err(), "重复起始版本"); + assert!(chain.push(Box::new(AddMirrorPreference)).is_ok()); + + struct SkipMajor; + impl SettingMigration for SkipMajor { + fn from(&self) -> SchemaVersion { + SchemaVersion::new(3, 0, 0) + } + fn to(&self) -> SchemaVersion { + SchemaVersion::new(5, 0, 0) + } + fn describe(&self) -> &'static str { + "跳版本(非法)" + } + fn apply(&self, _root: &mut Map) -> Result<(), String> { + Ok(()) + } + } + let err = chain + .push(Box::new(SkipMajor)) + .expect_err("跨两个 major 要报错"); + assert!(err.to_string().contains("major"), "{err}"); +} + +#[test] +fn migrates_sequentially_and_bumps_schema_version() { + let chain = two_step_chain(); + let mut root = json!({ "download": { "concurrency": 32 } }) + .as_object() + .cloned() + .expect("对象"); + root.insert(SCHEMA_VERSION_KEY.to_owned(), json!(1)); + + let report = chain + .migrate(SchemaVersion::new(1, 0, 0), &mut root) + .expect("迁移"); + assert_eq!( + report, + MigrationReport { + from: SchemaVersion::new(1, 0, 0), + to: SchemaVersion::new(3, 0, 0), + applied: vec![ + "download.concurrency → download.threads", + "补齐 download.mirror_preference", + ], + } + ); + assert!(!report.is_noop()); + assert_eq!(root["download"]["threads"], json!(32)); + assert_eq!(root["download"]["mirror_preference"], json!("auto")); + assert!(root["download"].get("concurrency").is_none()); + assert_eq!(root[SCHEMA_VERSION_KEY], json!(3)); +} + +#[test] +fn unknown_start_version_breaks_the_chain() { + let chain = two_step_chain(); + let mut root = Map::new(); + let err = chain + .migrate(SchemaVersion::new(9, 0, 0), &mut root) + .expect_err("没有 9.0.0 起始步"); + assert!(err.to_string().contains("链里没有"), "{err}"); + assert!(chain.plan_from(SchemaVersion::new(2, 0, 0)).is_ok()); +} + +#[test] +fn failing_step_reports_migration_error() { + let mut chain = MigrationChain::new(); + chain.push(Box::new(RenameThreads)).expect("第一环"); + chain.push(Box::new(AddMirrorPreference)).expect("第二环"); + + let mut root = json!({ "download": 3 }).as_object().cloned().expect("对象"); + let err = chain + .migrate(SchemaVersion::new(1, 0, 0), &mut root) + .expect_err("download 不是对象"); + assert!(err.to_string().contains("迁移"), "{err}"); +} + +#[test] +fn receipt_gate_prevents_replay() { + let temp = TempDir::new("migration-gate"); + let source = temp.write("legacy/hmcl.json", "{\"x\":1}"); + let target = temp.child("config/launcher.json"); + let fingerprint = fingerprint_file(&source).expect("算指纹").expect("文件在"); + + let receipt_path = temp.child("config/migration-receipt.json"); + let gate = MigrationGate::new(&receipt_path, 1_700_000_000_000); + assert_eq!(gate.now_ms(), 1_700_000_000_000); + assert!(gate.should_run(&fingerprint).expect("无收据")); + + let receipt = + MigrationReceipt::legacy_config(source, target, fingerprint.clone(), gate.now_ms()); + gate.record(&receipt).expect("写收据"); + assert!(!gate.should_run(&fingerprint).expect("同源指纹应跳过")); + assert_eq!( + gate.evaluate(&fingerprint).expect("评估"), + ReplayDecision::SkipSameSource + ); + assert_eq!( + gate.evaluate("其他指纹").expect("评估"), + ReplayDecision::ReplayChangedSource + ); + + let stored = read_receipt(&receipt_path) + .expect("读收据") + .expect("有收据"); + assert_eq!(stored.kind, ReceiptKind::LegacyConfig); + assert_eq!(stored.source_fingerprint, fingerprint); +} + +#[test] +fn receipt_decisions_and_fingerprints() { + let receipt = MigrationReceipt::legacy_config( + PathBuf::from("/a"), + PathBuf::from("/b"), + fingerprint_bytes(b"abc"), + 7, + ); + assert_eq!(decide(None, "x"), ReplayDecision::ReplayNoReceipt); + assert_eq!( + decide(Some(&receipt), &fingerprint_bytes(b"abc")), + ReplayDecision::SkipSameSource + ); + assert_eq!( + decide(Some(&receipt), "changed"), + ReplayDecision::ReplayChangedSource + ); + assert_eq!(ReplayDecision::SkipSameSource.as_str(), "skip_same_source"); + assert!(!ReplayDecision::SkipSameSource.should_replay()); + assert_eq!(fingerprint_bytes(b"abc").len(), 64); + assert_ne!( + unlucky_core::platform::receipt::fingerprint_data_dir_move( + &PathBuf::from("/a"), + &PathBuf::from("/b") + ), + unlucky_core::platform::receipt::fingerprint_data_dir_move( + &PathBuf::from("/a"), + &PathBuf::from("/c") + ) + ); +} + +fn schema_v2() -> SettingSchema { + SettingSchema::new(SchemaId::LAUNCHER, SchemaVersion::new(2, 0, 0)).with(SettingSpec::int( + "download.threads", + 8, + (1, 256), + AiPolicy::silent(), + "下载并发数(2.0.0 起的键名)", + )) +} + +#[test] +fn store_applies_chain_then_saves_new_version() { + let temp = TempDir::new("migration-store"); + let old_url = SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 0, 0)).url(); + temp.write( + "config/launcher.json", + &json!({ + SCHEMA_KEY: old_url, + SCHEMA_VERSION_KEY: 1, + "download": { "concurrency": 32 } + }) + .to_string(), + ); + + let mut store = SettingStore::load(temp.child("config/launcher.json"), schema_v2()); + assert_eq!(store.doc().version(), SchemaVersion::new(1, 0, 0)); + assert!(!store.is_dirty(), "加载不是改动"); + + let mut chain = MigrationChain::new(); + chain.push(Box::new(RenameThreads)).expect("一环足矣"); + let report = store.apply_chain(&chain).expect("迁移").expect("有迁移"); + assert_eq!(report.to, SchemaVersion::new(2, 0, 0)); + assert_eq!(store.doc().version(), SchemaVersion::new(2, 0, 0)); + assert_eq!( + store.effective("download.threads"), + Some(&json!(32)), + "值要跟着键名一起搬" + ); + assert!(store.is_dirty(), "迁移结果必须回写"); + + store.save().expect("保存"); + let saved: Value = serde_json::from_str(&temp.read("config/launcher.json")).expect("JSON"); + assert_eq!(saved[SCHEMA_VERSION_KEY], json!(2)); + assert!(saved[SCHEMA_KEY] + .as_str() + .expect("$schema") + .ends_with("launcher/2.0.0")); + assert_eq!(saved["download"]["threads"], json!(32)); + + let mut up_to_date = SettingStore::load(temp.child("config/launcher.json"), schema_v2()); + assert!(up_to_date.apply_chain(&chain).expect("已最新").is_none()); +} diff --git a/crates/core/tests/setting/redirect.rs b/crates/core/tests/setting/redirect.rs new file mode 100644 index 0000000..1b30023 --- /dev/null +++ b/crates/core/tests/setting/redirect.rs @@ -0,0 +1,88 @@ +//! `redirect.json`:写/读/跟随/成环/损坏/版本更高。 + +use std::path::PathBuf; + +use unlucky_core::platform::data_dir::DataMode; +use unlucky_core::platform::error::PlatformError; +use unlucky_core::platform::redirect::{ + follow, read, redirect_path, remove, write, RedirectPointer, REDIRECT_SCHEMA_VERSION, +}; + +use crate::common::TempDir; + +fn pointer(target: &str) -> RedirectPointer { + RedirectPointer { + schema_version: REDIRECT_SCHEMA_VERSION, + mode: DataMode::Standard, + launcher_home: PathBuf::from(target), + game_home: PathBuf::from(target).join(".minecraft"), + migrated_at_ms: 1_700_000_000_000, + } +} + +#[test] +fn round_trips_pointer_file() { + let temp = TempDir::new("redirect"); + let origin = temp.child(".ULL"); + assert_eq!(read(&origin).expect("读空目录"), None); + + let written = write(&origin, &pointer("/elsewhere/.ULL")).expect("写指针"); + assert_eq!(written, redirect_path(&origin)); + assert!(origin.join("redirect.json").is_file()); + + let loaded = read(&origin).expect("读指针").expect("有指针"); + assert_eq!(loaded, pointer("/elsewhere/.ULL")); + assert!(remove(&origin).expect("删指针")); + assert!(!remove(&origin).expect("再删一次")); +} + +#[test] +fn follows_chain_to_final_target() { + let temp = TempDir::new("redirect-chain"); + let first = temp.child("a"); + let second = temp.child("b"); + let third = temp.child("c"); + + write(&first, &pointer(&second.to_string_lossy())).expect("写 a"); + write(&second, &pointer(&third.to_string_lossy())).expect("写 b"); + + let followed = follow(&first, 8).expect("跟随").expect("有指针"); + assert_eq!(followed.hops, 2); + assert_eq!(followed.pointer.launcher_home, third); +} + +#[test] +fn detects_redirect_cycle() { + let temp = TempDir::new("redirect-cycle"); + let first = temp.child("a"); + let second = temp.child("b"); + write(&first, &pointer(&second.to_string_lossy())).expect("写 a"); + write(&second, &pointer(&first.to_string_lossy())).expect("写 b"); + + let err = follow(&first, 8).expect_err("应当成环报错"); + assert!(matches!(err, PlatformError::RedirectCycle { .. }), "{err}"); +} + +#[test] +fn rejects_corrupt_and_newer_pointer() { + let temp = TempDir::new("redirect-corrupt"); + let dir = temp.child(".ULL"); + std::fs::create_dir_all(&dir).expect("建目录"); + std::fs::write(dir.join("redirect.json"), b"{not json").expect("写坏文件"); + assert!(matches!( + read(&dir).expect_err("坏文件应报错"), + PlatformError::RedirectCorrupt { .. } + )); + + let mut newer = pointer("/newer/.ULL"); + newer.schema_version = REDIRECT_SCHEMA_VERSION + 1; + std::fs::write( + dir.join("redirect.json"), + serde_json::to_vec(&newer).expect("序列化"), + ) + .expect("写文件"); + assert!(matches!( + read(&dir).expect_err("新版本指针应报错"), + PlatformError::RedirectCorrupt { .. } + )); +} diff --git a/crates/core/tests/setting/schema_compat.rs b/crates/core/tests/setting/schema_compat.rs new file mode 100644 index 0000000..96fdcfe --- /dev/null +++ b/crates/core/tests/setting/schema_compat.rs @@ -0,0 +1,120 @@ +//! schema 版本标识、8 态兼容判定、三档访问权映射。 + +use serde_json::json; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::schema::{ + FileAccess, SchemaCompat, SchemaId, SchemaRef, SchemaVersion, SCHEMA_KEY, +}; + +fn expected() -> SchemaRef { + SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 2, 3)) +} + +fn compat_of(version: SchemaVersion) -> SchemaCompat { + let url = SchemaRef::new(SchemaId::LAUNCHER, version).url(); + SchemaCompat::check(&json!({ SCHEMA_KEY: url }), expected()) +} + +#[test] +fn version_parsing_and_ordering() { + let version = SchemaVersion::parse("1.2.3").expect("解析"); + assert_eq!(version, SchemaVersion::new(1, 2, 3)); + assert_eq!(version.to_string(), "1.2.3"); + assert_eq!(version.next_major(), SchemaVersion::new(2, 0, 0)); + assert!(SchemaVersion::parse("1.2").is_none()); + assert!(SchemaVersion::parse("1.2.3.4").is_none()); + assert!(SchemaVersion::new(1, 2, 3) < SchemaVersion::new(1, 3, 0)); +} + +#[test] +fn schema_url_round_trip() { + let reference = launcher_schema().schema_ref(); + let url = reference.url(); + assert!( + url.ends_with(&format!("launcher/{}", reference.version)), + "{url}" + ); + assert_eq!(SchemaRef::parse_url(&url), Some(reference)); + assert!(SchemaRef::parse_url("https://example.com/x").is_none()); + assert!(SchemaRef::parse_url("garbage").is_none()); + assert_eq!(SchemaId::parse("instance"), Some(SchemaId::INSTANCE)); + assert!(SchemaId::parse("nope").is_none()); +} + +#[test] +fn same_or_older_versions_are_read_write() { + assert_eq!( + compat_of(SchemaVersion::new(1, 2, 3)), + SchemaCompat::ReadWrite + ); + assert_eq!( + compat_of(SchemaVersion::new(1, 2, 2)), + SchemaCompat::ReadWrite + ); + assert_eq!( + compat_of(SchemaVersion::new(1, 0, 0)), + SchemaCompat::ReadWrite + ); + assert_eq!( + compat_of(SchemaVersion::new(0, 9, 0)), + SchemaCompat::ReadWrite + ); +} + +#[test] +fn newer_patch_keeps_original_schema() { + let compat = compat_of(SchemaVersion::new(1, 2, 4)); + assert_eq!(compat, SchemaCompat::ReadWritePreserveSchema); + assert!(compat.preserve_schema()); + assert_eq!(compat.access(), FileAccess::ReadWrite); +} + +#[test] +fn newer_minor_is_read_only() { + let compat = compat_of(SchemaVersion::new(1, 3, 0)); + assert_eq!(compat, SchemaCompat::ReadOnlyPreserveSchema); + assert!(compat.preserve_schema()); + assert_eq!(compat.access(), FileAccess::ReadOnly); + assert!(!compat.access().can_save()); + assert!(compat.access().blocks_editing()); +} + +#[test] +fn newer_major_is_unreadable() { + let compat = compat_of(SchemaVersion::new(2, 0, 0)); + assert_eq!(compat, SchemaCompat::UnsupportedMajor); + assert!(!compat.readable()); + assert_eq!(compat.access(), FileAccess::Unreadable); + assert!(!compat.access().can_read()); +} + +#[test] +fn other_ids_and_broken_urls_are_rejected() { + let instance_url = SchemaRef::new(SchemaId::INSTANCE, SchemaVersion::new(1, 0, 0)).url(); + assert_eq!( + SchemaCompat::check(&json!({ SCHEMA_KEY: instance_url }), expected()), + SchemaCompat::UnexpectedId + ); + assert_eq!( + SchemaCompat::check(&json!({ SCHEMA_KEY: "not-a-url" }), expected()), + SchemaCompat::Invalid + ); + assert_eq!( + SchemaCompat::check(&json!({ "schemaVersion": 1 }), expected()), + SchemaCompat::Missing + ); + assert_eq!(SchemaCompat::Unparseable.access(), FileAccess::Unreadable); +} + +#[test] +fn access_three_tiers() { + assert!(FileAccess::ReadWrite.can_read() && FileAccess::ReadWrite.can_save()); + assert!(FileAccess::ReadOnly.can_read() && !FileAccess::ReadOnly.can_save()); + assert!(!FileAccess::Unreadable.can_read()); + assert!(FileAccess::Unreadable.blocks_editing()); + assert_eq!(FileAccess::ReadOnly.as_str(), "read_only"); + assert_eq!( + SchemaCompat::ReadOnlyPreserveSchema.as_str(), + "read_only_preserve_schema" + ); +} diff --git a/crates/core/tests/setting/spec_ai.rs b/crates/core/tests/setting/spec_ai.rs new file mode 100644 index 0000000..92c2d19 --- /dev/null +++ b/crates/core/tests/setting/spec_ai.rs @@ -0,0 +1,178 @@ +//! 键元数据:AI 可写白名单、敏感分级、类型/范围校验、工具 JSON Schema。 + +use serde_json::json; +use unlucky_core::setting::catalog::SettingSchema; +use unlucky_core::setting::instance_keys::{instance_schema, profiles_schema}; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::schema::{SchemaId, SchemaVersion}; +use unlucky_core::setting::spec::{AiPolicy, Sensitivity, SettingSpec, ValueKind}; + +#[test] +fn ai_whitelist_excludes_credentials() { + let schema = launcher_schema(); + let writable = schema.ai_writable_keys(); + assert!(writable.contains(&"download.concurrency")); + assert!(writable.contains(&"appearance.theme_mode")); + assert!(!writable.contains(&"ai.api_key"), "凭据永不可写(D9)"); + + assert!(schema.accepts_ai_write("java.selected_path")); + assert!(!schema.accepts_ai_write("ai.api_key")); + assert!(!schema.accepts_ai_write("not.a.key")); +} + +#[test] +fn sensitivity_grades_are_flagged() { + let schema = launcher_schema(); + assert_eq!( + schema.sensitivity("core.data_mode"), + Some(Sensitivity::High) + ); + assert_eq!( + schema.sensitivity("download.concurrency"), + Some(Sensitivity::Low) + ); + assert_eq!(schema.sensitivity("ai.model"), Some(Sensitivity::Medium)); + assert_eq!(schema.sensitivity("ai.api_key"), Some(Sensitivity::High)); + assert!(Sensitivity::High.requires_confirmation()); + assert!(!Sensitivity::Low.requires_confirmation()); + assert_eq!(Sensitivity::Medium.as_str(), "medium"); +} + +#[test] +fn validates_types_ranges_and_enums() { + let schema = launcher_schema(); + assert!(schema.validate("download.concurrency", &json!(8)).is_ok()); + assert!(schema.validate("download.concurrency", &json!(0)).is_err()); + assert!(schema + .validate("download.concurrency", &json!(257)) + .is_err()); + assert!(schema + .validate("download.concurrency", &json!("8")) + .is_err()); + assert!(schema + .validate("appearance.theme_mode", &json!("dark")) + .is_ok()); + assert!(schema + .validate("appearance.theme_mode", &json!("solarized")) + .is_err()); + // 废弃键仍然「读兼容合法」——旧文件里的值不该被判成非法。 + assert!(schema.validate("ui.theme", &json!("metro_dark")).is_ok()); + assert!(schema.validate("ui.theme", &json!("auto")).is_err()); + assert!(schema.validate("ui.scale", &json!(1.5)).is_ok()); + assert!(schema.validate("ui.scale", &json!(9.0)).is_err()); + + let err = schema.validate("nope.key", &json!(1)).expect_err("未知键"); + assert!(err.to_string().contains("nope.key")); +} + +#[test] +fn value_kinds_cover_all_variants() { + assert!(ValueKind::Int { min: 1, max: 2 }.check(&json!(2)).is_ok()); + assert!(ValueKind::Int { min: 1, max: 2 }.check(&json!(3)).is_err()); + assert!(ValueKind::Float { min: 0.0, max: 1.0 } + .check(&json!(0.5)) + .is_ok()); + assert!(ValueKind::Text { max_len: 2 }.check(&json!("ab")).is_ok()); + assert!(ValueKind::Text { max_len: 2 }.check(&json!("abc")).is_err()); + assert!(ValueKind::Path.check(&json!(" ")).is_err()); + assert!(ValueKind::Path + .check(&json!("C:/java/bin/java.exe")) + .is_ok()); + assert!(ValueKind::TextList.check(&json!(["a", "b"])).is_ok()); + assert!(ValueKind::TextList.check(&json!([1])).is_err()); + assert!(ValueKind::Any.check(&json!({"whatever": 1})).is_ok()); +} + +#[test] +fn json_schema_projection_feeds_tool_specs() { + let schema = launcher_schema(); + let projected = schema.to_json_schema(); + assert_eq!(projected["type"], json!("object")); + assert_eq!(projected["additionalProperties"], json!(false)); + + let properties = projected["properties"].as_object().expect("properties"); + assert!(properties.contains_key("download.concurrency")); + assert!(!properties.contains_key("ai.api_key"), "锁定键不进工具参数"); + assert_eq!(properties["download.concurrency"]["type"], json!("integer")); + assert_eq!(properties["download.concurrency"]["default"], json!(8)); + assert_eq!( + properties["download.concurrency"]["x-ai-sensitivity"], + json!("low") + ); + assert_eq!(properties["appearance.theme_mode"]["type"], json!("string")); +} + +#[test] +fn top_level_keys_and_defaults() { + let schema = launcher_schema(); + let tops = schema.top_level_keys(); + for expected in ["core", "download", "java", "ai", "ui", "log"] { + assert!(tops.contains(expected), "缺少顶层键组 {expected}"); + } + assert_eq!( + schema.default_value("download.concurrency"), + Some(&json!(8)) + ); + assert_eq!( + schema.default_value("java.selected_path"), + Some(&json!(null)) + ); + assert!(schema.spec("download.concurrency").is_some()); + assert!(schema.has_prefix(&["download"])); + assert!(!schema.has_prefix(&["nope"])); +} + +#[test] +fn instance_and_profiles_schemas_are_usable() { + let instance = instance_schema(); + assert_eq!(instance.id(), SchemaId::INSTANCE); + // D20:实例键表统一成 camelCase 扁平形态(与 game::install::instance 的 serde 逐键一致)。 + assert!(instance.is_known("versionId")); + assert!(instance.is_known("isolation")); + assert!(instance.is_known("lastModeChange.probe.isolatedSide")); + assert!(instance.accepts_ai_write("isolation")); + assert!( + !instance.accepts_ai_write("lastModeChange.at"), + "切换记录由启动器写,AI 不许改" + ); + assert!(instance.validate("isolation", &json!("isolated")).is_ok()); + assert!(instance.validate("isolation", &json!("inherit")).is_ok()); + assert!(instance.validate("isolation", &json!("maybe")).is_err()); + assert!( + instance + .validate("isolation", &json!("per_version")) + .is_err(), + "旧字面量由 game 侧读兼容,schema 白名单只认新值" + ); + + let profiles = profiles_schema(); + assert_eq!(profiles.id(), SchemaId::PROFILES); + assert_eq!( + profiles.spec("profiles").expect("profiles 键").kind, + ValueKind::Any + ); + assert!( + !profiles.accepts_ai_write("profiles"), + "列表本体不许 AI 整体改写" + ); + assert!(profiles.accepts_ai_write("selected_profile_id")); +} + +#[test] +#[should_panic(expected = "重复的配置键")] +fn duplicate_key_is_a_table_bug() { + let schema = SettingSchema::new(SchemaId::LAUNCHER, SchemaVersion::new(9, 0, 0)) + .with(SettingSpec::bool( + "dup.key", + true, + AiPolicy::silent(), + "第一次", + )) + .with(SettingSpec::bool( + "dup.key", + false, + AiPolicy::silent(), + "第二次", + )); + assert_eq!(schema.specs().len(), 2); +} diff --git a/crates/core/tests/setting/store.rs b/crates/core/tests/setting/store.rs new file mode 100644 index 0000000..f4f312b --- /dev/null +++ b/crates/core/tests/setting/store.rs @@ -0,0 +1,260 @@ +//! 配置文件的加载降级、只写脏字段、坏文件备份、只读/不可读三档行为。 + +use serde_json::{json, Value}; +use unlucky_core::setting::document::LoadProblem; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::schema::{ + FileAccess, SchemaCompat, SchemaId, SchemaRef, SchemaVersion, SCHEMA_KEY, SCHEMA_VERSION_KEY, +}; +use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason}; + +use crate::common::TempDir; + +fn schema_url(version: SchemaVersion) -> String { + SchemaRef::new(SchemaId::LAUNCHER, version).url() +} + +fn read_json(temp: &TempDir, name: &str) -> Value { + serde_json::from_str(&temp.read(name)).expect("配置文件是合法 JSON") +} + +#[test] +fn first_run_starts_writable_with_defaults() { + let temp = TempDir::new("store-new"); + let path = temp.child("config/launcher.json"); + let store = SettingStore::load(&path, launcher_schema()); + + assert_eq!(store.access(), FileAccess::ReadWrite); + assert_eq!(store.compat(), SchemaCompat::Missing); + assert!(store.problems().is_empty()); + assert_eq!( + store.effective("download.concurrency"), + Some(&json!(8)), + "首次运行用默认值" + ); + assert!(!path.exists(), "只读加载不该创建文件"); +} + +#[test] +fn saves_only_when_dirty_and_writes_schema_markers() { + let temp = TempDir::new("store-save"); + let path = temp.child("config/launcher.json"); + let mut store = SettingStore::load(&path, launcher_schema()); + + assert_eq!( + store.save().expect("空保存"), + SaveOutcome::Skipped(SkipReason::NotDirty) + ); + + store + .set("download.concurrency", json!(16)) + .expect("改配置"); + let outcome = store.save().expect("保存"); + assert!(outcome.wrote()); + + let raw = read_json(&temp, "config/launcher.json"); + assert_eq!(raw["download"]["concurrency"], json!(16)); + assert_eq!(raw[SCHEMA_VERSION_KEY], json!(1)); + assert!( + raw[SCHEMA_KEY] + .as_str() + .expect("$schema") + .ends_with(&format!("launcher/{}", launcher_schema().version())), + "保存写当前 schema 版本" + ); + assert!(raw.get("ai").is_none(), "默认值不进文件"); + assert!(!store.is_dirty(), "保存后清脏"); + + let reloaded = SettingStore::load(&path, launcher_schema()); + assert_eq!(reloaded.effective("download.concurrency"), Some(&json!(16))); +} + +#[test] +fn preserves_unknown_keys_across_save() { + let temp = TempDir::new("store-unknown"); + let raw = json!({ + SCHEMA_KEY: schema_url(SchemaVersion::new(1, 0, 0)), + SCHEMA_VERSION_KEY: 1, + "download": { "concurrency": 4 }, + "from_the_future": { "flag": true } + }); + temp.write("config/launcher.json", &raw.to_string()); + + let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema()); + assert!( + store.problems().iter().any( + |problem| matches!(problem, LoadProblem::UnknownKey { key } if key == "from_the_future") + ), + "陌生顶层子树按最上层节点报一次: {:?}", + store.problems() + ); + + store + .set("download.concurrency", json!(12)) + .expect("改配置"); + store.save().expect("保存"); + + let after = read_json(&temp, "config/launcher.json"); + assert_eq!(after["download"]["concurrency"], json!(12)); + assert_eq!(after["from_the_future"]["flag"], json!(true)); +} + +#[test] +fn corrupt_file_is_backed_up_before_overwrite() { + let temp = TempDir::new("store-corrupt"); + temp.write("config/launcher.json", "{ definitely not json"); + + let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema()); + assert_eq!(store.compat(), SchemaCompat::Unparseable); + assert_eq!( + store.access(), + FileAccess::ReadWrite, + "坏文件要能修,否则用户被永久卡住" + ); + assert_eq!(store.effective("download.concurrency"), Some(&json!(8))); + + store.set("ui.language", json!("en_us")).expect("改配置"); + store.save().expect("保存"); + + assert!(temp.exists("config/launcher.json.1"), "坏文件先备份成 .1"); + let raw = read_json(&temp, "config/launcher.json"); + assert_eq!(raw["ui"]["language"], json!("en_us")); +} + +#[test] +fn newer_minor_is_read_only_and_keeps_its_schema() { + let temp = TempDir::new("store-readonly"); + // 比当前 schema(1.3.0)更高的 minor:只读且保留原 $schema。 + let url = schema_url(SchemaVersion::new(1, 4, 0)); + temp.write( + "config/launcher.json", + &json!({ SCHEMA_KEY: url, "download": { "concurrency": 32 } }).to_string(), + ); + + let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema()); + assert_eq!(store.compat(), SchemaCompat::ReadOnlyPreserveSchema); + assert_eq!(store.access(), FileAccess::ReadOnly); + assert_eq!( + store.effective("download.concurrency"), + Some(&json!(32)), + "只读档的值照读" + ); + assert!(store.set("download.concurrency", json!(1)).is_err()); + assert_eq!( + store.save().expect("只读保存"), + SaveOutcome::Skipped(SkipReason::ReadOnly) + ); + + store.save_forced().expect("用户确认强写"); + assert_eq!(store.access(), FileAccess::ReadWrite); + let raw = read_json(&temp, "config/launcher.json"); + assert!( + raw[SCHEMA_KEY] + .as_str() + .expect("$schema") + .ends_with("launcher/1.4.0"), + "更 minor 的文件强写后也要保留原 $schema" + ); +} + +#[test] +fn higher_major_is_unreadable_until_forced() { + let temp = TempDir::new("store-unreadable"); + let url = schema_url(SchemaVersion::new(2, 0, 0)); + temp.write( + "config/launcher.json", + &json!({ SCHEMA_KEY: url, "brand_new": { "thing": 1 } }).to_string(), + ); + + let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema()); + assert_eq!(store.compat(), SchemaCompat::UnsupportedMajor); + assert_eq!(store.access(), FileAccess::Unreadable); + assert!( + store.set("appearance.theme_mode", json!("dark")).is_err(), + "不可读档全程不写" + ); + assert_eq!( + store.save().expect("不可读保存"), + SaveOutcome::Skipped(SkipReason::Unreadable) + ); + + store.save_forced().expect("用户确认强写"); + assert!(temp.exists("config/launcher.json.1")); + assert_eq!(store.access(), FileAccess::ReadWrite); +} + +#[test] +fn reload_discards_in_memory_changes() { + let temp = TempDir::new("store-reload"); + let path = temp.write( + "config/launcher.json", + &json!({ SCHEMA_KEY: schema_url(SchemaVersion::new(1, 0, 0)), "appearance": { "theme_mode": "light" } }) + .to_string(), + ); + + let mut store = SettingStore::load(&path, launcher_schema()); + store + .set("appearance.theme_mode", json!("dark")) + .expect("改配置"); + assert!(store.is_dirty()); + store.reload(); + assert!(!store.is_dirty()); + assert_eq!( + store.effective("appearance.theme_mode"), + Some(&json!("light")) + ); +} + +#[test] +fn backup_paths_increment() { + let temp = TempDir::new("store-backup"); + temp.write("config/launcher.json.1", "old"); + temp.write("config/launcher.json.2", "older"); + let store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema()); + let next = store.next_backup_path(); + assert!(next.to_string_lossy().ends_with(".3"), "{next:?}"); +} + +/// finding 20261030-worker-java2-bug-schema-launcher-json-loadproblem: +/// `$schema` 前缀写错(手写/脚本生成的配置最常见的错)曾让整份文件静默回落默认值。 +/// M40 起:值照读 + `UnknownSchema` 告警 + 文件只读(不悄悄改写我们看不懂的文件)。 +#[test] +fn unknown_schema_prefix_warns_but_keeps_file_values() { + let temp = TempDir::new("store-unknown-schema"); + let url = "https://unlucky-launcher.local/schema/launcher/1.2.0.json"; + temp.write( + "config/launcher.json", + &json!({ + SCHEMA_KEY: url, + "core": { "data_mode": "standard" }, + "download": { "concurrency": 32 }, + }) + .to_string(), + ); + + let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema()); + assert_eq!(store.compat(), SchemaCompat::Invalid); + assert_eq!(store.access(), FileAccess::ReadOnly); + assert_eq!( + store.effective("download.concurrency"), + Some(&json!(32)), + "认不出 $schema 也要读文件里的值" + ); + assert!( + store.problems().iter().any( + |problem| matches!(problem, LoadProblem::UnknownSchema { url: found } if found == url) + ), + "{:?}", + store.problems() + ); + assert!( + store.problems()[0].to_string().contains("$schema"), + "{}", + store.problems()[0] + ); + assert!(store.set("download.concurrency", json!(1)).is_err()); + assert_eq!( + store.save().expect("只读保存"), + SaveOutcome::Skipped(SkipReason::ReadOnly) + ); +} diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..e9b0a59 --- /dev/null +++ b/deny.toml @@ -0,0 +1,54 @@ +# cargo-deny 配置(D15:Apache-2.0 主干,GPL 依赖不得进主干)。 +# CI 侧的安装与执行见 .github/workflows/guards.yml(licenses job)。 +# 参考:https://embarkstudios.github.io/cargo-deny/ · 决策:DevDocs/PlanDoc/00-decision-log.md D15 + +[graph] +all-features = true + +[advisories] +version = 2 +yanked = "deny" +# 注意:cargo-deny 0.20 移除了 severity-threshold(见 PR #611),严重度改由 +# [advisories] 的 ignore 清单 + 后续安全轮次单独把关;这里只做「yanked 依赖禁止」。 + +[licenses] +version = 2 +# 白名单:Apache-2.0 / MIT / BSD / Zlib 系(用户要求)+ 实际依赖树需要的宽松许可。 +# GPL / AGPL / LGPL / SSPL 一律不在列 → 直接 fail,不进主干。 +# +# 补充项及来源(均为宽松、与 Apache-2.0 兼容,不改变「拒绝 copyleft」的立场): +# - BSL-1.0(Boost):clipboard-win / error-code(winit/arboard 的 Windows 依赖) +# - Unlicense:ksni(Slint 系统托盘) +# - CDLA-Permissive-2.0:webpki-roots(Mozilla CA 根证书数据) +# - Unicode-3.0:unicode-ident;CC0-1.0 / MIT-0 / ISC:生态常见宽松项 +confidence-threshold = 0.92 +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "MIT", + "MIT-0", + "BSD-2-Clause", + "BSD-3-Clause", + "Zlib", + "BSL-1.0", + "ISC", + "CC0-1.0", + "Unlicense", + "Unicode-3.0", + "CDLA-Permissive-2.0", + # Slint(D2)走 Royalty-Free 2.0 轨道:其 crates 的 SPDX 表达为 + # "GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0", + # 允许多选一即通过;这里显式放行 RF 分支,GPL 分支仍是拒绝的。 + # 主义务:关于页放 AboutSlint 部件(见 NOTICE 与 DevDocs/PlanDoc/01c-native-gui-options.md §8)。 + "LicenseRef-Slint-Royalty-free-2.0", +] + +[bans] +multiple-versions = "warn" +wildcards = "deny" +highlight = "all" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..ab71de3 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,6 @@ +[toolchain] +# 全仓统一 stable,不在仓库内钉死具体小版本(CI 与本地同样走 stable 解析)。 +# 各平台(Win10+ / macOS / Linux)共用本文件,不按平台另起工具链。 +channel = "stable" +profile = "minimal" +components = ["rustfmt", "clippy"]