Compare commits
17
Commits
daadc9170f
...
37ccaaf23f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37ccaaf23f | ||
|
|
dc4fac8391 | ||
|
|
38832df095 | ||
|
|
6ce825db8d | ||
|
|
eaa1414847 | ||
|
|
62019a29cd | ||
|
|
ea6016716e | ||
|
|
92d8a705e8 | ||
|
|
d95c9664c6 | ||
|
|
3d9372029e | ||
|
|
b0f747074f | ||
|
|
858c227900 | ||
|
|
76d83148ff | ||
|
|
f9079f8ab0 | ||
|
|
07fd6113a7 | ||
|
|
8194e38bcf | ||
|
|
f62bb00494 |
No files matched your search
@@ -0,0 +1,236 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# 发布流水线:tag `v*` → 三平台(四槽位)矩阵构建 → 单文件产物 + SHA256SUMS
|
||||||
|
# → 上传 artifact + 创建 GitHub Release(pre-release 默认开)。同一 tag 重跑幂等:
|
||||||
|
# Release 已存在时覆盖资产与说明,不再 `gh release create`(见 release job 末步);
|
||||||
|
# 重跑**不碰** pre-release 标记,该标记只在 `inputs.prerelease` 显式给出时才被断言。
|
||||||
|
#
|
||||||
|
# 契约唯一真相源:packaging/README.md(命名/目录/运行时依赖)。
|
||||||
|
# 打包本体在 packaging/make_artifacts.py(本地与 CI 同一份脚本,逐字节同布局)。
|
||||||
|
# 手动触发(workflow_dispatch)默认只跑构建不建 Release —— 用来验证矩阵仍然编得过。
|
||||||
|
# 注意 `inputs.prerelease` 只在 **tag ref** 上 dispatch 才可达:Actions 页面的
|
||||||
|
# "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.1.0`。
|
||||||
|
# 分支 ref 的 dispatch 与 push 事件一样被 release job 的 `if: github.ref_type == 'tag'`
|
||||||
|
# 挡在发布之外,所以「dispatch 只构建不发布」与「dispatch 可关 pre-release」并不矛盾。
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
prerelease:
|
||||||
|
description: "标记为 pre-release(仅在 tag ref 上 dispatch 时才生效)"
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: build (${{ matrix.slot }})
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
# Windows:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,
|
||||||
|
# 双击即用的单文件形态;UCRT 本身就是 Win10+ 的系统组件。
|
||||||
|
- slot: windows-x64
|
||||||
|
os: windows-latest
|
||||||
|
rustflags: -C target-feature=+crt-static
|
||||||
|
# Linux:固定 ubuntu-22.04(glibc 2.35 地板)而不是 latest,
|
||||||
|
# 否则产物的 glibc 底线会跟着 runner 漂移(24.04 = 2.39)。
|
||||||
|
- slot: linux-x64
|
||||||
|
os: ubuntu-22.04
|
||||||
|
# macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。
|
||||||
|
- slot: macos-arm64
|
||||||
|
os: macos-latest
|
||||||
|
- slot: macos-x64
|
||||||
|
os: macos-15-intel
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Linux 图形栈构建依赖(winit / femtovg 编译期需要)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \
|
||||||
|
libwayland-dev libxcb1-dev
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: cargo build --release --locked
|
||||||
|
env:
|
||||||
|
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||||
|
run: cargo build --release --locked --bin unlucky-launcher --bin ull
|
||||||
|
|
||||||
|
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# +crt-static 是否真的生效,只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 说明还得装
|
||||||
|
# 可再发行包,那就不是"单文件"了,直接 fail。
|
||||||
|
DUMPBIN=$(command -v dumpbin || true)
|
||||||
|
if [ -z "$DUMPBIN" ]; then
|
||||||
|
DUMPBIN=$(ls -d "C:/Program Files/Microsoft Visual Studio/"*/VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe 2>/dev/null | head -n1 || true)
|
||||||
|
fi
|
||||||
|
# 工具缺失同样 fail:windows-latest 自带 VS 工具链,基本找得到;
|
||||||
|
# "找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
|
||||||
|
if [ -z "$DUMPBIN" ]; then
|
||||||
|
echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
"$DUMPBIN" -nologo -dependents target/release/unlucky-launcher.exe | tee dependents.txt
|
||||||
|
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
|
||||||
|
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 打包(产物 + SHA256SUMS)
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
|
||||||
|
REF_TYPE: ${{ github.ref_type }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
|
||||||
|
PY=$(command -v python3 || command -v python)
|
||||||
|
args=()
|
||||||
|
if [ "$REF_TYPE" = "tag" ]; then
|
||||||
|
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
|
||||||
|
fi
|
||||||
|
"$PY" packaging/make_artifacts.py "${args[@]}" --out dist
|
||||||
|
ls -l dist
|
||||||
|
|
||||||
|
- name: 归档内容自检(二进制定位 + 合规文本)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for archive in dist/*.tar.gz; do
|
||||||
|
[ -e "$archive" ] || continue
|
||||||
|
listing=$(tar -tzf "$archive")
|
||||||
|
for required in unlucky-launcher LICENSE NOTICE README.md; do
|
||||||
|
if ! grep -q "/$required$" <<<"$listing"; then
|
||||||
|
echo "::error file=$archive::$archive 缺少 $required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "OK: $archive 含二进制与 LICENSE/NOTICE/README.md"
|
||||||
|
done
|
||||||
|
if ls dist/*.exe >/dev/null 2>&1; then
|
||||||
|
echo "Windows 单 exe 形态:LICENSE/NOTICE/README.md 作为 Release 资产并列分发"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist-${{ matrix.slot }}
|
||||||
|
path: dist
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: GitHub Release
|
||||||
|
needs: build
|
||||||
|
if: github.ref_type == 'tag'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: 矩阵完整性 + 重算全量 SHA256SUMS
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ls -l dist
|
||||||
|
for slot in windows-x64 linux-x64 macos-arm64 macos-x64; do
|
||||||
|
if ! compgen -G "dist/*-$slot*" >/dev/null; then
|
||||||
|
echo "::error::矩阵缺产物:$slot"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# 各平台任务各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧重算。
|
||||||
|
cd dist
|
||||||
|
rm -f SHA256SUMS.txt
|
||||||
|
sha256sum unlucky-launcher-* > SHA256SUMS.txt
|
||||||
|
sha256sum -c SHA256SUMS.txt
|
||||||
|
cat SHA256SUMS.txt
|
||||||
|
|
||||||
|
- name: 生成 Release 说明(支持矩阵模板)
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python3 - <<'PY'
|
||||||
|
import os, pathlib
|
||||||
|
version = os.environ["TAG"].lstrip("v")
|
||||||
|
template = pathlib.Path("packaging/release-notes.md").read_text(encoding="utf-8")
|
||||||
|
sums = pathlib.Path("dist/SHA256SUMS.txt").read_text(encoding="utf-8").strip()
|
||||||
|
body = template.replace("{{VERSION}}", version).replace("{{SUMS}}", sums)
|
||||||
|
pathlib.Path("release-body.md").write_text(body, encoding="utf-8", newline="\n")
|
||||||
|
print(body[:400])
|
||||||
|
PY
|
||||||
|
|
||||||
|
- name: 创建或更新 GitHub Release(同 tag 重跑幂等)
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
PRERELEASE: ${{ inputs.prerelease }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
|
||||||
|
# pre-release 标记只在**显式要求**时才断言:
|
||||||
|
# PRERELEASE=true / false —— 来自 tag ref 上的 workflow_dispatch 输入;
|
||||||
|
# PRERELEASE 空 —— push 触发的运行或它的 Re-run,此时**不碰**该标记,
|
||||||
|
# 否则会把用户手动提升为 stable 的 Release 静默翻回 pre-release。
|
||||||
|
# 首次创建(create)仍保持「pre-release 默认开」的老契约:空值按 true 处理。
|
||||||
|
create_mark=()
|
||||||
|
edit_mark=()
|
||||||
|
case "${PRERELEASE:-}" in
|
||||||
|
true)
|
||||||
|
create_mark=(--prerelease)
|
||||||
|
edit_mark=(--prerelease)
|
||||||
|
;;
|
||||||
|
false)
|
||||||
|
edit_mark=(--prerelease=false)
|
||||||
|
;;
|
||||||
|
"")
|
||||||
|
create_mark=(--prerelease)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "::error::非预期的 inputs.prerelease 取值:$PRERELEASE"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,这三份与产物并列挂到 Release 上,
|
||||||
|
# 保证每个下载平台的许可条款都随包分发。
|
||||||
|
assets=(dist/unlucky-launcher-* dist/SHA256SUMS.txt LICENSE NOTICE README.md)
|
||||||
|
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||||
|
# 同 tag 重跑(补丁 / 重编):Release 已存在 → 覆盖资产 + 刷新说明,
|
||||||
|
# 不再 `create`(那会因已存在而失败,把整条流水线在最后一步染红)。
|
||||||
|
gh release upload "$TAG" "${assets[@]}" --clobber
|
||||||
|
gh release edit "$TAG" "${base_flags[@]}" "${edit_mark[@]}"
|
||||||
|
else
|
||||||
|
gh release create "$TAG" "${assets[@]}" "${base_flags[@]}" "${create_mark[@]}"
|
||||||
|
fi
|
||||||
+1
-1
@@ -82,7 +82,7 @@ all = { level = "deny", priority = -1 }
|
|||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
lto = "thin"
|
lto = "fat"
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
strip = "symbols"
|
strip = "symbols"
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
**English** · [简体中文](README.zh-CN.md)
|
**English** · [简体中文](README.zh-CN.md)
|
||||||
|
|
||||||
|
> **Official project home**: https://github.com/AskaEth/Unlucky-Launcher
|
||||||
|
|
||||||
Unlucky Launcher (ULL) is a free, open-source Minecraft: Java Edition launcher written in Rust
|
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 /
|
(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.
|
configuration / crash diagnosis) and integrates with the broader agent tooling ecosystem via MCP.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
[English](README.md) · **简体中文**
|
[English](README.md) · **简体中文**
|
||||||
|
|
||||||
|
> **官方项目主页**:https://github.com/AskaEth/Unlucky-Launcher
|
||||||
|
|
||||||
Rust 原生单文件 Minecraft 启动器:**Slint + Metro 视觉**,内置 **AI Agent 客户端**(对话 / 改配置 / 崩溃诊断),
|
Rust 原生单文件 Minecraft 启动器:**Slint + Metro 视觉**,内置 **AI Agent 客户端**(对话 / 改配置 / 崩溃诊断),
|
||||||
融合主流 Agent 工具(**MCP**),**最低基线 Win10+**。
|
融合主流 Agent 工具(**MCP**),**最低基线 Win10+**。
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ pub(crate) fn wire_pages(weak: Weak<Inner>) {
|
|||||||
this.sync();
|
this.sync();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
let weak = weak.clone();
|
||||||
|
page.on_msa_browser_begin(move || {
|
||||||
|
let Some(this) = weak.upgrade() else { return };
|
||||||
|
this.dispatch(AppCommand::StartBrowserLogin);
|
||||||
|
this.sync();
|
||||||
|
});
|
||||||
|
}
|
||||||
{
|
{
|
||||||
let weak = weak.clone();
|
let weak = weak.clone();
|
||||||
page.on_msa_cancel(move || {
|
page.on_msa_cancel(move || {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ pub fn active_account_name(state: &AppState) -> String {
|
|||||||
.unwrap_or_else(|| "未登录".to_string())
|
.unwrap_or_else(|| "未登录".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设备码登录面板。
|
/// 微软登录面板。
|
||||||
pub fn device(state: &AppState) -> UiDeviceCode {
|
pub fn device(state: &AppState) -> UiDeviceCode {
|
||||||
let view = &state.device_login;
|
let view = &state.device_login;
|
||||||
UiDeviceCode {
|
UiDeviceCode {
|
||||||
|
|||||||
@@ -35,6 +35,22 @@ fn accounts_and_device_login_render() {
|
|||||||
assert_eq!(device.phase_label, "等待确认");
|
assert_eq!(device.phase_label, "等待确认");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browser_login_panel_renders_the_authorization_link() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.device_login = DeviceLoginView::browser_waiting(
|
||||||
|
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=x",
|
||||||
|
300,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let device = device(&state);
|
||||||
|
assert!(device.visible && device.busy);
|
||||||
|
assert_eq!(device.user_code, "", "授权码流没有用户码");
|
||||||
|
assert!(device.verification_uri.contains("/authorize?client_id=x"));
|
||||||
|
assert_eq!(device.qr_link, device.verification_uri);
|
||||||
|
assert_eq!(device.expires, 300);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn install_progress_renders_labels_and_terminal_flags() {
|
fn install_progress_renders_labels_and_terminal_flags() {
|
||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! 账户页 DTO:三条轨道的统一抽象 + 微软设备码登录的可见状态。
|
//! 账户页 DTO:三条轨道的统一抽象 + 微软登录(设备码 / 授权码浏览器)的可见状态。
|
||||||
//!
|
//!
|
||||||
//! **凭据红线**:本模块(以及整个 `crate::dto`)只有 [`AccountView`] 这种公开字段视图。
|
//! **凭据红线**:本模块(以及整个 `crate::dto`)只有 [`AccountView`] 这种公开字段视图。
|
||||||
//! `core::auth` 的 `Credentials` / `MicrosoftSession` / token 永远不进 app 层——
|
//! `core::auth` 的 `Credentials` / `MicrosoftSession` / token 永远不进 app 层——
|
||||||
@@ -65,7 +65,7 @@ pub struct AccountView {
|
|||||||
pub active: bool,
|
pub active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设备码登录阶段(UI 据此切换面板)。
|
/// 微软登录阶段(UI 据此切换面板;两条流共用)。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum DeviceLoginPhase {
|
pub enum DeviceLoginPhase {
|
||||||
/// 没有登录在进行
|
/// 没有登录在进行
|
||||||
@@ -160,6 +160,33 @@ impl DeviceLoginView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 授权码流:本地回调已就绪、浏览器已拉起(`present` 回调时刻)。
|
||||||
|
///
|
||||||
|
/// 面板不展示用户码,改展示**可复制的授权链接**(用户可能需要在另一台设备/
|
||||||
|
/// 另一个浏览器里手动打开);`warning` 是「浏览器没拉起来」这类非致命提示。
|
||||||
|
pub fn browser_waiting(
|
||||||
|
authorization_url: &str,
|
||||||
|
expires_in: u64,
|
||||||
|
warning: Option<&str>,
|
||||||
|
) -> Self {
|
||||||
|
let mut message = format!(
|
||||||
|
"已在系统浏览器打开微软登录页,请完成授权({} 秒内有效);没弹出就把下面的链接粘到浏览器里",
|
||||||
|
expires_in.max(1)
|
||||||
|
);
|
||||||
|
if let Some(warning) = warning {
|
||||||
|
message.push_str(&format!("({warning})"));
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
phase: DeviceLoginPhase::Waiting,
|
||||||
|
user_code: String::new(),
|
||||||
|
verification_uri: authorization_url.to_string(),
|
||||||
|
qr_link: authorization_url.to_string(),
|
||||||
|
expires_in,
|
||||||
|
message,
|
||||||
|
account_name: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 失败态。
|
/// 失败态。
|
||||||
pub fn failed(message: impl Into<String>) -> Self {
|
pub fn failed(message: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -212,6 +239,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browser_waiting_shows_a_copyable_link_instead_of_a_user_code() {
|
||||||
|
let view = DeviceLoginView::browser_waiting(
|
||||||
|
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=x",
|
||||||
|
300,
|
||||||
|
Some("拉起浏览器失败"),
|
||||||
|
);
|
||||||
|
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
|
||||||
|
assert!(view.phase.busy());
|
||||||
|
assert!(view.user_code.is_empty(), "授权码流没有用户码");
|
||||||
|
assert_eq!(view.qr_link, view.verification_uri);
|
||||||
|
assert!(view.message.contains("浏览器"));
|
||||||
|
assert!(view.message.contains("拉起浏览器失败"), "{}", view.message);
|
||||||
|
let json = serde_json::to_string(&view).expect("序列化");
|
||||||
|
for forbidden in ["refresh", "mc-access"] {
|
||||||
|
assert!(!json.contains(forbidden), "视图里不该出现 {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn succeeded_and_failed_clear_the_code() {
|
fn succeeded_and_failed_clear_the_code() {
|
||||||
let ok = DeviceLoginView::succeeded("Steve");
|
let ok = DeviceLoginView::succeeded("Steve");
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ pub enum CoreEvent {
|
|||||||
DiagnosisReady(DiagnosisReport),
|
DiagnosisReady(DiagnosisReport),
|
||||||
/// 账户列表刷新
|
/// 账户列表刷新
|
||||||
AccountsChanged(Vec<AccountView>),
|
AccountsChanged(Vec<AccountView>),
|
||||||
/// 设备码登录状态刷新
|
/// 微软登录状态刷新
|
||||||
DeviceLoginChanged(DeviceLoginView),
|
DeviceLoginChanged(DeviceLoginView),
|
||||||
/// Java 页快照(发行版/回退链/运行时)
|
/// Java 页快照(发行版/回退链/运行时)
|
||||||
JavaChanged(JavaPageView),
|
JavaChanged(JavaPageView),
|
||||||
@@ -129,9 +129,11 @@ pub enum AppCommand {
|
|||||||
RemoveAccount(String),
|
RemoveAccount(String),
|
||||||
/// 切换当前启动账户
|
/// 切换当前启动账户
|
||||||
SwitchAccount(String),
|
SwitchAccount(String),
|
||||||
/// 开始微软设备码登录
|
/// 开始微软设备码登录(CLI/无浏览器场景、备用通道)
|
||||||
StartDeviceLogin,
|
StartDeviceLogin,
|
||||||
/// 取消/关掉设备码登录面板(已发出的请求会在收尾时丢弃结果)
|
/// 开始微软授权码登录(拉起系统默认浏览器 + 本地回调,GUI 的默认入口)
|
||||||
|
StartBrowserLogin,
|
||||||
|
/// 取消/关掉微软登录面板(已发出的请求会在收尾时丢弃结果)
|
||||||
CancelDeviceLogin,
|
CancelDeviceLogin,
|
||||||
/// 安装指定发行版的托管 JDK(`temurin` / `oracle` / `microsoft` / `zulu`)
|
/// 安装指定发行版的托管 JDK(`temurin` / `oracle` / `microsoft` / `zulu`)
|
||||||
InstallJava(String),
|
InstallJava(String),
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ impl InstallStage {
|
|||||||
match self {
|
match self {
|
||||||
Self::Planning => "解析版本",
|
Self::Planning => "解析版本",
|
||||||
Self::Downloading => "下载文件",
|
Self::Downloading => "下载文件",
|
||||||
Self::Installing => "写入版本",
|
Self::Installing => "生成版本",
|
||||||
Self::Registering => "登记实例",
|
Self::Registering => "登记实例",
|
||||||
Self::Done => "已完成",
|
Self::Done => "已完成",
|
||||||
Self::Failed => "失败",
|
Self::Failed => "失败",
|
||||||
|
|||||||
@@ -1,24 +1,29 @@
|
|||||||
//! 账户:三轨统一列表(只读公开字段)+ 离线新增 + 删除/切换 + 微软设备码登录。
|
//! 账户:三轨统一列表(只读公开字段)+ 离线新增 + 删除/切换 + 微软登录(两条流)。
|
||||||
//!
|
//!
|
||||||
//! 凭据红线(D9):`Credentials` / `MicrosoftSession` 只在本模块与 `launch.rs` 里出现,
|
//! 凭据红线(D9):`Credentials` / `MicrosoftSession` 只在本模块与 `launch.rs` 里出现,
|
||||||
//! 出口一律是 [`AccountView`] / [`DeviceLoginView`]。设备码本身不是凭据,可以直接展示。
|
//! 出口一律是 [`AccountView`] / [`DeviceLoginView`]。
|
||||||
//!
|
//!
|
||||||
//! 取消语义:设备码轮询跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时
|
//! 两条登录流:授权码([`start_browser_login`],GUI 默认:拉起系统浏览器 + 本地回调,
|
||||||
|
//! `research/08` §3 方案 A)与设备码([`start_login`],无浏览器/CLI 场景)。除了「怎么拿到
|
||||||
|
//! MS access_token」之外完全共用:同一个取消槽位、同一段账户入库、同一个刷新链。
|
||||||
|
//!
|
||||||
|
//! 取消语义:轮询/等回调都跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时
|
||||||
//! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。
|
//! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use unlucky_core::auth::account::Credentials;
|
use unlucky_core::auth::account::Credentials;
|
||||||
use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth};
|
use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth, MicrosoftSession};
|
||||||
use unlucky_core::auth::offline::offline_meta;
|
use unlucky_core::auth::offline::offline_meta;
|
||||||
use unlucky_core::auth::AccountStore;
|
use unlucky_core::auth::{AccountStore, AuthResult};
|
||||||
use unlucky_core::download::blocking::block_on;
|
use unlucky_core::download::blocking::block_on;
|
||||||
|
|
||||||
use crate::dto::{AccountKind, AccountView, DeviceLoginView};
|
use crate::dto::{AccountKind, AccountView, DeviceLoginView};
|
||||||
|
|
||||||
use super::env::HostEnv;
|
use super::env::HostEnv;
|
||||||
use super::job::{spawn, HostTask, JobSender};
|
use super::job::{spawn, HostTask, JobSender};
|
||||||
|
use super::opener;
|
||||||
|
|
||||||
/// 账户列表(公开字段)。
|
/// 账户列表(公开字段)。
|
||||||
pub fn accounts(env: &HostEnv, active: Option<&str>) -> Vec<AccountView> {
|
pub fn accounts(env: &HostEnv, active: Option<&str>) -> Vec<AccountView> {
|
||||||
@@ -103,7 +108,7 @@ pub fn remove(env: &HostEnv, key: &str) -> Result<String, String> {
|
|||||||
Ok(meta.display_name().to_string())
|
Ok(meta.display_name().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设备码登录任务句柄(取消标志 + 账户名回执)。
|
/// 微软登录任务句柄(取消标志 + 账户名回执;两条流共用)。
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct LoginJob {
|
pub struct LoginJob {
|
||||||
/// 用户点了取消(线程收尾时据此丢弃结果)
|
/// 用户点了取消(线程收尾时据此丢弃结果)
|
||||||
@@ -132,16 +137,9 @@ impl LoginJob {
|
|||||||
/// 起一次设备码登录(后台线程;`present` 时刻发 Waiting 事件)。
|
/// 起一次设备码登录(后台线程;`present` 时刻发 Waiting 事件)。
|
||||||
pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) {
|
pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) {
|
||||||
spawn("msa-login", move || {
|
spawn("msa-login", move || {
|
||||||
let auth = match MicrosoftAuth::from_env() {
|
let auth = match microsoft_auth(&env) {
|
||||||
Ok(auth) => auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir(
|
Ok(auth) => auth,
|
||||||
env.layout.logs(),
|
Err(message) => return fail(&tx, message),
|
||||||
))),
|
|
||||||
Err(error) => {
|
|
||||||
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed(
|
|
||||||
error.to_string(),
|
|
||||||
))));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let tx_prompt = tx.clone();
|
let tx_prompt = tx.clone();
|
||||||
let result = block_on(auth.device_code_login(|prompt| {
|
let result = block_on(auth.device_code_login(|prompt| {
|
||||||
@@ -152,45 +150,84 @@ pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) {
|
|||||||
&format!("{}#{}", prompt.verification_uri, prompt.user_code),
|
&format!("{}#{}", prompt.verification_uri, prompt.user_code),
|
||||||
))));
|
))));
|
||||||
}));
|
}));
|
||||||
if job.is_cancelled() {
|
finish(&env, &tx, &job, result);
|
||||||
let _ = tx.send(HostTask::Login(Box::default()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match result {
|
|
||||||
Ok(session) => {
|
|
||||||
let meta = unlucky_core::auth::account::AccountMeta::new(
|
|
||||||
unlucky_core::auth::account::AccountKind::Microsoft,
|
|
||||||
session.profile_id.clone(),
|
|
||||||
session.profile_name.clone(),
|
|
||||||
now_ms(),
|
|
||||||
);
|
|
||||||
let name = meta.display_name().to_string();
|
|
||||||
let mut store = AccountStore::open(env.layout.auth_data());
|
|
||||||
let saved = store
|
|
||||||
.insert(meta, Credentials::Microsoft(session))
|
|
||||||
.and_then(|()| store.save());
|
|
||||||
match saved {
|
|
||||||
Ok(()) => {
|
|
||||||
let _ =
|
|
||||||
tx.send(HostTask::Login(Box::new(DeviceLoginView::succeeded(&name))));
|
|
||||||
let _ = tx.send(HostTask::Accounts(accounts(&env, None)));
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed(
|
|
||||||
format!("账户入库失败:{error}"),
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed(
|
|
||||||
error.to_string(),
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 起一次授权码登录(后台线程:绑本地端口 → 拉起系统默认浏览器 → 等回调 → 换链)。
|
||||||
|
///
|
||||||
|
/// 与设备码流共用同一个 [`LoginJob`] 取消槽位、同一段账户入库逻辑,所以两条流的
|
||||||
|
/// 凭据/刷新语义完全一致(`core` 侧也共用 [`unlucky_core::auth::MicrosoftSession`])。
|
||||||
|
pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) {
|
||||||
|
spawn("msa-authcode", move || {
|
||||||
|
let auth = match microsoft_auth(&env) {
|
||||||
|
Ok(auth) => auth,
|
||||||
|
Err(message) => return fail(&tx, message),
|
||||||
|
};
|
||||||
|
let tx_prompt = tx.clone();
|
||||||
|
let result = block_on(auth.authcode_login(|prompt| {
|
||||||
|
// 浏览器拉不起来不是致命错误:面板里还有可复制的授权链接。
|
||||||
|
let opened = opener::open_url(&prompt.authorization_url);
|
||||||
|
let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::browser_waiting(
|
||||||
|
&prompt.authorization_url,
|
||||||
|
prompt.timeout.as_secs(),
|
||||||
|
opened.err().as_deref(),
|
||||||
|
))));
|
||||||
|
}));
|
||||||
|
finish(&env, &tx, &job, result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生产入口的 `MicrosoftAuth`:client id 取环境/构建期,失败日志落 `<数据根>/logs/`。
|
||||||
|
fn microsoft_auth(env: &HostEnv) -> Result<MicrosoftAuth, String> {
|
||||||
|
MicrosoftAuth::from_env()
|
||||||
|
.map(|auth| {
|
||||||
|
auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir(
|
||||||
|
env.layout.logs(),
|
||||||
|
)))
|
||||||
|
})
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail(tx: &JobSender, message: String) {
|
||||||
|
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed(message))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收尾:取消就吞掉结果;成功入库;失败带 core 的诊断文案(M38 的 403 也走这条)。
|
||||||
|
fn finish(env: &HostEnv, tx: &JobSender, job: &LoginJob, result: AuthResult<MicrosoftSession>) {
|
||||||
|
if job.is_cancelled() {
|
||||||
|
let _ = tx.send(HostTask::Login(Box::default()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match result {
|
||||||
|
Ok(session) => match store_session(env, session) {
|
||||||
|
Ok(name) => {
|
||||||
|
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::succeeded(&name))));
|
||||||
|
let _ = tx.send(HostTask::Accounts(accounts(env, None)));
|
||||||
|
}
|
||||||
|
Err(message) => fail(tx, message),
|
||||||
|
},
|
||||||
|
Err(error) => fail(tx, error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 微软会话入库(元数据 + 凭据分文件,见 `core::auth::AccountStore`)。
|
||||||
|
fn store_session(env: &HostEnv, session: MicrosoftSession) -> Result<String, String> {
|
||||||
|
let meta = unlucky_core::auth::account::AccountMeta::new(
|
||||||
|
unlucky_core::auth::account::AccountKind::Microsoft,
|
||||||
|
session.profile_id.clone(),
|
||||||
|
session.profile_name.clone(),
|
||||||
|
now_ms(),
|
||||||
|
);
|
||||||
|
let name = meta.display_name().to_string();
|
||||||
|
let mut store = AccountStore::open(env.layout.auth_data());
|
||||||
|
store
|
||||||
|
.insert(meta, Credentials::Microsoft(session))
|
||||||
|
.and_then(|()| store.save())
|
||||||
|
.map_err(|error| format!("账户入库失败:{error}"))?;
|
||||||
|
Ok(name)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -109,6 +109,16 @@ impl CoreHost {
|
|||||||
}));
|
}));
|
||||||
accounts::start_login(self.env.clone(), self.tx.clone(), job);
|
accounts::start_login(self.env.clone(), self.tx.clone(), job);
|
||||||
}
|
}
|
||||||
|
AppCommand::StartBrowserLogin => {
|
||||||
|
self.login = Some(LoginJob::new());
|
||||||
|
let job = self.login.clone().unwrap_or_default();
|
||||||
|
self.send(CoreEvent::DeviceLoginChanged(crate::dto::DeviceLoginView {
|
||||||
|
phase: crate::dto::DeviceLoginPhase::Requesting,
|
||||||
|
message: "正在准备浏览器登录(本地回调端口)…".to_string(),
|
||||||
|
..crate::dto::DeviceLoginView::new()
|
||||||
|
}));
|
||||||
|
accounts::start_browser_login(self.env.clone(), self.tx.clone(), job);
|
||||||
|
}
|
||||||
AppCommand::CancelDeviceLogin => {
|
AppCommand::CancelDeviceLogin => {
|
||||||
if let Some(job) = &self.login {
|
if let Some(job) = &self.login {
|
||||||
job.cancel();
|
job.cancel();
|
||||||
|
|||||||
+127
-47
@@ -1,12 +1,14 @@
|
|||||||
//! 安装:新建实例向导 / 下载页「安装」→ core 安装管线(原版 + Fabric/Quilt)→ 登记实例。
|
//! 安装:新建实例向导 / 下载页「安装」→ core 安装管线 → 登记实例。
|
||||||
//!
|
//!
|
||||||
//! 与 CLI `ull install` 同序:`RemoteSource::resolve → InstallPlan::build → execute → register`。
|
//! 与 CLI `ull install` 同序:`RemoteSource::resolve → InstallPlan::build → execute → register`。
|
||||||
//! 差异:进度经 [`super::job::HostTask::Install`] 回流(文件计数 + 字节 + 速度/ETA),
|
//! Forge/NeoForge 多一段 installer processors([`super::install_processors`]):
|
||||||
|
//! `ProcessorPlan::build → 依赖库下载 → 选 Java → 串行 java -cp`,**跑完才写版本 JSON**。
|
||||||
|
//! 差异只在线程模型:进度经 [`super::job::HostTask::Install`] 回流(文件计数 + 字节 + 速度/ETA),
|
||||||
//! 且整段跑在后台线程里(`block_on` 不占 UI 线程)。
|
//! 且整段跑在后台线程里(`block_on` 不占 UI 线程)。
|
||||||
//!
|
//!
|
||||||
//! 加载器范围:原版 + Fabric/Quilt(纯库层)。Forge/NeoForge 要外调 java 跑 installer
|
//! 加载器范围:原版 + Fabric/Quilt/Forge/NeoForge(四家,与 CLI 对齐)。OptiFine/LiteLoader
|
||||||
//! processors(M35 的执行器在 CLI 侧),GUI 这一轮**显式拒装并说清去哪装**,
|
//! 两家 core 侧都还没有安装管线,仍在 GUI 里显式拒装并说清去哪装。
|
||||||
//! 而不是装出一个启动必崩的骨架。
|
//! 失败一律落 `.ULL/logs/install.log`([`super::install_journal`]),文案里带日志路径。
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -27,6 +29,8 @@ use crate::dto::{CreateRequest, InstallStage, LoaderKind};
|
|||||||
use crate::toast::{Toast, ToastKind};
|
use crate::toast::{Toast, ToastKind};
|
||||||
|
|
||||||
use super::env::HostEnv;
|
use super::env::HostEnv;
|
||||||
|
use super::install_journal::Journal;
|
||||||
|
use super::install_processors::{self, StageInput};
|
||||||
use super::job::{spawn, HostTask, JobSender};
|
use super::job::{spawn, HostTask, JobSender};
|
||||||
use super::meter::{sink_for, Meter};
|
use super::meter::{sink_for, Meter};
|
||||||
|
|
||||||
@@ -36,6 +40,26 @@ pub const CONCURRENCY: usize = 16;
|
|||||||
/// 用户取消的稳定文案(阶段边界生效)。
|
/// 用户取消的稳定文案(阶段边界生效)。
|
||||||
pub const CANCELLED: &str = "已取消(阶段边界停下;已下载的文件保留,重装会续用)";
|
pub const CANCELLED: &str = "已取消(阶段边界停下;已下载的文件保留,重装会续用)";
|
||||||
|
|
||||||
|
/// 一次安装任务的运行上下文(后台线程内一路传下去,免得每个函数排七八个参数)。
|
||||||
|
struct Job<'a> {
|
||||||
|
env: &'a HostEnv,
|
||||||
|
tx: &'a JobSender,
|
||||||
|
meter: &'a Arc<std::sync::Mutex<Meter>>,
|
||||||
|
cancel: &'a Arc<AtomicBool>,
|
||||||
|
journal: &'a Journal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Job<'_> {
|
||||||
|
/// processor 段的进度出口(同一套 channel/meter/journal)。
|
||||||
|
fn reporter(&self) -> install_processors::Reporter<'_> {
|
||||||
|
install_processors::Reporter {
|
||||||
|
tx: self.tx,
|
||||||
|
meter: self.meter,
|
||||||
|
journal: self.journal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 加载器徽标(`原版` / `Fabric 0.16.5`)。
|
/// 加载器徽标(`原版` / `Fabric 0.16.5`)。
|
||||||
pub fn loader_label(kind: LoaderKind, version: &str) -> String {
|
pub fn loader_label(kind: LoaderKind, version: &str) -> String {
|
||||||
if kind == LoaderKind::Vanilla || version.trim().is_empty() {
|
if kind == LoaderKind::Vanilla || version.trim().is_empty() {
|
||||||
@@ -45,20 +69,26 @@ pub fn loader_label(kind: LoaderKind, version: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GUI 侧支持的加载器(Forge/NeoForge 需要 installer processors)。
|
/// GUI 侧支持的加载器(五选:原版 + 四家加载器,与 CLI 的 `--loader` 词表一致)。
|
||||||
|
///
|
||||||
|
/// 写成穷举 `matches!` 而不是 `true`:将来 core 加了第六种加载器,这里默认拒绝,
|
||||||
|
/// 由 [`unsupported_reason`] 给出明确说法,而不是让它走进不属于它的安装分支。
|
||||||
pub fn supported(kind: LoaderKind) -> bool {
|
pub fn supported(kind: LoaderKind) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
kind,
|
kind,
|
||||||
LoaderKind::Vanilla | LoaderKind::Fabric | LoaderKind::Quilt
|
LoaderKind::Vanilla
|
||||||
|
| LoaderKind::Fabric
|
||||||
|
| LoaderKind::Quilt
|
||||||
|
| LoaderKind::Forge
|
||||||
|
| LoaderKind::NeoForge
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 不支持的加载器 → 给用户的可执行建议。
|
/// 不支持的加载器 → 给用户的可执行建议。
|
||||||
pub fn unsupported_reason(kind: LoaderKind) -> String {
|
pub fn unsupported_reason(kind: LoaderKind) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{} 需要 installer processors(外调 java 改 jar),GUI 这一轮还没接入;请先在终端跑 `ull install <版本> --loader {}`,装完回实例页刷新即可启动。",
|
"{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge);请先在实例页导入已有版本,或换成受支持的加载器。",
|
||||||
kind.label(),
|
kind.label()
|
||||||
kind.as_str()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,13 +96,28 @@ pub fn unsupported_reason(kind: LoaderKind) -> String {
|
|||||||
pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel: Arc<AtomicBool>) {
|
pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel: Arc<AtomicBool>) {
|
||||||
spawn("install", move || {
|
spawn("install", move || {
|
||||||
let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request)));
|
let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request)));
|
||||||
let result = run(&env, &tx, &request, &meter, &cancel);
|
let journal = Journal::open(&env);
|
||||||
|
journal.line(&format!(
|
||||||
|
"—— 开始安装 {}({} {})——",
|
||||||
|
request.instance_name(),
|
||||||
|
loader_label(request.loader, ""),
|
||||||
|
request.version
|
||||||
|
));
|
||||||
|
let job = Job {
|
||||||
|
env: &env,
|
||||||
|
tx: &tx,
|
||||||
|
meter: &meter,
|
||||||
|
cancel: &cancel,
|
||||||
|
journal: &journal,
|
||||||
|
};
|
||||||
|
let result = run(&job, &request);
|
||||||
let mut progress = meter
|
let mut progress = meter
|
||||||
.lock()
|
.lock()
|
||||||
.map(|guard| guard.progress.clone())
|
.map(|guard| guard.progress.clone())
|
||||||
.unwrap_or_else(|_| Meter::new(&request).progress);
|
.unwrap_or_else(|_| Meter::new(&request).progress);
|
||||||
match result {
|
match result {
|
||||||
Ok(name) => {
|
Ok(name) => {
|
||||||
|
journal.line(&format!("完成:实例 {name} 已登记"));
|
||||||
progress.stage = InstallStage::Done;
|
progress.stage = InstallStage::Done;
|
||||||
progress.files_done = progress.files_total;
|
progress.files_done = progress.files_total;
|
||||||
progress.message = format!("已安装 {name}({})", progress.loader);
|
progress.message = format!("已安装 {name}({})", progress.loader);
|
||||||
@@ -88,15 +133,24 @@ pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel
|
|||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
Err(reason) => {
|
Err(reason) => {
|
||||||
progress.stage = if reason == CANCELLED {
|
let cancelled = reason == CANCELLED;
|
||||||
|
progress.stage = if cancelled {
|
||||||
InstallStage::Cancelled
|
InstallStage::Cancelled
|
||||||
} else {
|
} else {
|
||||||
InstallStage::Failed
|
InstallStage::Failed
|
||||||
};
|
};
|
||||||
progress.message.clone_from(&reason);
|
// 失败文案带上日志路径:面板只有一行、Toast 4 秒就消失,
|
||||||
|
// 装日志是用户事后能自己回看的唯一渠道(进度板之外)。
|
||||||
|
let text = if cancelled {
|
||||||
|
reason
|
||||||
|
} else {
|
||||||
|
journal.line(&format!("安装未完成:{reason}"));
|
||||||
|
format!("{reason}(日志:{})", journal.path().display())
|
||||||
|
};
|
||||||
|
progress.message.clone_from(&text);
|
||||||
let _ = tx.send(HostTask::InstallDone {
|
let _ = tx.send(HostTask::InstallDone {
|
||||||
progress: Box::new(progress),
|
progress: Box::new(progress),
|
||||||
result: Err(reason),
|
result: Err(text),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,13 +158,7 @@ pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 真安装(同步跑完,调用方在后台线程里)。
|
/// 真安装(同步跑完,调用方在后台线程里)。
|
||||||
fn run(
|
fn run(job: &Job<'_>, request: &CreateRequest) -> Result<String, String> {
|
||||||
env: &HostEnv,
|
|
||||||
tx: &JobSender,
|
|
||||||
request: &CreateRequest,
|
|
||||||
meter: &Arc<std::sync::Mutex<Meter>>,
|
|
||||||
cancel: &Arc<AtomicBool>,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
if request.version.trim().is_empty() {
|
if request.version.trim().is_empty() {
|
||||||
return Err("没有选择版本".to_string());
|
return Err("没有选择版本".to_string());
|
||||||
}
|
}
|
||||||
@@ -118,26 +166,31 @@ fn run(
|
|||||||
return Err(unsupported_reason(request.loader));
|
return Err(unsupported_reason(request.loader));
|
||||||
}
|
}
|
||||||
let name = request.instance_name();
|
let name = request.instance_name();
|
||||||
|
let env = job.env;
|
||||||
let engine = super::engine::engine(env);
|
let engine = super::engine::engine(env);
|
||||||
{
|
{
|
||||||
let mut guard = meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||||
guard.stage(InstallStage::Planning, "正在解析版本清单…");
|
guard.stage(InstallStage::Planning, "正在解析版本清单…");
|
||||||
let _ = tx.send(HostTask::Install(Box::new(guard.progress.clone())));
|
let _ = job
|
||||||
|
.tx
|
||||||
|
.send(HostTask::Install(Box::new(guard.progress.clone())));
|
||||||
}
|
}
|
||||||
|
|
||||||
if cancel.load(Ordering::SeqCst) {
|
if job.cancel.load(Ordering::SeqCst) {
|
||||||
return Err(CANCELLED.to_string());
|
return Err(CANCELLED.to_string());
|
||||||
}
|
}
|
||||||
let version_id = if request.loader == LoaderKind::Vanilla {
|
let version_id = if request.loader == LoaderKind::Vanilla {
|
||||||
install_vanilla(env, &engine, request, tx, meter, cancel)?
|
install_vanilla(job, &engine, request)?
|
||||||
} else {
|
} else {
|
||||||
install_loader(env, &engine, request, tx, meter, cancel)?
|
install_loader(job, &engine, request)?
|
||||||
};
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut guard = meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||||
guard.stage(InstallStage::Registering, "正在登记实例…");
|
guard.stage(InstallStage::Registering, "正在登记实例…");
|
||||||
let _ = tx.send(HostTask::Install(Box::new(guard.progress.clone())));
|
let _ = job
|
||||||
|
.tx
|
||||||
|
.send(HostTask::Install(Box::new(guard.progress.clone())));
|
||||||
}
|
}
|
||||||
let created_at =
|
let created_at =
|
||||||
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now());
|
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now());
|
||||||
@@ -153,17 +206,15 @@ fn run(
|
|||||||
|
|
||||||
/// 原版安装(`resolve → plan → [prefetch 资源索引] → execute`)。
|
/// 原版安装(`resolve → plan → [prefetch 资源索引] → execute`)。
|
||||||
fn install_vanilla(
|
fn install_vanilla(
|
||||||
env: &HostEnv,
|
job: &Job<'_>,
|
||||||
engine: &DownloadEngine,
|
engine: &DownloadEngine,
|
||||||
request: &CreateRequest,
|
request: &CreateRequest,
|
||||||
tx: &JobSender,
|
|
||||||
meter: &Arc<std::sync::Mutex<Meter>>,
|
|
||||||
cancel: &Arc<AtomicBool>,
|
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
|
let env = job.env;
|
||||||
let version = request.version.trim();
|
let version = request.version.trim();
|
||||||
let meta_cache = env.layout.meta_cache();
|
let meta_cache = env.layout.meta_cache();
|
||||||
let now = now_ms();
|
let now = now_ms();
|
||||||
let sink = sink_for(tx.clone(), Arc::clone(meter));
|
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
|
||||||
block_on(async {
|
block_on(async {
|
||||||
let source = RemoteSource::new(engine, &env.game, &meta_cache, now);
|
let source = RemoteSource::new(engine, &env.game, &meta_cache, now);
|
||||||
let resolved = source.resolve(version).await.map_err(install_error)?;
|
let resolved = source.resolve(version).await.map_err(install_error)?;
|
||||||
@@ -197,7 +248,7 @@ fn install_vanilla(
|
|||||||
}
|
}
|
||||||
let (bytes_total, _) = plan.total_bytes();
|
let (bytes_total, _) = plan.total_bytes();
|
||||||
{
|
{
|
||||||
let mut guard = meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||||
guard.progress.files_total = u64::try_from(plan.file_count()).unwrap_or(u64::MAX);
|
guard.progress.files_total = u64::try_from(plan.file_count()).unwrap_or(u64::MAX);
|
||||||
guard.progress.bytes_total = bytes_total;
|
guard.progress.bytes_total = bytes_total;
|
||||||
guard.stage(
|
guard.stage(
|
||||||
@@ -205,7 +256,7 @@ fn install_vanilla(
|
|||||||
&format!("开始下载 {} 个文件…", plan.file_count()),
|
&format!("开始下载 {} 个文件…", plan.file_count()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if cancel.load(Ordering::SeqCst) {
|
if job.cancel.load(Ordering::SeqCst) {
|
||||||
return Err(CANCELLED.to_string());
|
return Err(CANCELLED.to_string());
|
||||||
}
|
}
|
||||||
let report = execute(engine, &plan, CONCURRENCY, Some(sink))
|
let report = execute(engine, &plan, CONCURRENCY, Some(sink))
|
||||||
@@ -216,15 +267,13 @@ fn install_vanilla(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 加载器安装(原版前置 → 加载器库 → 版本 JSON 落盘)。
|
/// 加载器安装(原版前置 → installer processors → 加载器库 → 版本 JSON 落盘)。
|
||||||
fn install_loader(
|
fn install_loader(
|
||||||
env: &HostEnv,
|
job: &Job<'_>,
|
||||||
engine: &DownloadEngine,
|
engine: &DownloadEngine,
|
||||||
request: &CreateRequest,
|
request: &CreateRequest,
|
||||||
tx: &JobSender,
|
|
||||||
meter: &Arc<std::sync::Mutex<Meter>>,
|
|
||||||
cancel: &Arc<AtomicBool>,
|
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
|
let env = job.env;
|
||||||
let version = request.version.trim();
|
let version = request.version.trim();
|
||||||
let kind = CoreLoaderKind::parse(request.loader.as_str())
|
let kind = CoreLoaderKind::parse(request.loader.as_str())
|
||||||
.ok_or_else(|| format!("未知加载器 `{}`", request.loader.as_str()))?;
|
.ok_or_else(|| format!("未知加载器 `{}`", request.loader.as_str()))?;
|
||||||
@@ -237,6 +286,12 @@ fn install_loader(
|
|||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
|
job.journal.line(&format!(
|
||||||
|
"加载器解析:{} {} → {}",
|
||||||
|
release.kind.label(),
|
||||||
|
release.loader_version,
|
||||||
|
release.version_id
|
||||||
|
));
|
||||||
|
|
||||||
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||||
layout: &env.game,
|
layout: &env.game,
|
||||||
@@ -247,22 +302,47 @@ fn install_loader(
|
|||||||
if plan.vanilla_install_needed {
|
if plan.vanilla_install_needed {
|
||||||
let mut vanilla = request.clone();
|
let mut vanilla = request.clone();
|
||||||
vanilla.loader = LoaderKind::Vanilla;
|
vanilla.loader = LoaderKind::Vanilla;
|
||||||
install_vanilla(env, engine, &vanilla, tx, meter, cancel)?;
|
install_vanilla(job, engine, &vanilla)?;
|
||||||
|
}
|
||||||
|
// 两段(加载器库 + 处理器依赖库)在动手前一次预记账:进度条只走一条单调曲线,
|
||||||
|
// 处理器依赖库下完不会先满格、再在加载器库开始时回落到一半。
|
||||||
|
{
|
||||||
|
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||||
|
let (bytes, _) = plan.install.total_bytes();
|
||||||
|
guard.expect(plan.files().len(), bytes);
|
||||||
|
}
|
||||||
|
// Forge/NeoForge:installer processors 必须先跑完,`execute` 才允许写版本 JSON。
|
||||||
|
if release.requires_installer() {
|
||||||
|
let input = StageInput {
|
||||||
|
kind: release.kind,
|
||||||
|
mc_version: &release.mc_version,
|
||||||
|
version_id: &release.version_id,
|
||||||
|
profile: release.install_profile.as_ref(),
|
||||||
|
installer: release.installer.as_deref(),
|
||||||
|
installed: env.game.is_version_installed(&release.version_id),
|
||||||
|
};
|
||||||
|
// `None` = 这一版不需要跑处理器(版本 JSON 已在盘上 / profile 没有 client 侧步骤),
|
||||||
|
// 进度与日志都由处理器段自己写全,这里只消费返回值。
|
||||||
|
let _ = install_processors::stage(
|
||||||
|
env,
|
||||||
|
engine,
|
||||||
|
&input,
|
||||||
|
&install_processors::CORE,
|
||||||
|
&job.reporter(),
|
||||||
|
job.cancel,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
let mut guard = meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||||
guard.progress.files_total += u64::try_from(plan.files().len()).unwrap_or(u64::MAX);
|
|
||||||
let (bytes, _) = plan.install.total_bytes();
|
|
||||||
guard.progress.bytes_total = guard.progress.bytes_total.max(bytes);
|
|
||||||
guard.stage(
|
guard.stage(
|
||||||
InstallStage::Downloading,
|
InstallStage::Downloading,
|
||||||
&format!("开始下载加载器库({} 个文件)…", plan.files().len()),
|
&format!("开始下载加载器库({} 个文件)…", plan.files().len()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if cancel.load(Ordering::SeqCst) {
|
if job.cancel.load(Ordering::SeqCst) {
|
||||||
return Err(CANCELLED.to_string());
|
return Err(CANCELLED.to_string());
|
||||||
}
|
}
|
||||||
let sink = sink_for(tx.clone(), Arc::clone(meter));
|
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
|
||||||
let report = block_on(async {
|
let report = block_on(async {
|
||||||
execute(engine, &plan.install, CONCURRENCY, Some(sink))
|
execute(engine, &plan.install, CONCURRENCY, Some(sink))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//! 安装过程的可回溯日志(`.ULL/logs/install.log`,一轮一行)。
|
||||||
|
//!
|
||||||
|
//! 起因(M45):Forge/NeoForge 装失败时,GUI 只有一行 Toast,processor 的 stderr 尾部
|
||||||
|
//! 与「哪一步挂了」在面板一关就没了。这里把安装过程按行追加到 `.ULL/logs/`
|
||||||
|
//! (与 `auth.jsonl` 同一分区),失败文案里带上日志路径,用户能自己回看。
|
||||||
|
//!
|
||||||
|
//! 三条纪律(与 auth 日志同源):
|
||||||
|
//! 1. **只追加**,不轮转、不截断(体积由「一行一条、只记诊断字段」控制);
|
||||||
|
//! 2. **只记诊断字段**:阶段、processor 坐标、退出码、已脱敏的 stderr 尾部(core 侧脱敏);
|
||||||
|
//! 3. **写盘尽力而为**:开不了文件就静默跳过,绝不让日志失败连累安装。
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use unlucky_core::launch::time;
|
||||||
|
|
||||||
|
use super::env::HostEnv;
|
||||||
|
|
||||||
|
/// 日志文件名(在 `.ULL/logs/` 下)。
|
||||||
|
pub const INSTALL_LOG_FILE: &str = "install.log";
|
||||||
|
|
||||||
|
/// 一轮安装的日志句柄(`Clone` 很便宜:只有一个路径)。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Journal {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Journal {
|
||||||
|
/// 按数据根打开(`logs/` 不存在就补建;建不出来只是没有日志)。
|
||||||
|
pub fn open(env: &HostEnv) -> Self {
|
||||||
|
let dir = env.layout.logs();
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
Self {
|
||||||
|
path: dir.join(INSTALL_LOG_FILE),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日志路径(失败文案里给用户看)。
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 追加一行(自动补 RFC3339 时间戳;写失败静默)。
|
||||||
|
pub fn line(&self, text: &str) {
|
||||||
|
let Ok(mut file) = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&self.path)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = writeln!(
|
||||||
|
file,
|
||||||
|
"[{}] {text}",
|
||||||
|
time::format_rfc3339_millis(time::now())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lines_are_appended_with_a_timestamp() {
|
||||||
|
let root = std::env::temp_dir().join(format!("ull-install-journal-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
let env = HostEnv::at_root(&root);
|
||||||
|
let journal = Journal::open(&env);
|
||||||
|
journal.line("第一行");
|
||||||
|
journal.line("第二行");
|
||||||
|
let text = std::fs::read_to_string(journal.path()).expect("日志存在");
|
||||||
|
assert!(text.contains("第一行") && text.contains("第二行"), "{text}");
|
||||||
|
assert_eq!(text.lines().count(), 2);
|
||||||
|
assert!(text.starts_with("[2"), "带时间戳:{text}");
|
||||||
|
assert_eq!(journal.path(), env.layout.logs().join(INSTALL_LOG_FILE));
|
||||||
|
std::fs::remove_dir_all(&root).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
//! Forge / NeoForge 的 installer processors 段(M45:GUI 与 CLI 行为对齐)。
|
||||||
|
//!
|
||||||
|
//! 顺序与 CLI `ull install --loader forge` 完全一致(`crates/cli/src/commands/install/
|
||||||
|
//! loader_processors.rs`),只是把进度接到 GUI 的回流通道上:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! ProcessorPlan::build(纯计算:变量表 + 每步 java -cp + 依赖库清单)
|
||||||
|
//! → download_libraries(M27 引擎;进度回流 HostTask::Install)
|
||||||
|
//! → select_java(M33 解析链:托管 > JAVA_HOME > PATH > 扫盘)
|
||||||
|
//! → run(串行 java -cp;每步一条面板文案 + 一行装日志)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 四条硬约束(与 CLI 同源,GUI 侧一条都不放宽):
|
||||||
|
//!
|
||||||
|
//! 1. **processors 跑完才允许写版本 JSON**:版本清单存在 = 这版装完了,抢在 processors
|
||||||
|
//! 前写会留下启动必崩却被报成"已安装"的实例(顺序由 `install_loader` 保证);
|
||||||
|
//! 2. **幂等**:版本 JSON 已在盘上 = 上一轮跑完了,整段跳过;
|
||||||
|
//! 3. **取消在阶段边界生效**:处理器阶段是原子段(串行外调 java,进程内没有取消钩子),
|
||||||
|
//! 进入前与退出后各查一次 `cancel`;
|
||||||
|
//! 4. **失败不静默**:结构化文案(带 `processor_*` 大类码)+ 装日志一行一条,由调用方
|
||||||
|
//! 再补 Toast(`HostTask::InstallDone` 的既有通路)。
|
||||||
|
//!
|
||||||
|
//! 执行后端走 [`Backend`]:真实实现 [`CoreBackend`] 直接转调 core,单测注入 fake,
|
||||||
|
//! 因此「计划怎么组、Java 找不到怎么说、依赖库下漏怎么报」都能在没有网络/没有 JDK
|
||||||
|
//! 的机器上断言。
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use unlucky_core::addon::loader::forge::InstallProfile;
|
||||||
|
use unlucky_core::addon::loader::processors::{
|
||||||
|
self, ProcessorError, ProcessorPlan, ProcessorPlanInput, ProcessorRun, StepOutcome, StepSink,
|
||||||
|
StepStatus,
|
||||||
|
};
|
||||||
|
use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind;
|
||||||
|
use unlucky_core::download::blocking::block_on;
|
||||||
|
use unlucky_core::download::DownloadEngine;
|
||||||
|
use unlucky_core::game::arguments::Environment;
|
||||||
|
use unlucky_core::game::install::{InstallReport, PlanFile, ProgressSink};
|
||||||
|
use unlucky_core::game::java::{JavaRequirement, JavaRuntime};
|
||||||
|
|
||||||
|
use crate::dto::InstallStage;
|
||||||
|
|
||||||
|
use super::env::HostEnv;
|
||||||
|
use super::install::{CANCELLED, CONCURRENCY};
|
||||||
|
use super::install_journal::Journal;
|
||||||
|
use super::job::{HostTask, JobSender};
|
||||||
|
use super::meter::{sink_for, Meter};
|
||||||
|
|
||||||
|
/// 真实后端(无状态,可以放静态里复用)。
|
||||||
|
pub(super) const CORE: CoreBackend = CoreBackend;
|
||||||
|
|
||||||
|
/// processors 段需要的输入(从 `LoaderRelease` 摘出来,便于单测构造)。
|
||||||
|
pub(super) struct StageInput<'a> {
|
||||||
|
/// 加载器种类(补库时 maven 基址的兜底来源)。
|
||||||
|
pub kind: CoreLoaderKind,
|
||||||
|
/// 目标原版版本。
|
||||||
|
pub mc_version: &'a str,
|
||||||
|
/// loader 版本 id(依赖库落点 + 临时目录名)。
|
||||||
|
pub version_id: &'a str,
|
||||||
|
/// installer 里读出来的 install profile(`requires_installer` 为真时必须有)。
|
||||||
|
pub profile: Option<&'a InstallProfile>,
|
||||||
|
/// installer jar 的磁盘路径。
|
||||||
|
pub installer: Option<&'a Path>,
|
||||||
|
/// 版本 JSON 已在盘上 = 上一轮跑完了,整段跳过(幂等)。
|
||||||
|
pub installed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// processors 段的执行后端(真实实现转调 core;单测注入 fake)。
|
||||||
|
pub(super) trait Backend {
|
||||||
|
/// 下载 processor 依赖库(M27 引擎:镜像/健康度/退避/校验)。
|
||||||
|
fn download_libraries(
|
||||||
|
&self,
|
||||||
|
engine: &DownloadEngine,
|
||||||
|
files: &[PlanFile],
|
||||||
|
sink: Option<ProgressSink>,
|
||||||
|
) -> InstallReport;
|
||||||
|
/// 按 M33 的解析链挑一个能跑 installer 的 Java。
|
||||||
|
fn select_java(&self, managed: &Path, requirement: &JavaRequirement) -> Option<JavaRuntime>;
|
||||||
|
/// 串行跑 processors。
|
||||||
|
fn run(
|
||||||
|
&self,
|
||||||
|
plan: &ProcessorPlan,
|
||||||
|
java: &JavaRuntime,
|
||||||
|
sink: Option<StepSink>,
|
||||||
|
) -> Result<ProcessorRun, ProcessorError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 真实后端:三件事全部转调 core 的同一个执行器(CLI 用的就是它)。
|
||||||
|
pub(super) struct CoreBackend;
|
||||||
|
|
||||||
|
impl Backend for CoreBackend {
|
||||||
|
fn download_libraries(
|
||||||
|
&self,
|
||||||
|
engine: &DownloadEngine,
|
||||||
|
files: &[PlanFile],
|
||||||
|
sink: Option<ProgressSink>,
|
||||||
|
) -> InstallReport {
|
||||||
|
block_on(processors::download_libraries(
|
||||||
|
engine,
|
||||||
|
files,
|
||||||
|
CONCURRENCY,
|
||||||
|
sink,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_java(&self, managed: &Path, requirement: &JavaRequirement) -> Option<JavaRuntime> {
|
||||||
|
processors::select_java(managed, requirement)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
&self,
|
||||||
|
plan: &ProcessorPlan,
|
||||||
|
java: &JavaRuntime,
|
||||||
|
sink: Option<StepSink>,
|
||||||
|
) -> Result<ProcessorRun, ProcessorError> {
|
||||||
|
processors::run(plan, java, sink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 段内的进度出口:进度面板(`HostTask::Install`)+ 装日志。
|
||||||
|
pub(super) struct Reporter<'a> {
|
||||||
|
/// 后台线程 → host 的通道。
|
||||||
|
pub tx: &'a JobSender,
|
||||||
|
/// 进度记账器。
|
||||||
|
pub meter: &'a Arc<Mutex<Meter>>,
|
||||||
|
/// 装日志。
|
||||||
|
pub journal: &'a Journal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reporter<'_> {
|
||||||
|
/// 换阶段并推一条进度。
|
||||||
|
fn stage(&self, stage: InstallStage, message: &str) {
|
||||||
|
let Ok(mut guard) = self.meter.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
guard.stage(stage, message);
|
||||||
|
let _ = self
|
||||||
|
.tx
|
||||||
|
.send(HostTask::Install(Box::new(guard.progress.clone())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 先把待下的库记账(文件数 + 已知字节),下载回调再逐个累加。
|
||||||
|
fn expect(&self, files: usize, bytes: u64) {
|
||||||
|
let Ok(mut guard) = self.meter.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
guard.expect(files, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 失败留痕(文案由调用方决定怎么呈现;这里只保证日志里有一条)。
|
||||||
|
fn fail(&self, reason: String) -> String {
|
||||||
|
self.journal.line(&format!("失败:{reason}"));
|
||||||
|
reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 跑 Forge / NeoForge 的 installer processors(`Ok(None)` = 这一轮不需要跑)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// 元数据缺 profile/installer、依赖库没下全、找不到可用 Java、processor 非零退出、
|
||||||
|
/// 产物没落盘、用户取消时返回给 UI 的文案(已写进装日志)。
|
||||||
|
pub(super) fn stage(
|
||||||
|
env: &HostEnv,
|
||||||
|
engine: &DownloadEngine,
|
||||||
|
input: &StageInput<'_>,
|
||||||
|
backend: &dyn Backend,
|
||||||
|
reporter: &Reporter<'_>,
|
||||||
|
cancel: &Arc<AtomicBool>,
|
||||||
|
) -> Result<Option<ProcessorRun>, String> {
|
||||||
|
if input.installed {
|
||||||
|
reporter.journal.line(&format!(
|
||||||
|
"{} 的版本 JSON 已在盘上,跳过 installer processors",
|
||||||
|
input.version_id
|
||||||
|
));
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let (Some(profile), Some(installer)) = (input.profile, input.installer) else {
|
||||||
|
return Err(reporter.fail(metadata_missing(input)));
|
||||||
|
};
|
||||||
|
reporter.stage(InstallStage::Installing, "正在组 installer 处理器计划…");
|
||||||
|
let scratch = env
|
||||||
|
.layout
|
||||||
|
.cache()
|
||||||
|
.join("processors")
|
||||||
|
.join(safe_name(input.version_id));
|
||||||
|
let plan = ProcessorPlan::build(ProcessorPlanInput {
|
||||||
|
kind: input.kind,
|
||||||
|
mc_version: input.mc_version,
|
||||||
|
version_id: input.version_id,
|
||||||
|
profile,
|
||||||
|
layout: &env.game,
|
||||||
|
installer,
|
||||||
|
scratch: &scratch,
|
||||||
|
environment: &Environment::host(),
|
||||||
|
})
|
||||||
|
.map_err(|error| reporter.fail(diagnostic(&error)))?;
|
||||||
|
for warning in &plan.warnings {
|
||||||
|
reporter.journal.line(&format!("规划告警:{warning}"));
|
||||||
|
}
|
||||||
|
if plan.is_empty() {
|
||||||
|
reporter
|
||||||
|
.journal
|
||||||
|
.line("install profile 没有 client 侧 processor,跳过这一段");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = plan.step_count();
|
||||||
|
let bytes = plan.libraries.iter().filter_map(|file| file.size).sum();
|
||||||
|
reporter.expect(plan.libraries.len(), bytes);
|
||||||
|
reporter.stage(
|
||||||
|
InstallStage::Downloading,
|
||||||
|
&format!("下载 installer 依赖库({} 个文件)…", plan.libraries.len()),
|
||||||
|
);
|
||||||
|
if cancel.load(Ordering::SeqCst) {
|
||||||
|
return Err(CANCELLED.to_string());
|
||||||
|
}
|
||||||
|
let sink = sink_for(reporter.tx.clone(), Arc::clone(reporter.meter));
|
||||||
|
let libraries = backend.download_libraries(engine, &plan.libraries, Some(sink));
|
||||||
|
if !libraries.is_complete() {
|
||||||
|
return Err(reporter.fail(libraries_failed(&libraries)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let managed = env.layout.java_managed();
|
||||||
|
let Some(java) = backend.select_java(&managed, &plan.java_requirement) else {
|
||||||
|
return Err(reporter.fail(java_missing(&plan.java_requirement, &managed)));
|
||||||
|
};
|
||||||
|
reporter.journal.line(&format!(
|
||||||
|
"用 {}({})跑 {total} 步 processor",
|
||||||
|
java.binary.display(),
|
||||||
|
java_label(&java)
|
||||||
|
));
|
||||||
|
reporter.stage(
|
||||||
|
InstallStage::Installing,
|
||||||
|
&format!("运行 installer 处理器 0/{total}({})…", java_label(&java)),
|
||||||
|
);
|
||||||
|
if cancel.load(Ordering::SeqCst) {
|
||||||
|
return Err(CANCELLED.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let steps = step_sink(
|
||||||
|
reporter.tx.clone(),
|
||||||
|
Arc::clone(reporter.meter),
|
||||||
|
reporter.journal.clone(),
|
||||||
|
total,
|
||||||
|
);
|
||||||
|
let run = backend
|
||||||
|
.run(&plan, &java, Some(steps))
|
||||||
|
.map_err(|error| reporter.fail(diagnostic(&error)))?;
|
||||||
|
for warning in &run.warnings {
|
||||||
|
reporter.journal.line(&format!("执行告警:{warning}"));
|
||||||
|
}
|
||||||
|
reporter.journal.line(&format!(
|
||||||
|
"processors 完成:执行 {} 步、跳过 {} 步",
|
||||||
|
run.executed(),
|
||||||
|
run.skipped()
|
||||||
|
));
|
||||||
|
reporter.stage(
|
||||||
|
InstallStage::Installing,
|
||||||
|
&format!(
|
||||||
|
"installer 处理器完成(执行 {} 步、跳过 {} 步),正在写入版本清单…",
|
||||||
|
run.executed(),
|
||||||
|
run.skipped()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if cancel.load(Ordering::SeqCst) {
|
||||||
|
return Err(CANCELLED.to_string());
|
||||||
|
}
|
||||||
|
Ok(Some(run))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 每步完成的回调:面板一行 + 装日志一行。
|
||||||
|
///
|
||||||
|
/// `StepSink` 要求 `'static`,所以这里全持所有权(通道/记账器/日志都是克隆)。
|
||||||
|
fn step_sink(tx: JobSender, meter: Arc<Mutex<Meter>>, journal: Journal, total: usize) -> StepSink {
|
||||||
|
Arc::new(move |outcome: &StepOutcome| {
|
||||||
|
let line = step_line(outcome, total);
|
||||||
|
journal.line(&line);
|
||||||
|
let Ok(mut guard) = meter.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
guard.stage(InstallStage::Installing, &line);
|
||||||
|
let _ = tx.send(HostTask::Install(Box::new(guard.progress.clone())));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一步的进度/日志文案(纯函数,单测直接断言)。
|
||||||
|
pub(super) fn step_line(outcome: &StepOutcome, total: usize) -> String {
|
||||||
|
let suffix = match outcome.status {
|
||||||
|
StepStatus::Skipped => "(产物已就绪,跳过)".to_string(),
|
||||||
|
StepStatus::Executed => format!("({} ms)", outcome.elapsed_ms),
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"installer 处理器 {}/{}:{}{suffix}",
|
||||||
|
outcome.index, total, outcome.label
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// processor 错误 → 用户可读文案(保留 `processor_*` 大类码,便于按码搜日志)。
|
||||||
|
pub(super) fn diagnostic(error: &ProcessorError) -> String {
|
||||||
|
let mut text = format!("{}({})", error.message(), error.kind().as_str());
|
||||||
|
if let Some(hint) = error.hint() {
|
||||||
|
text.push_str(";建议:");
|
||||||
|
text.push_str(hint);
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 缺 Java 运行时的专用文案(比通用错误多给一条「去哪装」)。
|
||||||
|
pub(super) fn java_missing(requirement: &JavaRequirement, managed: &Path) -> String {
|
||||||
|
format!(
|
||||||
|
"跑 installer 需要 {},但一个可用的 Java 运行时都没找到;请到「Java」页安装托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})",
|
||||||
|
requirement.describe(),
|
||||||
|
managed.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 依赖库没下全 → 列前三条失败明细。
|
||||||
|
pub(super) fn libraries_failed(report: &InstallReport) -> String {
|
||||||
|
let details: Vec<String> = report
|
||||||
|
.failures(3)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|outcome| {
|
||||||
|
outcome
|
||||||
|
.failure
|
||||||
|
.as_ref()
|
||||||
|
.map(|failure| format!("{}:{}", outcome.name, failure.message))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!(
|
||||||
|
"installer 依赖库没下全({} 个失败,共 {} 个):{}",
|
||||||
|
report.failed(),
|
||||||
|
report.files.len(),
|
||||||
|
details.join(";")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 需要 installer 但元数据不全(缓存半截 / 换线路重解析过)。
|
||||||
|
fn metadata_missing(input: &StageInput<'_>) -> String {
|
||||||
|
format!(
|
||||||
|
"{} {} 需要跑 installer processors,但没拿到 install profile / installer 路径;重跑一次即可(元数据缓存可能不完整)",
|
||||||
|
input.kind.label(),
|
||||||
|
input.version_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Java 的一行短标识(`Java 21 · managed`)。
|
||||||
|
fn java_label(java: &JavaRuntime) -> String {
|
||||||
|
match java.major {
|
||||||
|
Some(major) => format!("Java {major} · {}", java.origin.as_str()),
|
||||||
|
None => format!("Java 主版本未知 · {}", java.origin.as_str()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 版本 id → 可做目录名的片段(`+` 与路径分隔符都换掉,与 CLI 同规则)。
|
||||||
|
fn safe_name(raw: &str) -> String {
|
||||||
|
raw.chars()
|
||||||
|
.map(|ch| {
|
||||||
|
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
|
||||||
|
ch
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_end_matches('.')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "install_processors_fixture.rs"]
|
||||||
|
mod fixture;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "install_processors_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
//! installer processors 段的单测夹具(拆文件守 400 行上限)。
|
||||||
|
//!
|
||||||
|
//! 不需要网络、不需要 JDK、不真的起 java:`Harness` 给一个临时数据根 + 进度通道,
|
||||||
|
//! `Fake` 回放预置的下载/Java/执行结果并记录被调顺序与组出来的命令行。
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
|
use std::sync::mpsc::Receiver;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use unlucky_core::addon::loader::forge::{InstallProfile, Processor};
|
||||||
|
use unlucky_core::addon::loader::processors::{
|
||||||
|
ProcessorError, ProcessorPlan, ProcessorRun, StepOutcome, StepSink, StepStatus,
|
||||||
|
};
|
||||||
|
use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind;
|
||||||
|
use unlucky_core::download::{DownloadEngine, DownloadErrorKind};
|
||||||
|
use unlucky_core::game::install::{
|
||||||
|
FileFailure, FileOutcome, FileStatus, InstallReport, PlanFile, ProgressSink,
|
||||||
|
};
|
||||||
|
use unlucky_core::game::java::{JavaOrigin, JavaRequirement, JavaRuntime};
|
||||||
|
|
||||||
|
use super::{Backend, Reporter, StageInput};
|
||||||
|
use crate::dto::{CreateRequest, LoaderKind};
|
||||||
|
use crate::host::env::HostEnv;
|
||||||
|
use crate::host::install_journal::Journal;
|
||||||
|
use crate::host::job::{channel, HostTask, JobSender};
|
||||||
|
use crate::host::meter::Meter;
|
||||||
|
|
||||||
|
static SEQ: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
/// 一步的计划视图(序号 / 坐标 / 参数 / classpath 条数)。
|
||||||
|
pub(super) type StepView = (usize, String, Vec<String>, usize);
|
||||||
|
|
||||||
|
/// 一段 cases 的公共环境:临时数据根 + 引擎 + 进度通道。
|
||||||
|
pub(super) struct Harness {
|
||||||
|
dir: PathBuf,
|
||||||
|
pub(super) env: HostEnv,
|
||||||
|
pub(super) engine: DownloadEngine,
|
||||||
|
tx: JobSender,
|
||||||
|
rx: Receiver<HostTask>,
|
||||||
|
pub(super) meter: Arc<Mutex<Meter>>,
|
||||||
|
pub(super) journal: Journal,
|
||||||
|
pub(super) cancel: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Harness {
|
||||||
|
pub(super) fn new() -> Self {
|
||||||
|
let seq = SEQ.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let dir = std::env::temp_dir().join(format!("ull-proc-{}-{seq}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
let env = HostEnv::at_root(&dir);
|
||||||
|
let engine = crate::host::engine::engine(&env);
|
||||||
|
let request = CreateRequest {
|
||||||
|
version: "1.20.1".to_string(),
|
||||||
|
loader: LoaderKind::Forge,
|
||||||
|
loader_version: "47.2.0".to_string(),
|
||||||
|
name: "proc".to_string(),
|
||||||
|
};
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
Self {
|
||||||
|
journal: Journal::open(&env),
|
||||||
|
meter: Arc::new(Mutex::new(Meter::new(&request))),
|
||||||
|
cancel: Arc::new(AtomicBool::new(false)),
|
||||||
|
dir,
|
||||||
|
env,
|
||||||
|
engine,
|
||||||
|
tx,
|
||||||
|
rx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn reporter(&self) -> Reporter<'_> {
|
||||||
|
Reporter {
|
||||||
|
tx: &self.tx,
|
||||||
|
meter: &self.meter,
|
||||||
|
journal: &self.journal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进度面板收到的所有文案。
|
||||||
|
pub(super) fn messages(&self) -> Vec<String> {
|
||||||
|
self.rx
|
||||||
|
.try_iter()
|
||||||
|
.filter_map(|task| match task {
|
||||||
|
HostTask::Install(progress) => Some(progress.message.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 装日志全文。
|
||||||
|
pub(super) fn log(&self) -> String {
|
||||||
|
std::fs::read_to_string(self.journal.path()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Harness {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
std::fs::remove_dir_all(&self.dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注入用的假后端:回放预置结果 + 记录调用顺序与被组出来的命令行。
|
||||||
|
pub(super) struct Fake {
|
||||||
|
pub(super) libraries_ok: bool,
|
||||||
|
pub(super) java: Option<JavaRuntime>,
|
||||||
|
pub(super) run_error: Option<ProcessorError>,
|
||||||
|
calls: RefCell<Vec<&'static str>>,
|
||||||
|
steps: RefCell<Vec<StepView>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fake {
|
||||||
|
pub(super) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
libraries_ok: true,
|
||||||
|
java: Some(java()),
|
||||||
|
run_error: None,
|
||||||
|
calls: RefCell::new(Vec::new()),
|
||||||
|
steps: RefCell::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn calls(&self) -> Vec<&'static str> {
|
||||||
|
self.calls.borrow().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 被组出来的每步(序号 / 坐标 / 参数 / classpath 条数)。
|
||||||
|
pub(super) fn steps(&self) -> Vec<StepView> {
|
||||||
|
self.steps.borrow().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Backend for Fake {
|
||||||
|
fn download_libraries(
|
||||||
|
&self,
|
||||||
|
_engine: &DownloadEngine,
|
||||||
|
files: &[PlanFile],
|
||||||
|
_sink: Option<ProgressSink>,
|
||||||
|
) -> InstallReport {
|
||||||
|
self.calls.borrow_mut().push("libraries");
|
||||||
|
let ok = self.libraries_ok;
|
||||||
|
let outcomes = files
|
||||||
|
.iter()
|
||||||
|
.map(|file| FileOutcome {
|
||||||
|
role: file.role,
|
||||||
|
name: file.name.clone(),
|
||||||
|
dest: file.dest.clone(),
|
||||||
|
status: if ok {
|
||||||
|
FileStatus::Skipped
|
||||||
|
} else {
|
||||||
|
FileStatus::Failed
|
||||||
|
},
|
||||||
|
bytes: 0,
|
||||||
|
source: None,
|
||||||
|
failure: (!ok).then(|| FileFailure {
|
||||||
|
kind: DownloadErrorKind::Network,
|
||||||
|
message: "连接超时".to_string(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
InstallReport {
|
||||||
|
files: outcomes,
|
||||||
|
version_json: None,
|
||||||
|
warnings: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_java(&self, _managed: &Path, _requirement: &JavaRequirement) -> Option<JavaRuntime> {
|
||||||
|
self.calls.borrow_mut().push("java");
|
||||||
|
self.java.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
&self,
|
||||||
|
plan: &ProcessorPlan,
|
||||||
|
java: &JavaRuntime,
|
||||||
|
sink: Option<StepSink>,
|
||||||
|
) -> Result<ProcessorRun, ProcessorError> {
|
||||||
|
self.calls.borrow_mut().push("run");
|
||||||
|
*self.steps.borrow_mut() = plan
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.map(|step| {
|
||||||
|
(
|
||||||
|
step.index,
|
||||||
|
step.label.clone(),
|
||||||
|
step.args.clone(),
|
||||||
|
step.classpath.len(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Some(error) = &self.run_error {
|
||||||
|
return Err(error.clone());
|
||||||
|
}
|
||||||
|
let outcomes: Vec<StepOutcome> = plan
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.map(|step| StepOutcome {
|
||||||
|
index: step.index,
|
||||||
|
label: step.label.clone(),
|
||||||
|
status: StepStatus::Executed,
|
||||||
|
outputs: step.outputs.len(),
|
||||||
|
elapsed_ms: 12,
|
||||||
|
stdout_tail: None,
|
||||||
|
stderr_tail: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Some(sink) = &sink {
|
||||||
|
for outcome in &outcomes {
|
||||||
|
sink(outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ProcessorRun {
|
||||||
|
java: java.clone(),
|
||||||
|
steps: outcomes,
|
||||||
|
warnings: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个主版本已知的假 Java。
|
||||||
|
pub(super) fn java() -> JavaRuntime {
|
||||||
|
JavaRuntime {
|
||||||
|
binary: PathBuf::from("java"),
|
||||||
|
origin: JavaOrigin::Managed,
|
||||||
|
version: Some("21.0.4".to_string()),
|
||||||
|
major: Some(21),
|
||||||
|
bits: 64,
|
||||||
|
vendor: Some("temurin".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个最小可用的 install profile:一个 client 侧 processor、两个字面量参数。
|
||||||
|
pub(super) fn profile() -> InstallProfile {
|
||||||
|
let mut profile = InstallProfile::default();
|
||||||
|
profile.processors.push(Processor {
|
||||||
|
sides: vec!["client".to_string()],
|
||||||
|
jar: Some("net.minecraftforge:installertools:1.3.0".to_string()),
|
||||||
|
classpath: vec!["net.minecraftforge:jarsplitter:1.1.4".to_string()],
|
||||||
|
args: vec!["--task".to_string(), "'MCP_DATA'".to_string()],
|
||||||
|
..Processor::default()
|
||||||
|
});
|
||||||
|
profile
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn input<'a>(profile: &'a InstallProfile, installer: &'a Path) -> StageInput<'a> {
|
||||||
|
StageInput {
|
||||||
|
kind: CoreLoaderKind::Forge,
|
||||||
|
mc_version: "1.20.1",
|
||||||
|
version_id: "1.20.1-forge-47.2.0",
|
||||||
|
profile: Some(profile),
|
||||||
|
installer: Some(installer),
|
||||||
|
installed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn installer() -> PathBuf {
|
||||||
|
PathBuf::from("cache/meta/forge-installer.jar")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn outcome(status: StepStatus) -> StepOutcome {
|
||||||
|
StepOutcome {
|
||||||
|
index: 3,
|
||||||
|
label: "net.minecraftforge:installertools:1.3.0".to_string(),
|
||||||
|
status,
|
||||||
|
outputs: 2,
|
||||||
|
elapsed_ms: 1234,
|
||||||
|
stdout_tail: None,
|
||||||
|
stderr_tail: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
//! host/install_processors.rs 的单测(拆文件守 400 行上限)。
|
||||||
|
//!
|
||||||
|
//! 夹具在 [`super::fixture`]:这里只断言「计划怎么组」「找不到 Java 怎么说」
|
||||||
|
//! 「哪一步挂了怎么报」「进度文案长什么样」。
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use unlucky_core::addon::loader::forge::InstallProfile;
|
||||||
|
use unlucky_core::addon::loader::processors::{ProcessorError, StepStatus};
|
||||||
|
use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind;
|
||||||
|
use unlucky_core::download::DownloadErrorKind;
|
||||||
|
use unlucky_core::game::install::{FileFailure, FileOutcome, FileRole, FileStatus, InstallReport};
|
||||||
|
use unlucky_core::game::java::JavaRequirement;
|
||||||
|
|
||||||
|
use super::fixture::{input, installer, outcome, profile, Fake, Harness};
|
||||||
|
use super::{diagnostic, java_missing, libraries_failed, stage, step_line, StageInput};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn step_line_names_the_step_and_marks_the_outcome() {
|
||||||
|
let executed = step_line(&outcome(StepStatus::Executed), 9);
|
||||||
|
assert_eq!(
|
||||||
|
executed,
|
||||||
|
"installer 处理器 3/9:net.minecraftforge:installertools:1.3.0(1234 ms)"
|
||||||
|
);
|
||||||
|
let skipped = step_line(&outcome(StepStatus::Skipped), 9);
|
||||||
|
assert!(skipped.contains("产物已就绪"), "{skipped}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diagnostic_keeps_the_stable_code_and_the_hint() {
|
||||||
|
let failed = ProcessorError::failed("net.minecraftforge:installertools:1.3.0", "1", "boom")
|
||||||
|
.with_hint("确认 Java 版本与磁盘权限");
|
||||||
|
let text = diagnostic(&failed);
|
||||||
|
assert!(text.contains("processor_failed"), "{text}");
|
||||||
|
assert!(text.contains("退出码 1"), "{text}");
|
||||||
|
assert!(text.contains("boom"), "stderr 尾部要留:{text}");
|
||||||
|
assert!(text.contains("建议:确认 Java 版本"), "{text}");
|
||||||
|
|
||||||
|
let plan = diagnostic(&ProcessorError::plan("install profile 缺 `jar`"));
|
||||||
|
assert!(plan.contains("processor_plan"), "{plan}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn java_missing_points_at_the_java_page() {
|
||||||
|
let managed = PathBuf::from("/tmp/.ULL/java");
|
||||||
|
let text = java_missing(&JavaRequirement::table(21, None), &managed);
|
||||||
|
assert!(text.contains("Java 21+"), "{text}");
|
||||||
|
assert!(text.contains("Java」页"), "{text}");
|
||||||
|
assert!(text.contains("JAVA_HOME"), "{text}");
|
||||||
|
assert!(text.contains(&managed.display().to_string()), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn libraries_failed_lists_at_most_three() {
|
||||||
|
let files: Vec<FileOutcome> = (0..4)
|
||||||
|
.map(|index| FileOutcome {
|
||||||
|
role: FileRole::Library,
|
||||||
|
name: format!("lib{index}.jar"),
|
||||||
|
dest: PathBuf::from(format!("lib{index}.jar")),
|
||||||
|
status: FileStatus::Failed,
|
||||||
|
bytes: 0,
|
||||||
|
source: None,
|
||||||
|
failure: Some(FileFailure {
|
||||||
|
kind: DownloadErrorKind::Network,
|
||||||
|
message: "连接超时".to_string(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let report = InstallReport {
|
||||||
|
files,
|
||||||
|
version_json: None,
|
||||||
|
warnings: Vec::new(),
|
||||||
|
};
|
||||||
|
let text = libraries_failed(&report);
|
||||||
|
assert!(text.contains("4 个失败,共 4 个"), "{text}");
|
||||||
|
assert!(text.contains("lib0.jar"), "{text}");
|
||||||
|
assert!(!text.contains("lib3.jar"), "最多列三条:{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_skips_when_the_version_json_is_already_there() {
|
||||||
|
let h = Harness::new();
|
||||||
|
let installer = installer();
|
||||||
|
let profile = profile();
|
||||||
|
let mut input = input(&profile, &installer);
|
||||||
|
input.installed = true;
|
||||||
|
let fake = Fake::new();
|
||||||
|
let result = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel);
|
||||||
|
assert!(matches!(result, Ok(None)));
|
||||||
|
assert!(fake.calls().is_empty(), "已装完就不该再动网络/Java");
|
||||||
|
assert!(h.log().contains("跳过 installer processors"), "{}", h.log());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_skips_an_empty_processor_list() {
|
||||||
|
let h = Harness::new();
|
||||||
|
let installer = installer();
|
||||||
|
let profile = InstallProfile::default();
|
||||||
|
let input = input(&profile, &installer);
|
||||||
|
let fake = Fake::new();
|
||||||
|
let result = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel);
|
||||||
|
assert!(matches!(result, Ok(None)), "没有 processor 就没这段");
|
||||||
|
assert!(fake.calls().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_reports_missing_installer_metadata() {
|
||||||
|
let h = Harness::new();
|
||||||
|
let input = StageInput {
|
||||||
|
kind: CoreLoaderKind::Forge,
|
||||||
|
mc_version: "1.20.1",
|
||||||
|
version_id: "1.20.1-forge-47.2.0",
|
||||||
|
profile: None,
|
||||||
|
installer: None,
|
||||||
|
installed: false,
|
||||||
|
};
|
||||||
|
let fake = Fake::new();
|
||||||
|
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
|
||||||
|
assert!(err.contains("install profile"), "{err}");
|
||||||
|
assert!(h.log().contains("失败:"), "{}", h.log());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_builds_the_command_line_from_the_install_profile() {
|
||||||
|
let h = Harness::new();
|
||||||
|
let installer = installer();
|
||||||
|
let profile = profile();
|
||||||
|
let input = input(&profile, &installer);
|
||||||
|
let fake = Fake::new();
|
||||||
|
let run = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel)
|
||||||
|
.expect("stage 成功")
|
||||||
|
.expect("应当真的跑了 processors");
|
||||||
|
assert_eq!(run.executed(), 1);
|
||||||
|
let steps = fake.steps();
|
||||||
|
assert_eq!(steps.len(), 1);
|
||||||
|
let (index, label, args, classpath) = &steps[0];
|
||||||
|
assert_eq!(*index, 1);
|
||||||
|
assert_eq!(label, "net.minecraftforge:installertools:1.3.0");
|
||||||
|
assert_eq!(args, &vec!["--task".to_string(), "MCP_DATA".to_string()]);
|
||||||
|
assert_eq!(*classpath, 1, "jar 自己不在 -cp 里(拼命令行时才补)");
|
||||||
|
assert_eq!(fake.calls(), vec!["libraries", "java", "run"]);
|
||||||
|
let messages = h.messages();
|
||||||
|
assert!(
|
||||||
|
messages.iter().any(|m| m.contains("installer 处理器 1/1")),
|
||||||
|
"面板要看到当前处理器:{messages:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
messages
|
||||||
|
.last()
|
||||||
|
.is_some_and(|m| m.contains("正在写入版本清单")),
|
||||||
|
"{messages:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
h.log().contains("processors 完成:执行 1 步"),
|
||||||
|
"{}",
|
||||||
|
h.log()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_fails_loudly_without_a_usable_java() {
|
||||||
|
let h = Harness::new();
|
||||||
|
let installer = installer();
|
||||||
|
let profile = profile();
|
||||||
|
let input = input(&profile, &installer);
|
||||||
|
let mut fake = Fake::new();
|
||||||
|
fake.java = None;
|
||||||
|
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
|
||||||
|
assert!(err.contains("Java"), "{err}");
|
||||||
|
assert!(err.contains("托管目录"), "{err}");
|
||||||
|
assert_eq!(
|
||||||
|
fake.calls(),
|
||||||
|
vec!["libraries", "java"],
|
||||||
|
"没 Java 就不该起进程"
|
||||||
|
);
|
||||||
|
assert!(h.log().contains("失败:"), "{}", h.log());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_fails_loudly_when_a_processor_exits_nonzero() {
|
||||||
|
let h = Harness::new();
|
||||||
|
let installer = installer();
|
||||||
|
let profile = profile();
|
||||||
|
let input = input(&profile, &installer);
|
||||||
|
let mut fake = Fake::new();
|
||||||
|
fake.run_error = Some(ProcessorError::failed(
|
||||||
|
"net.minecraftforge:installertools:1.3.0",
|
||||||
|
"1",
|
||||||
|
"java.lang.NoSuchMethodError",
|
||||||
|
));
|
||||||
|
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
|
||||||
|
assert!(err.contains("processor_failed"), "{err}");
|
||||||
|
assert!(err.contains("NoSuchMethodError"), "{err}");
|
||||||
|
let log = h.log();
|
||||||
|
assert!(log.contains("失败:"), "{log}");
|
||||||
|
assert!(log.contains("NoSuchMethodError"), "日志要能回溯:{log}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_reports_incomplete_library_downloads() {
|
||||||
|
let h = Harness::new();
|
||||||
|
let installer = installer();
|
||||||
|
let profile = profile();
|
||||||
|
let input = input(&profile, &installer);
|
||||||
|
let mut fake = Fake::new();
|
||||||
|
fake.libraries_ok = false;
|
||||||
|
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
|
||||||
|
assert!(err.contains("依赖库没下全"), "{err}");
|
||||||
|
assert!(err.contains("连接超时"), "{err}");
|
||||||
|
assert_eq!(fake.calls(), vec!["libraries"], "库没下全就别去选 Java");
|
||||||
|
let progress = h.meter.lock().expect("进度锁").progress.clone();
|
||||||
|
assert!(progress.files_total >= 2, "依赖库要计入文件总数");
|
||||||
|
}
|
||||||
@@ -1,24 +1,170 @@
|
|||||||
//! host/install.rs 的单测(拆文件守 400 行上限)。
|
//! host/install.rs 的单测(拆文件守 400 行上限)。
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use unlucky_core::game::install::{FileOutcome, FileRole, FileStatus};
|
use unlucky_core::game::install::{FileOutcome, FileRole, FileStatus};
|
||||||
|
|
||||||
use super::{run, supported, unsupported_reason};
|
use super::{loader_label, run, supported, unsupported_reason, Job};
|
||||||
use crate::dto::{CreateRequest, LoaderKind};
|
use crate::dto::{CreateRequest, LoaderKind};
|
||||||
use crate::host::env::HostEnv;
|
use crate::host::env::HostEnv;
|
||||||
|
use crate::host::install_journal::Journal;
|
||||||
|
use crate::host::job::{channel, JobSender};
|
||||||
use crate::host::meter::Meter;
|
use crate::host::meter::Meter;
|
||||||
|
|
||||||
|
static SEQ: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
/// 一个装在临时根里的任务上下文(只走「联网前就返回」的用例)。
|
||||||
|
struct Fixture {
|
||||||
|
dir: PathBuf,
|
||||||
|
env: HostEnv,
|
||||||
|
tx: JobSender,
|
||||||
|
meter: Arc<Mutex<Meter>>,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
journal: Journal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new(request: &CreateRequest) -> Self {
|
||||||
|
let seq = SEQ.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("ull-host-install-{}-{seq}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
let env = HostEnv::at_root(&dir);
|
||||||
|
let journal = Journal::open(&env);
|
||||||
|
Self {
|
||||||
|
dir,
|
||||||
|
env,
|
||||||
|
tx: channel().0,
|
||||||
|
meter: Arc::new(Mutex::new(Meter::new(request))),
|
||||||
|
cancel: Arc::new(AtomicBool::new(false)),
|
||||||
|
journal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn job(&self) -> Job<'_> {
|
||||||
|
Job {
|
||||||
|
env: &self.env,
|
||||||
|
tx: &self.tx,
|
||||||
|
meter: &self.meter,
|
||||||
|
cancel: &self.cancel,
|
||||||
|
journal: &self.journal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
std::fs::remove_dir_all(&self.dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(loader: LoaderKind, version: &str) -> CreateRequest {
|
||||||
|
CreateRequest {
|
||||||
|
version: version.to_string(),
|
||||||
|
loader,
|
||||||
|
loader_version: String::new(),
|
||||||
|
name: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个「刚下完」的文件结局(记账用)。
|
||||||
|
fn downloaded(bytes: u64) -> FileOutcome {
|
||||||
|
FileOutcome {
|
||||||
|
role: FileRole::Library,
|
||||||
|
name: "lib.jar".to_string(),
|
||||||
|
dest: PathBuf::from("lib.jar"),
|
||||||
|
status: FileStatus::Downloaded,
|
||||||
|
bytes,
|
||||||
|
source: None,
|
||||||
|
failure: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_is_pre_accounted_across_segments_so_the_bar_never_snaps_to_full() {
|
||||||
|
// M45 r1 P2-2 的回归:处理器依赖库下完那一刻,面板曾显示 36/36 满格 + 3.4MB/10MB,
|
||||||
|
// 之后加载器库的 29 个文件才被计入 → 进度条从 100% 回落到 55% 再涨回去。
|
||||||
|
let request = request(LoaderKind::Forge, "1.20.1");
|
||||||
|
let mut meter = Meter::new(&request);
|
||||||
|
|
||||||
|
// 原版段:30 个文件全下完(install_vanilla 对总量是赋值,不是累加)
|
||||||
|
meter.progress.files_total = 30;
|
||||||
|
meter.progress.bytes_total = 3_000_000;
|
||||||
|
for _ in 0..30 {
|
||||||
|
meter.note(&downloaded(100_000));
|
||||||
|
}
|
||||||
|
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
|
||||||
|
|
||||||
|
// install_loader 的顺序:加载器库与处理器依赖库都在动手前一次预记账
|
||||||
|
meter.expect(29, 10_000_000);
|
||||||
|
meter.expect(36, 4_000_000);
|
||||||
|
assert_eq!(meter.progress.files_total, 95, "两段之和一次到位");
|
||||||
|
assert_eq!(
|
||||||
|
meter.progress.bytes_total, 17_000_000,
|
||||||
|
"字节是相加不是取最大"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
meter.progress.ratio() < 0.7,
|
||||||
|
"预记账后不能还停在满格:{}",
|
||||||
|
meter.progress.ratio()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理器依赖库 36 个下完 → 仍没满格(加载器库还在后面)
|
||||||
|
for _ in 0..36 {
|
||||||
|
meter.note(&downloaded(110_000));
|
||||||
|
}
|
||||||
|
let ratio = meter.progress.ratio();
|
||||||
|
assert!(
|
||||||
|
(0.6..1.0).contains(&ratio),
|
||||||
|
"回落到中间态而不是满格:{ratio}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
meter.progress.bytes_done < meter.progress.bytes_total,
|
||||||
|
"字节总量要覆盖已下载量:{}/{}",
|
||||||
|
meter.progress.bytes_done,
|
||||||
|
meter.progress.bytes_total
|
||||||
|
);
|
||||||
|
|
||||||
|
// 加载器库也下完 → 这时候才该是满格
|
||||||
|
for _ in 0..29 {
|
||||||
|
meter.note(&downloaded(340_000));
|
||||||
|
}
|
||||||
|
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn loader_support_matrix_is_explicit() {
|
fn loader_support_matrix_is_explicit() {
|
||||||
assert!(supported(LoaderKind::Vanilla));
|
// M45 语义变更:Forge/NeoForge 从「GUI 显式拒装」扩进受支持集合(走 installer processors)。
|
||||||
assert!(supported(LoaderKind::Fabric));
|
for kind in [
|
||||||
assert!(supported(LoaderKind::Quilt));
|
LoaderKind::Vanilla,
|
||||||
assert!(!supported(LoaderKind::Forge));
|
LoaderKind::Fabric,
|
||||||
assert!(!supported(LoaderKind::NeoForge));
|
LoaderKind::Quilt,
|
||||||
assert!(unsupported_reason(LoaderKind::Forge).contains("ull install"));
|
LoaderKind::Forge,
|
||||||
|
LoaderKind::NeoForge,
|
||||||
|
] {
|
||||||
|
assert!(supported(kind), "{} 应当受支持", kind.label());
|
||||||
|
}
|
||||||
|
// 这两家 core 侧还没有安装管线,仍然显式拒装并说清去哪装。
|
||||||
|
for kind in [LoaderKind::OptiFine, LoaderKind::LiteLoader] {
|
||||||
|
assert!(!supported(kind), "{} 不该被当成可装", kind.label());
|
||||||
|
let reason = unsupported_reason(kind);
|
||||||
|
assert!(reason.contains(kind.label()), "{reason}");
|
||||||
|
assert!(reason.contains("还没有接入"), "{reason}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forge_install_is_no_longer_refused_at_the_door() {
|
||||||
|
// 旧文案是把用户赶去终端跑 `ull install`;M45 之后 GUI 与 CLI 同一条路径。
|
||||||
|
assert!(supported(LoaderKind::Forge));
|
||||||
|
assert!(supported(LoaderKind::NeoForge));
|
||||||
|
assert_eq!(loader_label(LoaderKind::Forge, "47.2.0"), "Forge 47.2.0");
|
||||||
|
assert_eq!(
|
||||||
|
loader_label(LoaderKind::NeoForge, "21.1.80"),
|
||||||
|
"NeoForge 21.1.80"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -57,30 +203,16 @@ fn meter_counts_files_and_skips_skipped_bytes() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_version_is_rejected_before_network() {
|
fn empty_version_is_rejected_before_network() {
|
||||||
let dir = std::env::temp_dir().join(format!("ull-host-install-{}", std::process::id()));
|
let request = request(LoaderKind::Vanilla, "");
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let fixture = Fixture::new(&request);
|
||||||
let env = HostEnv::at_root(&dir);
|
let err = run(&fixture.job(), &request).unwrap_err();
|
||||||
let (tx, _rx) = super::super::job::channel();
|
|
||||||
let request = CreateRequest {
|
|
||||||
version: String::new(),
|
|
||||||
loader: LoaderKind::Vanilla,
|
|
||||||
loader_version: String::new(),
|
|
||||||
name: String::new(),
|
|
||||||
};
|
|
||||||
let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request)));
|
|
||||||
let cancel = Arc::new(AtomicBool::new(false));
|
|
||||||
let err = run(&env, &tx, &request, &meter, &cancel).unwrap_err();
|
|
||||||
assert!(err.contains("没有选择版本"), "{err}");
|
assert!(err.contains("没有选择版本"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
let forge = CreateRequest {
|
#[test]
|
||||||
version: "1.20.1".to_string(),
|
fn unsupported_loader_is_rejected_before_network() {
|
||||||
loader: LoaderKind::Forge,
|
let request = request(LoaderKind::OptiFine, "1.20.1");
|
||||||
..request
|
let fixture = Fixture::new(&request);
|
||||||
};
|
let err = run(&fixture.job(), &request).unwrap_err();
|
||||||
let meter = Arc::new(std::sync::Mutex::new(Meter::new(&forge)));
|
assert!(err.contains("还没有接入"), "{err}");
|
||||||
let cancel = Arc::new(AtomicBool::new(false));
|
|
||||||
assert!(run(&env, &tx, &forge, &meter, &cancel)
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("installer processors"));
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,16 @@ impl Meter {
|
|||||||
self.previous = None;
|
self.previous = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 预记账一段待下载的文件(文件数 + 字节)。
|
||||||
|
///
|
||||||
|
/// 一次安装可能分几段(原版 / 处理器依赖库 / 加载器库):每段开工前都先记账,
|
||||||
|
/// 整条进度曲线才是单调的——某一段下完不会先到 100%,再因为下一段的文件被计入而回落。
|
||||||
|
pub(super) fn expect(&mut self, files: usize, bytes: u64) {
|
||||||
|
let files = u64::try_from(files).unwrap_or(u64::MAX);
|
||||||
|
self.progress.files_total = self.progress.files_total.saturating_add(files);
|
||||||
|
self.progress.bytes_total = self.progress.bytes_total.saturating_add(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
/// 记账一个文件的结局。
|
/// 记账一个文件的结局。
|
||||||
pub(super) fn note(&mut self, outcome: &FileOutcome) {
|
pub(super) fn note(&mut self, outcome: &FileOutcome) {
|
||||||
self.progress.files_done += 1;
|
self.progress.files_done += 1;
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ mod game;
|
|||||||
mod handle;
|
mod handle;
|
||||||
mod identity;
|
mod identity;
|
||||||
mod install;
|
mod install;
|
||||||
|
mod install_journal;
|
||||||
|
mod install_processors;
|
||||||
mod java;
|
mod java;
|
||||||
mod job;
|
mod job;
|
||||||
mod launch;
|
mod launch;
|
||||||
mod logs;
|
mod logs;
|
||||||
mod meter;
|
mod meter;
|
||||||
|
mod opener;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod versions;
|
mod versions;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
//! 用系统默认浏览器打开授权 URL(授权码流唯一需要宿主做的事)。
|
||||||
|
//!
|
||||||
|
//! core 只负责拼授权 URL 与等本地回调(`core::auth::microsoft::authcode`);
|
||||||
|
//! 「谁来打开浏览器」是宿主的决定:GUI 走这里,CLI/无浏览器场景仍用设备码流。
|
||||||
|
//!
|
||||||
|
//! 平台差异收在 [`launcher`] 一处,不往业务代码里散写 `cfg` 补丁(`AGENTS.md` §7)。
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// 拉起系统默认浏览器打开 `url`(只接受 http/https)。
|
||||||
|
///
|
||||||
|
/// 不等待浏览器退出:用户可能在浏览器里停留几分钟,宿主只关心「有没有拉起来」。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// URL 不是 http/https,或进程拉不起来时返回用户可读文案。
|
||||||
|
pub fn open_url(url: &str) -> Result<(), String> {
|
||||||
|
if !url.starts_with("https://") && !url.starts_with("http://") {
|
||||||
|
return Err(format!("不是可打开的网址:{url}"));
|
||||||
|
}
|
||||||
|
let (program, args) = launcher(url);
|
||||||
|
Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.spawn()
|
||||||
|
.map(|_child| ())
|
||||||
|
.map_err(|error| format!("拉起浏览器失败({program}):{error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn launcher(url: &str) -> (&'static str, Vec<String>) {
|
||||||
|
// `rundll32` 不用 shell,URL 里的 `&` 不会被当成命令分隔符(`cmd /C start` 会)。
|
||||||
|
(
|
||||||
|
"rundll32",
|
||||||
|
vec!["url.dll,FileProtocolHandler".to_string(), url.to_string()],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn launcher(url: &str) -> (&'static str, Vec<String>) {
|
||||||
|
("open", vec![url.to_string()])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
fn launcher(url: &str) -> (&'static str, Vec<String>) {
|
||||||
|
("xdg-open", vec![url.to_string()])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{launcher, open_url};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_http_urls() {
|
||||||
|
assert!(open_url("javascript:alert(1)").is_err());
|
||||||
|
assert!(open_url("").is_err());
|
||||||
|
// 合法 URL 只验命令形态,不在单测里真去拉浏览器。
|
||||||
|
let (program, args) = launcher("http://localhost:1234/auth?code=a&state=b");
|
||||||
|
assert!(!program.is_empty());
|
||||||
|
assert!(args
|
||||||
|
.iter()
|
||||||
|
.any(|arg| arg.contains("http://localhost:1234/auth")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[test]
|
||||||
|
fn windows_uses_the_file_protocol_handler() {
|
||||||
|
let (program, args) = launcher("https://example.invalid/");
|
||||||
|
assert_eq!(program, "rundll32");
|
||||||
|
assert_eq!(args[0], "url.dll,FileProtocolHandler");
|
||||||
|
assert_eq!(args[1], "https://example.invalid/");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ pub struct ShotOptions {
|
|||||||
pub wizard: bool,
|
pub wizard: bool,
|
||||||
/// 发起一次真实安装并停 `dwell_ms` 后取图(中间态)
|
/// 发起一次真实安装并停 `dwell_ms` 后取图(中间态)
|
||||||
pub install: Option<String>,
|
pub install: Option<String>,
|
||||||
|
/// 安装走哪个加载器(`--screenshot-loader forge`;缺省 = 原版)
|
||||||
|
pub loader: Option<String>,
|
||||||
/// 发起一次真实启动并停 `dwell_ms` 后取图(中间态)
|
/// 发起一次真实启动并停 `dwell_ms` 后取图(中间态)
|
||||||
pub launch: Option<String>,
|
pub launch: Option<String>,
|
||||||
/// 打开一个日志会话(日志页有数据态:日志行 + 诊断卡片)
|
/// 打开一个日志会话(日志页有数据态:日志行 + 诊断卡片)
|
||||||
@@ -58,6 +60,7 @@ pub fn shot_options() -> Option<ShotOptions> {
|
|||||||
},
|
},
|
||||||
wizard: flag("--screenshot-wizard"),
|
wizard: flag("--screenshot-wizard"),
|
||||||
install: value_of("--screenshot-install"),
|
install: value_of("--screenshot-install"),
|
||||||
|
loader: value_of("--screenshot-loader"),
|
||||||
launch: value_of("--screenshot-launch"),
|
launch: value_of("--screenshot-launch"),
|
||||||
session: value_of("--screenshot-session"),
|
session: value_of("--screenshot-session"),
|
||||||
dwell_ms: value_of("--screenshot-dwell")
|
dwell_ms: value_of("--screenshot-dwell")
|
||||||
@@ -92,7 +95,7 @@ pub fn capture(app: &AppUi, options: &ShotOptions) -> Result<(), slint::Platform
|
|||||||
}
|
}
|
||||||
if let Some(version) = &options.install {
|
if let Some(version) = &options.install {
|
||||||
app.dispatch_and_run(
|
app.dispatch_and_run(
|
||||||
AppCommand::InstallVersion(version.clone()),
|
install_command(version, options.loader.as_deref()),
|
||||||
options.dwell_ms,
|
options.dwell_ms,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -115,6 +118,20 @@ pub fn capture(app: &AppUi, options: &ShotOptions) -> Result<(), slint::Platform
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 截图通道的安装指令:缺省原版,给了 `--screenshot-loader` 就走向导同一条路
|
||||||
|
/// (`CreateInstance`),因此 Forge/NeoForge 的中间态也能取图。
|
||||||
|
fn install_command(version: &str, loader: Option<&str>) -> AppCommand {
|
||||||
|
match loader.map(crate::dto::LoaderKind::parse) {
|
||||||
|
Some(Some(kind)) => AppCommand::CreateInstance(crate::dto::CreateRequest {
|
||||||
|
version: version.to_string(),
|
||||||
|
loader: kind,
|
||||||
|
loader_version: String::new(),
|
||||||
|
name: version.to_string(),
|
||||||
|
}),
|
||||||
|
_ => AppCommand::InstallVersion(version.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `--flag` 是否存在。
|
/// `--flag` 是否存在。
|
||||||
pub fn flag(name: &str) -> bool {
|
pub fn flag(name: &str) -> bool {
|
||||||
std::env::args().skip(1).any(|arg| arg == name)
|
std::env::args().skip(1).any(|arg| arg == name)
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ pub struct AppState {
|
|||||||
pub download_query: String,
|
pub download_query: String,
|
||||||
/// 账户列表(三轨统一视图;凭据不在其中)
|
/// 账户列表(三轨统一视图;凭据不在其中)
|
||||||
pub accounts: Vec<AccountView>,
|
pub accounts: Vec<AccountView>,
|
||||||
/// 微软设备码登录状态
|
/// 微软登录状态
|
||||||
pub device_login: DeviceLoginView,
|
pub device_login: DeviceLoginView,
|
||||||
/// Java 页快照(发行版 / 回退链 / 运行时)
|
/// Java 页快照(发行版 / 回退链 / 运行时)
|
||||||
pub java: JavaPageView,
|
pub java: JavaPageView,
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ export struct UiAccount {
|
|||||||
active: bool,
|
active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设备码登录面板(用户码/链接不是凭据,可直接展示)。
|
/// 微软登录面板(用户码 / 授权链接都不是凭据,可直接展示)。
|
||||||
export struct UiDeviceCode {
|
export struct UiDeviceCode {
|
||||||
visible: bool,
|
visible: bool,
|
||||||
busy: bool,
|
busy: bool,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export global PageState {
|
|||||||
callback account-remove(string);
|
callback account-remove(string);
|
||||||
callback account-switch(string);
|
callback account-switch(string);
|
||||||
callback msa-begin();
|
callback msa-begin();
|
||||||
|
callback msa-browser-begin();
|
||||||
callback msa-cancel();
|
callback msa-cancel();
|
||||||
|
|
||||||
// ── Java ──────────────────────────────────────────────────
|
// ── Java ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// 设置 → 账户(D9 / G6):三轨账户列表 + 离线添加 + 微软设备码登录 + 删除/切换。
|
// 设置 → 账户(D9 / G6):三轨账户列表 + 离线添加 + 微软登录(授权码浏览器 / 设备码)+ 删除/切换。
|
||||||
//
|
//
|
||||||
// 凭据红线:这里能看到的只有「名字 / 轨道 / 公开 UUID / 登录名 / 创建时间」——
|
// 凭据红线:这里能看到的只有「名字 / 轨道 / 公开 UUID / 登录名 / 创建时间」——
|
||||||
// token 与密码永远不进 UI(Rust 侧的 AccountView 不含这些字段)。
|
// token 与密码永远不进 UI(Rust 侧的 AccountView 不含这些字段)。
|
||||||
@@ -72,7 +72,7 @@ export component AccountSection inherits Rectangle {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
// 设备码登录面板(Waiting 时显示用户码 + 链接;失败显示原因)
|
// 微软登录面板(设备码显示用户码;授权码显示可复制的授权链接;失败显示原因)
|
||||||
if PageState.device.visible : Rectangle {
|
if PageState.device.visible : Rectangle {
|
||||||
height: PageState.device.busy ? 96px : 84px;
|
height: PageState.device.busy ? 96px : 84px;
|
||||||
background: PageState.device.phase_label == "失败" ? Theme.state-danger-soft : Theme.accent-soft;
|
background: PageState.device.phase_label == "失败" ? Theme.state-danger-soft : Theme.accent-soft;
|
||||||
@@ -148,15 +148,22 @@ export component AccountSection inherits Rectangle {
|
|||||||
placeholder: "玩家名(离线 UUID 自动生成)";
|
placeholder: "玩家名(离线 UUID 自动生成)";
|
||||||
}
|
}
|
||||||
MetroButton {
|
MetroButton {
|
||||||
kind: ButtonKind.accent;
|
kind: ButtonKind.standard;
|
||||||
text: "添加离线";
|
text: "添加离线";
|
||||||
button-height: 28px;
|
button-height: 28px;
|
||||||
enabled: PageState.offline-name != "";
|
enabled: PageState.offline-name != "";
|
||||||
clicked => { PageState.account-add-offline(PageState.offline-name); }
|
clicked => { PageState.account-add-offline(PageState.offline-name); }
|
||||||
}
|
}
|
||||||
MetroButton {
|
MetroButton {
|
||||||
kind: ButtonKind.standard;
|
kind: ButtonKind.accent;
|
||||||
text: "微软登录(设备码)";
|
text: "微软登录";
|
||||||
|
button-height: 28px;
|
||||||
|
enabled: !PageState.device.busy;
|
||||||
|
clicked => { PageState.msa-browser-begin(); }
|
||||||
|
}
|
||||||
|
MetroButton {
|
||||||
|
kind: ButtonKind.subtle;
|
||||||
|
text: "设备码登录";
|
||||||
button-height: 28px;
|
button-height: 28px;
|
||||||
enabled: !PageState.device.busy;
|
enabled: !PageState.device.busy;
|
||||||
clicked => { PageState.msa-begin(); }
|
clicked => { PageState.msa-begin(); }
|
||||||
|
|||||||
@@ -196,8 +196,8 @@ export component NewInstanceWizard inherits Rectangle {
|
|||||||
LoaderChoice { index: 0; label: "原版(Vanilla)"; hint: "不装加载器,最快最稳"; picked => { PageState.wizard-select-loader(0); } }
|
LoaderChoice { index: 0; label: "原版(Vanilla)"; hint: "不装加载器,最快最稳"; picked => { PageState.wizard-select-loader(0); } }
|
||||||
LoaderChoice { index: 1; label: "Fabric"; hint: "轻量模组加载器(GUI 可直接装)"; picked => { PageState.wizard-select-loader(1); } }
|
LoaderChoice { index: 1; label: "Fabric"; hint: "轻量模组加载器(GUI 可直接装)"; picked => { PageState.wizard-select-loader(1); } }
|
||||||
LoaderChoice { index: 2; label: "Quilt"; hint: "Fabric 的分支(GUI 可直接装)"; picked => { PageState.wizard-select-loader(2); } }
|
LoaderChoice { index: 2; label: "Quilt"; hint: "Fabric 的分支(GUI 可直接装)"; picked => { PageState.wizard-select-loader(2); } }
|
||||||
LoaderChoice { index: 3; label: "Forge"; hint: "需要 installer processors:本轮请用 ull install"; picked => { PageState.wizard-select-loader(3); } }
|
LoaderChoice { index: 3; label: "Forge"; hint: "需要 installer processors(首装会外调 java,约 1–3 分钟)"; picked => { PageState.wizard-select-loader(3); } }
|
||||||
LoaderChoice { index: 4; label: "NeoForge"; hint: "需要 installer processors:本轮请用 ull install"; picked => { PageState.wizard-select-loader(4); } }
|
LoaderChoice { index: 4; label: "NeoForge"; hint: "需要 installer processors(首装会外调 java,约 1–3 分钟)"; picked => { PageState.wizard-select-loader(4); } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ③ 名字
|
// ③ 名字
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ impl std::fmt::Display for AccountId {
|
|||||||
pub enum AccountKind {
|
pub enum AccountKind {
|
||||||
/// 离线(本地生成的 UUID,不联网)。
|
/// 离线(本地生成的 UUID,不联网)。
|
||||||
Offline,
|
Offline,
|
||||||
/// 微软(device-code)。
|
/// 微软(device-code / 授权码浏览器)。
|
||||||
Microsoft,
|
Microsoft,
|
||||||
/// authlib-injector 第三方认证服务器(标准 Yggdrasil 协议)。
|
/// authlib-injector 第三方认证服务器(标准 Yggdrasil 协议)。
|
||||||
AuthlibInjector,
|
AuthlibInjector,
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
//! 授权码流(`research/08` §3 方案 A):授权 URL 构造 + code 换 token + 整链编排。
|
||||||
|
//!
|
||||||
|
//! 与设备码流的分工:**只多一跳**——拿 MS access_token 的方式不同,往后的
|
||||||
|
//! XBL → XSTS → login_with_xbox → entitlements → profile 与刷新链完全复用
|
||||||
|
//! ([`super::chain::complete_chain`]),所以两条流的凭据存储与刷新语义天然一致。
|
||||||
|
//!
|
||||||
|
//! # 三个硬性细节(都是「抄错就失败」的那一类)
|
||||||
|
//!
|
||||||
|
//! 1. **租户 `consumers`**:授权页与 token 端点都走 `login.microsoftonline.com/consumers/…`,
|
||||||
|
//! 换成 `common`/AAD 租户 id 时 `XboxLive.signin` 直接被拒(`research/08` §2 约束 1)。
|
||||||
|
//! 2. **重定向 URI 严格一致**:`http://localhost:{port}/auth` —— 授权 URL 里的、
|
||||||
|
//! token 请求体里的、本地服务器实际监听的必须是**同一个字符串**(含端口与路径),
|
||||||
|
//! 所以本模块只从 [`RedirectUri`] 取这一个来源。
|
||||||
|
//! 3. **`prompt=select_account`**:不带这个参数,用户会被静默塞进上一次的账号(坑 6)。
|
||||||
|
//!
|
||||||
|
//! scope 与设备码流共用 [`super::flow::SCOPE`](`XboxLive.signin offline_access`)。
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use reqwest::Client;
|
||||||
|
|
||||||
|
use crate::auth::error::AuthResult;
|
||||||
|
use crate::auth::http::post_form;
|
||||||
|
use crate::auth::secret::Secret;
|
||||||
|
|
||||||
|
use super::callback::{CallbackServer, REDIRECT_PATH};
|
||||||
|
use super::chain::complete_chain;
|
||||||
|
use super::endpoints::MicrosoftEndpoints;
|
||||||
|
use super::flow::{oauth_error, tokens_from, OAuthTokens, SCOPE};
|
||||||
|
use super::now_ms;
|
||||||
|
use super::session::MicrosoftSession;
|
||||||
|
|
||||||
|
/// 授权码 grant 类型。
|
||||||
|
pub const AUTHORIZATION_CODE_GRANT: &str = "authorization_code";
|
||||||
|
/// 默认等回调的时间预算(用户要开浏览器、登录、点同意)。
|
||||||
|
pub const DEFAULT_CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
|
/// 默认的端口绑定尝试次数。
|
||||||
|
pub const DEFAULT_BIND_ATTEMPTS: u8 = 3;
|
||||||
|
|
||||||
|
/// 回填到授权 URL 与 token 请求里的重定向 URI(**唯一来源**)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RedirectUri {
|
||||||
|
port: u16,
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RedirectUri {
|
||||||
|
/// 按实际绑定的端口构造 `http://localhost:{port}/auth`。
|
||||||
|
///
|
||||||
|
/// 用 `localhost` 而不是 `127.0.0.1`:微软对两者处理不一致(`research/08` 坑 5)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(port: u16) -> Self {
|
||||||
|
Self {
|
||||||
|
port,
|
||||||
|
url: format!("http://localhost:{port}{REDIRECT_PATH}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 端口。
|
||||||
|
#[must_use]
|
||||||
|
pub fn port(&self) -> u16 {
|
||||||
|
self.port
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URI 字面量(授权 URL 与 token 请求共用,保证逐字节一致)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for RedirectUri {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(&self.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 授权码流的运行参数(生产用 [`AuthCodePolicy::default`],测试压短超时/固定端口)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct AuthCodePolicy {
|
||||||
|
/// 等浏览器回调的上限。
|
||||||
|
pub callback_timeout: Duration,
|
||||||
|
/// 端口绑定尝试次数(`>= 1`)。
|
||||||
|
pub bind_attempts: u8,
|
||||||
|
/// 固定端口(`None` = 由内核分配,生产路径)。
|
||||||
|
pub port: Option<u16>,
|
||||||
|
/// CSRF 用的 `state`(`None` = 每次随机生成)。
|
||||||
|
pub state: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AuthCodePolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
callback_timeout: DEFAULT_CALLBACK_TIMEOUT,
|
||||||
|
bind_attempts: DEFAULT_BIND_ATTEMPTS,
|
||||||
|
port: None,
|
||||||
|
state: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 要宿主做的事:打开浏览器(core 不碰 UI:谁来开由 CLI/GUI 决定)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct AuthCodePrompt {
|
||||||
|
/// 授权页 URL(宿主交给系统默认浏览器;也可展示给用户手动打开)。
|
||||||
|
pub authorization_url: String,
|
||||||
|
/// 本地回调地址(展示/排障用;与授权 URL 里的 `redirect_uri` 同源)。
|
||||||
|
pub redirect_uri: String,
|
||||||
|
/// 最多等多久。
|
||||||
|
pub timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拼授权 URL(`response_type=code` + `prompt=select_account` + 严格编码的重定向 URI)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn authorization_url(
|
||||||
|
endpoints: &MicrosoftEndpoints,
|
||||||
|
client_id: &str,
|
||||||
|
redirect_uri: &RedirectUri,
|
||||||
|
state: &str,
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
"{}?client_id={}&response_type=code&redirect_uri={}&scope={}&prompt=select_account&state={}",
|
||||||
|
endpoints.authorize,
|
||||||
|
encode(client_id),
|
||||||
|
encode(redirect_uri.as_str()),
|
||||||
|
encode(SCOPE),
|
||||||
|
encode(state),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 授权码换 token(`grant_type=authorization_code`)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// OAuth 拒绝([`crate::auth::AuthError::Denied`] /
|
||||||
|
/// [`crate::auth::AuthError::CredentialExpired`])、网络失败、响应缺 token。
|
||||||
|
pub(crate) async fn exchange_code(
|
||||||
|
client: &Client,
|
||||||
|
endpoints: &MicrosoftEndpoints,
|
||||||
|
client_id: &str,
|
||||||
|
code: &Secret,
|
||||||
|
redirect_uri: &RedirectUri,
|
||||||
|
) -> AuthResult<OAuthTokens> {
|
||||||
|
let reply = post_form(
|
||||||
|
client,
|
||||||
|
&endpoints.token,
|
||||||
|
vec![
|
||||||
|
("client_id", client_id.to_string()),
|
||||||
|
("grant_type", AUTHORIZATION_CODE_GRANT.to_string()),
|
||||||
|
("code", code.expose().to_string()),
|
||||||
|
("redirect_uri", redirect_uri.as_str().to_string()),
|
||||||
|
("scope", SCOPE.to_string()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if reply.text("error").is_some() || !reply.is_success() {
|
||||||
|
return Err(oauth_error(&reply, &endpoints.token));
|
||||||
|
}
|
||||||
|
tokens_from(&reply, &endpoints.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 授权码登录主体:绑端口 → 出授权 URL(交给 `present`)→ 等回调 → 换 token → 整链。
|
||||||
|
///
|
||||||
|
/// `present` 在**端口绑定之后、等待之前**被调用:宿主此时才知道端口,也只有这时
|
||||||
|
/// 打开浏览器才不会让用户对着还没起来的服务端登录。
|
||||||
|
pub(crate) async fn login<F>(
|
||||||
|
client: &Client,
|
||||||
|
endpoints: &MicrosoftEndpoints,
|
||||||
|
client_id: &str,
|
||||||
|
policy: AuthCodePolicy,
|
||||||
|
present: F,
|
||||||
|
) -> AuthResult<MicrosoftSession>
|
||||||
|
where
|
||||||
|
F: FnOnce(&AuthCodePrompt),
|
||||||
|
{
|
||||||
|
let state = policy.state.clone().unwrap_or_else(nonce);
|
||||||
|
let server = CallbackServer::bind(policy.port, policy.bind_attempts, state.clone()).await?;
|
||||||
|
let redirect_uri = RedirectUri::new(server.port());
|
||||||
|
present(&AuthCodePrompt {
|
||||||
|
authorization_url: authorization_url(endpoints, client_id, &redirect_uri, &state),
|
||||||
|
redirect_uri: redirect_uri.as_str().to_string(),
|
||||||
|
timeout: policy.callback_timeout,
|
||||||
|
});
|
||||||
|
let code = server.wait_for_code(policy.callback_timeout).await?;
|
||||||
|
let tokens = exchange_code(
|
||||||
|
client,
|
||||||
|
endpoints,
|
||||||
|
client_id,
|
||||||
|
&Secret::new(code),
|
||||||
|
&redirect_uri,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
complete_chain(client, endpoints, tokens, now_ms()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 每次登录一个 `state`(不用于加密,只用来把迟到的旧回调挡掉)。
|
||||||
|
///
|
||||||
|
/// 128 位(两个独立随机种子的 SipHash 输出拼接):RFC 6749 §10.10 建议 `state` 至少
|
||||||
|
/// 128 位熵,挡住「猜到 state 就能伪造回调」这类攻击面。
|
||||||
|
fn nonce() -> String {
|
||||||
|
use std::collections::hash_map::RandomState;
|
||||||
|
use std::hash::{BuildHasher, Hasher};
|
||||||
|
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|since| since.as_nanos())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let mut halves = [String::new(), String::new()];
|
||||||
|
for half in &mut halves {
|
||||||
|
// 每次 `RandomState::new()` 都是独立随机种子 → 两段各 64 位、彼此不可推。
|
||||||
|
let mut hasher = RandomState::new().build_hasher();
|
||||||
|
hasher.write_u128(nanos);
|
||||||
|
hasher.write_u32(std::process::id());
|
||||||
|
*half = format!("{:016x}", hasher.finish());
|
||||||
|
}
|
||||||
|
format!("{}{}", halves[0], halves[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 百分号编码(RFC 3986 unreserved 之外一律转义;空格用 `%20` 而不是 `+`)。
|
||||||
|
fn encode(raw: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(raw.len());
|
||||||
|
for byte in raw.bytes() {
|
||||||
|
match byte {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||||
|
out.push(char::from(byte));
|
||||||
|
}
|
||||||
|
_ => out.push_str(&format!("%{byte:02X}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{authorization_url, encode, RedirectUri, AUTHORIZATION_CODE_GRANT};
|
||||||
|
use crate::auth::microsoft::MicrosoftEndpoints;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redirect_uri_is_localhost_with_exact_port_and_path() {
|
||||||
|
let uri = RedirectUri::new(51_234);
|
||||||
|
assert_eq!(uri.as_str(), "http://localhost:51234/auth");
|
||||||
|
assert_eq!(uri.port(), 51_234);
|
||||||
|
assert_eq!(uri.to_string(), uri.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authorization_url_matches_research_08_vector() {
|
||||||
|
let endpoints = MicrosoftEndpoints::consumers();
|
||||||
|
let uri = RedirectUri::new(8_080);
|
||||||
|
let url = authorization_url(&endpoints, "0000-client", &uri, "state-123");
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize\
|
||||||
|
?client_id=0000-client&response_type=code\
|
||||||
|
&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fauth\
|
||||||
|
&scope=XboxLive.signin%20offline_access&prompt=select_account&state=state-123"
|
||||||
|
);
|
||||||
|
// 三个硬性细节各断言一遍,改坏了立刻知道是哪一条。
|
||||||
|
assert!(url.contains("/consumers/oauth2/v2.0/authorize"));
|
||||||
|
assert!(url.contains("prompt=select_account"));
|
||||||
|
assert!(url.contains("scope=XboxLive.signin%20offline_access"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_escapes_everything_but_unreserved() {
|
||||||
|
assert_eq!(encode("abc-._~09"), "abc-._~09");
|
||||||
|
assert_eq!(encode("a b/c:"), "a%20b%2Fc%3A");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grant_type_is_the_standard_one() {
|
||||||
|
assert_eq!(AUTHORIZATION_CODE_GRANT, "authorization_code");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_state_has_128_bits_of_entropy() {
|
||||||
|
let first = super::nonce();
|
||||||
|
let second = super::nonce();
|
||||||
|
assert_eq!(first.len(), 32, "128 位 = 32 个 hex 字符:{first}");
|
||||||
|
assert!(first.chars().all(|c| c.is_ascii_hexdigit()), "{first}");
|
||||||
|
assert_ne!(first, second, "每次登录的 state 必须不同");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
//! 授权码流的本地回调服务器(`research/08` §3 方案 A:HMCL 主流做法)。
|
||||||
|
//!
|
||||||
|
//! 只干一件事:等浏览器把 `?code=` 送到 `http://localhost:{port}/auth`,把 code 交出去。
|
||||||
|
//! 协议面故意压到最小(只认一个 GET 路径、只回一个静态页),这样它能被单测直接驱动。
|
||||||
|
//!
|
||||||
|
//! # 四条容易踩的细节
|
||||||
|
//!
|
||||||
|
//! 1. **重定向 URI 用 `localhost`,监听绑 `127.0.0.1`**:微软对 `localhost` 与 `127.0.0.1`
|
||||||
|
//! 处理不一致(坑 5),URI 只能写前者;浏览器把 `localhost` 解析到 `::1` 时会被拒一次
|
||||||
|
//! 再回落 IPv4,Chrome/Edge 都走这条回退,所以只绑 v4 就够。
|
||||||
|
//! 2. **端口由内核分配**(绑定 `:0`),端口被占时按 `attempts` 重试;授权 URL 里的
|
||||||
|
//! `redirect_uri` 必须用**最终绑到的端口**回填,换 token 时再原样带上(URI 严格一致)。
|
||||||
|
//! 3. **非 `/auth` 的请求(`/favicon.ico`、浏览器预连)回 404 后继续等**:让一个 favicon
|
||||||
|
//! 把整次登录判成失败是最容易犯的错。
|
||||||
|
//! 4. **`state` 不匹配的请求一律回 400 后继续等**——包括带 `error=` 的那些:旧标签页的
|
||||||
|
//! 迟到回调不该打断新的登录,更要紧的是 RFC 6749 §4.1.2.1 / RFC 9207 要求**错误响应同样
|
||||||
|
//! 要校验 `state`**;否则任何人 `GET /auth?error=access_denied` 就能静默中止别人的登录。
|
||||||
|
//! 真正的超时由调用方的时间预算兜底。
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
|
||||||
|
use crate::auth::error::{AuthError, AuthResult};
|
||||||
|
|
||||||
|
/// 回调路径(重定向 URI 的路径段)。
|
||||||
|
pub const REDIRECT_PATH: &str = "/auth";
|
||||||
|
/// 一次回调请求最多读这么多字节(超了就丢弃连接,防被灌内存)。
|
||||||
|
pub const MAX_REQUEST_HEAD: usize = 8 * 1024;
|
||||||
|
/// 端口被占用时的重试间隔。
|
||||||
|
const RETRY_DELAY: Duration = Duration::from_millis(20);
|
||||||
|
/// 单条连接读完请求头的上限(浏览器/预连不把请求头发完时不能把整个登录卡死)。
|
||||||
|
const REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
const SUCCESS_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
|
||||||
|
<title>登录完成</title></head><body style=\"font-family:system-ui;padding:40px\">\
|
||||||
|
<h1>登录完成</h1><p>可以关闭此窗口,回到启动器继续。</p></body></html>";
|
||||||
|
const DENIED_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
|
||||||
|
<title>登录未完成</title></head><body style=\"font-family:system-ui;padding:40px\">\
|
||||||
|
<h1>登录未完成</h1><p>授权没有完成,可以回到启动器重试。</p></body></html>";
|
||||||
|
const BAD_REQUEST_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
|
||||||
|
<title>回调无效</title></head><body style=\"font-family:system-ui;padding:40px\">\
|
||||||
|
<h1>回调无效</h1><p>这个链接不属于本次登录。</p></body></html>";
|
||||||
|
const NOT_FOUND_PAGE: &str = "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">\
|
||||||
|
<title>Not Found</title></head><body><h1>Not Found</h1></body></html>";
|
||||||
|
|
||||||
|
/// 本地回调服务器(一个实例只服务一次登录)。
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CallbackServer {
|
||||||
|
listener: TcpListener,
|
||||||
|
port: u16,
|
||||||
|
state: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CallbackServer {
|
||||||
|
/// 绑定回调端口。
|
||||||
|
///
|
||||||
|
/// `port` 为 `None` 时由内核分配(生产路径);给定端口时按 `attempts` 重试
|
||||||
|
/// (CLI/测试里固定端口用,端口被占会退避再试)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// 全部尝试都绑定失败时返回 [`AuthError::Network`](文案带端口与重试次数)。
|
||||||
|
pub async fn bind(
|
||||||
|
port: Option<u16>,
|
||||||
|
attempts: u8,
|
||||||
|
state: impl Into<String>,
|
||||||
|
) -> AuthResult<Self> {
|
||||||
|
let wanted = port.unwrap_or(0);
|
||||||
|
let attempts = attempts.max(1);
|
||||||
|
let mut last = String::new();
|
||||||
|
for attempt in 0..attempts {
|
||||||
|
match TcpListener::bind(("127.0.0.1", wanted)).await {
|
||||||
|
Ok(listener) => {
|
||||||
|
let port = listener
|
||||||
|
.local_addr()
|
||||||
|
.map_err(|error| AuthError::network("localhost", error.to_string()))?
|
||||||
|
.port();
|
||||||
|
return Ok(Self {
|
||||||
|
listener,
|
||||||
|
port,
|
||||||
|
state: state.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
last = error.to_string();
|
||||||
|
if attempt + 1 < attempts {
|
||||||
|
tokio::time::sleep(RETRY_DELAY).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let what = if wanted == 0 {
|
||||||
|
"本地回调端口分配失败".to_string()
|
||||||
|
} else {
|
||||||
|
format!("本地回调端口 {wanted} 绑定失败(重试 {attempts} 次)")
|
||||||
|
};
|
||||||
|
Err(AuthError::network("localhost", format!("{what}:{last}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 实际端口(回填 `redirect_uri` 用)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn port(&self) -> u16 {
|
||||||
|
self.port
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本次登录的 `state`(授权 URL 与回调校验共用)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn state(&self) -> &str {
|
||||||
|
&self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 等浏览器把 code 送到 `/auth`,最多等 `timeout`。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// 超时([`AuthError::Timeout`])、用户在授权页点了取消([`AuthError::Canceled`])、
|
||||||
|
/// 授权页回了别的 `error`([`AuthError::Denied`])、socket 出错([`AuthError::Network`])。
|
||||||
|
pub async fn wait_for_code(self, timeout: Duration) -> AuthResult<String> {
|
||||||
|
let deadline = tokio::time::Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||||
|
if remaining.is_zero() {
|
||||||
|
return Err(wait_timeout(timeout));
|
||||||
|
}
|
||||||
|
let accepted = tokio::time::timeout(remaining, self.listener.accept()).await;
|
||||||
|
let (mut stream, _) = match accepted {
|
||||||
|
Ok(Ok(pair)) => pair,
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
return Err(AuthError::network("localhost", error.to_string()));
|
||||||
|
}
|
||||||
|
Err(_) => return Err(wait_timeout(timeout)),
|
||||||
|
};
|
||||||
|
// 一条连接最多读这么久请求头:慢客户端/半开连接不能把整个登录拖过时间预算。
|
||||||
|
// 预算按 **accept 之后** 的剩余时间重算(用 accept 前那份会让总时长超发最多 5s)。
|
||||||
|
let budget = REQUEST_READ_TIMEOUT
|
||||||
|
.min(deadline.saturating_duration_since(tokio::time::Instant::now()));
|
||||||
|
if budget.is_zero() {
|
||||||
|
return Err(wait_timeout(timeout));
|
||||||
|
}
|
||||||
|
match tokio::time::timeout(budget, self.handle(&mut stream)).await {
|
||||||
|
Ok(result) => {
|
||||||
|
if let Some(code) = result? {
|
||||||
|
return Ok(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理一条连接:`Some(code)` = 登录完成,`None` = 与本次登录无关,继续等。
|
||||||
|
async fn handle(&self, stream: &mut TcpStream) -> AuthResult<Option<String>> {
|
||||||
|
let Some(target) = read_request_target(stream).await else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let (path, query) = match target.split_once('?') {
|
||||||
|
Some((path, query)) => (path, query),
|
||||||
|
None => (target.as_str(), ""),
|
||||||
|
};
|
||||||
|
if path != REDIRECT_PATH {
|
||||||
|
respond(stream, 404, NOT_FOUND_PAGE).await;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let params = Query::parse(query);
|
||||||
|
// state 必须先校验,且**先于** error:错误响应同样要认 state(RFC 6749 §4.1.2.1 /
|
||||||
|
// RFC 9207)。否则任意一次 `GET /auth?error=access_denied`(state 随便给或不给)就能
|
||||||
|
// 把这台机器上正在进行的登录静默中止 —— 不需要知道随机 state、不需要 code。
|
||||||
|
if params.get("state") != Some(self.state.as_str()) {
|
||||||
|
respond(stream, 400, BAD_REQUEST_PAGE).await;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if let Some(error) = params.get("error") {
|
||||||
|
respond(stream, 200, DENIED_PAGE).await;
|
||||||
|
return Err(oauth_error(error, params.get("error_description")));
|
||||||
|
}
|
||||||
|
let Some(code) = params.get("code").filter(|code| !code.is_empty()) else {
|
||||||
|
respond(stream, 400, BAD_REQUEST_PAGE).await;
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
respond(stream, 200, SUCCESS_PAGE).await;
|
||||||
|
Ok(Some(code.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_timeout(timeout: Duration) -> AuthError {
|
||||||
|
AuthError::Timeout {
|
||||||
|
what: format!("等待浏览器回调(上限 {} 秒)", timeout.as_secs()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 授权页回的 `error` → 错误分流(`access_denied` 归取消,其余归拒绝)。
|
||||||
|
fn oauth_error(code: &str, description: Option<&str>) -> AuthError {
|
||||||
|
if matches!(code, "access_denied" | "authorization_declined") {
|
||||||
|
return AuthError::Canceled;
|
||||||
|
}
|
||||||
|
AuthError::Denied {
|
||||||
|
code: code.to_string(),
|
||||||
|
description: description.unwrap_or("授权页没有给出原因").to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读请求行里的 target(`/auth?code=...`)。非 GET/HEAD、超长头、连接中断都给 `None`。
|
||||||
|
async fn read_request_target(stream: &mut TcpStream) -> Option<String> {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let mut chunk = [0_u8; 1024];
|
||||||
|
loop {
|
||||||
|
if let Some(end) = find_headers_end(&buffer) {
|
||||||
|
let head = String::from_utf8_lossy(&buffer[..end]).into_owned();
|
||||||
|
let mut parts = head.lines().next()?.split(' ');
|
||||||
|
let method = parts.next()?;
|
||||||
|
if !method.eq_ignore_ascii_case("GET") && !method.eq_ignore_ascii_case("HEAD") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return parts.next().map(str::to_string);
|
||||||
|
}
|
||||||
|
if buffer.len() > MAX_REQUEST_HEAD {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match stream.read(&mut chunk).await {
|
||||||
|
Ok(0) | Err(_) => return None,
|
||||||
|
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_headers_end(buffer: &[u8]) -> Option<usize> {
|
||||||
|
buffer
|
||||||
|
.windows(4)
|
||||||
|
.position(|window| window == b"\r\n\r\n")
|
||||||
|
.map(|position| position + 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn respond(stream: &mut TcpStream, status: u16, page: &str) {
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 {status} {}\r\nContent-Type: text/html; charset=utf-8\r\n\
|
||||||
|
Content-Length: {}\r\nConnection: close\r\n\r\n{page}",
|
||||||
|
reason(status),
|
||||||
|
page.len()
|
||||||
|
);
|
||||||
|
let _ = stream.write_all(response.as_bytes()).await;
|
||||||
|
let _ = stream.flush().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reason(status: u16) -> &'static str {
|
||||||
|
match status {
|
||||||
|
200 => "OK",
|
||||||
|
400 => "Bad Request",
|
||||||
|
404 => "Not Found",
|
||||||
|
_ => "Status",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回调 query 的最小解析(`k=v` 对,percent 解码)。
|
||||||
|
struct Query(Vec<(String, String)>);
|
||||||
|
|
||||||
|
impl Query {
|
||||||
|
fn parse(raw: &str) -> Self {
|
||||||
|
Self(
|
||||||
|
raw.split('&')
|
||||||
|
.filter(|pair| !pair.is_empty())
|
||||||
|
.filter_map(|pair| {
|
||||||
|
let (key, value) = pair.split_once('=')?;
|
||||||
|
Some((decode(key), decode(value)))
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, key: &str) -> Option<&str> {
|
||||||
|
self.0
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| name == key)
|
||||||
|
.map(|(_, value)| value.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `percent` 解码(`+` 视为空格;坏转义原样保留)。
|
||||||
|
fn decode(raw: &str) -> String {
|
||||||
|
let bytes = raw.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut index = 0;
|
||||||
|
while index < bytes.len() {
|
||||||
|
match bytes[index] {
|
||||||
|
b'%' if index + 2 < bytes.len() => {
|
||||||
|
let parsed = std::str::from_utf8(&bytes[index + 1..index + 3])
|
||||||
|
.ok()
|
||||||
|
.and_then(|hex| u8::from_str_radix(hex, 16).ok());
|
||||||
|
match parsed {
|
||||||
|
Some(byte) => {
|
||||||
|
out.push(byte);
|
||||||
|
index += 3;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
out.push(b'%');
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b'+' => {
|
||||||
|
out.push(b' ');
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
byte => {
|
||||||
|
out.push(byte);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{decode, oauth_error, Query};
|
||||||
|
use crate::auth::error::AuthError;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_decodes_percent_and_plus() {
|
||||||
|
let query = Query::parse("code=ab%2Fcd&state=s+1&empty=");
|
||||||
|
assert_eq!(query.get("code"), Some("ab/cd"));
|
||||||
|
assert_eq!(query.get("state"), Some("s 1"));
|
||||||
|
assert_eq!(query.get("empty"), Some(""));
|
||||||
|
assert_eq!(query.get("missing"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_keeps_broken_escapes() {
|
||||||
|
assert_eq!(decode("100%"), "100%");
|
||||||
|
assert_eq!(decode("%zz"), "%zz");
|
||||||
|
assert_eq!(decode("a%20b"), "a b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn access_denied_maps_to_canceled() {
|
||||||
|
assert_eq!(oauth_error("access_denied", None), AuthError::Canceled);
|
||||||
|
match oauth_error("invalid_request", Some("bad scope")) {
|
||||||
|
AuthError::Denied { code, description } => {
|
||||||
|
assert_eq!(code, "invalid_request");
|
||||||
|
assert_eq!(description, "bad scope");
|
||||||
|
}
|
||||||
|
other => panic!("应是 Denied,实际 {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
//! 微软端点表:**直连**(不走镜像,承 mission Context 与 `analyse/04` §4 的端点族区分)。
|
//! 微软端点表:**直连**(不走镜像,承 mission Context 与 `analyse/04` §4 的端点族区分)。
|
||||||
//!
|
//!
|
||||||
//! 两个端点族不能混用(`analyse/04` §4 的「第一个坑」):
|
//! 两条流**共用同一个端点族**(`research/08` §2 硬性约束 1:租户只能用 `consumers`):
|
||||||
//! - 设备码流:`login.microsoftonline.com/consumers/oauth2/v2.0/*`;
|
//! - 设备码流:`devicecode` + `token`;
|
||||||
//! - HMCL 的浏览器授权码流走 `login.live.com`(本期不实现,故不在此表)。
|
//! - 授权码流:`authorize`(浏览器打开)+ 同一个 `token`(`grant_type=authorization_code`)。
|
||||||
|
//!
|
||||||
|
//! 都走 `login.microsoftonline.com/consumers/oauth2/v2.0/*`:用 `common` 或 AAD 租户 id
|
||||||
|
//! 拿 `XboxLive.signin` 会直接被拒(`analyse/04` §4 的「第一个坑」)。
|
||||||
//!
|
//!
|
||||||
//! 端点集中成一张表是为了**测试可替换**:整表指向一个进程内 mock 服务器即可覆盖全链,
|
//! 端点集中成一张表是为了**测试可替换**:整表指向一个进程内 mock 服务器即可覆盖全链,
|
||||||
//! 生产默认走 [`MicrosoftEndpoints::consumers`]。
|
//! 生产默认走 [`MicrosoftEndpoints::consumers`]。
|
||||||
@@ -12,7 +15,9 @@
|
|||||||
pub struct MicrosoftEndpoints {
|
pub struct MicrosoftEndpoints {
|
||||||
/// 设备码申请(`POST`,表单 `client_id` + `scope`)。
|
/// 设备码申请(`POST`,表单 `client_id` + `scope`)。
|
||||||
pub device_code: String,
|
pub device_code: String,
|
||||||
/// 换 token / 刷新 token(`POST`,表单)。
|
/// 授权码流的授权页(`GET`,浏览器打开;`research/08` §3 方案 A)。
|
||||||
|
pub authorize: String,
|
||||||
|
/// 换 token / 刷新 token / 授权码换 token(`POST`,表单)。
|
||||||
pub token: String,
|
pub token: String,
|
||||||
/// Xbox Live 用户认证(`POST` JSON)。
|
/// Xbox Live 用户认证(`POST` JSON)。
|
||||||
pub xbl_authenticate: String,
|
pub xbl_authenticate: String,
|
||||||
@@ -35,6 +40,8 @@ impl MicrosoftEndpoints {
|
|||||||
Self {
|
Self {
|
||||||
device_code: "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"
|
device_code: "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
authorize: "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"
|
||||||
|
.to_string(),
|
||||||
token: "https://login.microsoftonline.com/consumers/oauth2/v2.0/token".to_string(),
|
token: "https://login.microsoftonline.com/consumers/oauth2/v2.0/token".to_string(),
|
||||||
xbl_authenticate: "https://user.auth.xboxlive.com/user/authenticate".to_string(),
|
xbl_authenticate: "https://user.auth.xboxlive.com/user/authenticate".to_string(),
|
||||||
xsts_authorize: "https://xsts.auth.xboxlive.com/xsts/authorize".to_string(),
|
xsts_authorize: "https://xsts.auth.xboxlive.com/xsts/authorize".to_string(),
|
||||||
@@ -54,6 +61,7 @@ impl MicrosoftEndpoints {
|
|||||||
let join = |path: &str| format!("{}/{path}", base.trim_end_matches('/'));
|
let join = |path: &str| format!("{}/{path}", base.trim_end_matches('/'));
|
||||||
Self {
|
Self {
|
||||||
device_code: join("devicecode"),
|
device_code: join("devicecode"),
|
||||||
|
authorize: join("authorize"),
|
||||||
token: join("token"),
|
token: join("token"),
|
||||||
xbl_authenticate: join("xbl"),
|
xbl_authenticate: join("xbl"),
|
||||||
xsts_authorize: join("xsts"),
|
xsts_authorize: join("xsts"),
|
||||||
@@ -66,9 +74,10 @@ impl MicrosoftEndpoints {
|
|||||||
|
|
||||||
/// 所有端点(调试/诊断用)。
|
/// 所有端点(调试/诊断用)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn all(&self) -> [&str; 8] {
|
pub fn all(&self) -> [&str; 9] {
|
||||||
[
|
[
|
||||||
&self.device_code,
|
&self.device_code,
|
||||||
|
&self.authorize,
|
||||||
&self.token,
|
&self.token,
|
||||||
&self.xbl_authenticate,
|
&self.xbl_authenticate,
|
||||||
&self.xsts_authorize,
|
&self.xsts_authorize,
|
||||||
@@ -95,11 +104,15 @@ mod tests {
|
|||||||
let endpoints = MicrosoftEndpoints::consumers();
|
let endpoints = MicrosoftEndpoints::consumers();
|
||||||
assert!(endpoints.device_code.contains("login.microsoftonline.com"));
|
assert!(endpoints.device_code.contains("login.microsoftonline.com"));
|
||||||
assert!(endpoints.token.contains("/consumers/oauth2/v2.0/token"));
|
assert!(endpoints.token.contains("/consumers/oauth2/v2.0/token"));
|
||||||
|
// 授权码流的授权页与设备码流同族同租户(`research/08` §2:只能用 consumers)。
|
||||||
|
assert!(endpoints
|
||||||
|
.authorize
|
||||||
|
.contains("/consumers/oauth2/v2.0/authorize"));
|
||||||
assert!(endpoints
|
assert!(endpoints
|
||||||
.xbl_authenticate
|
.xbl_authenticate
|
||||||
.contains("user.auth.xboxlive.com"));
|
.contains("user.auth.xboxlive.com"));
|
||||||
assert!(endpoints.xsts_authorize.contains("xsts.auth.xboxlive.com"));
|
assert!(endpoints.xsts_authorize.contains("xsts.auth.xboxlive.com"));
|
||||||
// 授权码流的 login.live.com 不在这张表里(本期不实现)。
|
// 微软 OAuth 两流都不走 HMCL 的 login.live.com 端点族。
|
||||||
assert!(endpoints
|
assert!(endpoints
|
||||||
.all()
|
.all()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -110,6 +123,7 @@ mod tests {
|
|||||||
fn all_on_keeps_paths_distinct() {
|
fn all_on_keeps_paths_distinct() {
|
||||||
let endpoints = MicrosoftEndpoints::all_on("http://127.0.0.1:1234/");
|
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.device_code, "http://127.0.0.1:1234/devicecode");
|
||||||
|
assert_eq!(endpoints.authorize, "http://127.0.0.1:1234/authorize");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
endpoints.entitlements_license,
|
endpoints.entitlements_license,
|
||||||
"http://127.0.0.1:1234/entitlements/license"
|
"http://127.0.0.1:1234/entitlements/license"
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ pub(crate) async fn refresh_tokens(
|
|||||||
tokens_from(&reply, &endpoints.token)
|
tokens_from(&reply, &endpoints.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> {
|
pub(crate) fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> {
|
||||||
let access_token = reply
|
let access_token = reply
|
||||||
.text("access_token")
|
.text("access_token")
|
||||||
.ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 access_token")))?;
|
.ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 access_token")))?;
|
||||||
@@ -237,7 +237,7 @@ fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// OAuth 错误归一:**`error` 非空即失败**(HMCL 只在两个字段都非空时处理,我们收紧)。
|
/// OAuth 错误归一:**`error` 非空即失败**(HMCL 只在两个字段都非空时处理,我们收紧)。
|
||||||
fn oauth_error(reply: &Reply, url: &str) -> AuthError {
|
pub(crate) fn oauth_error(reply: &Reply, url: &str) -> AuthError {
|
||||||
let Some(code) = reply.text("error") else {
|
let Some(code) = reply.text("error") else {
|
||||||
return AuthError::network(
|
return AuthError::network(
|
||||||
url.split('?').next().unwrap_or(url),
|
url.split('?').next().unwrap_or(url),
|
||||||
|
|||||||
@@ -1,25 +1,35 @@
|
|||||||
//! 微软轨:device-code 全流程 + 会话/刷新语义(`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5)。
|
//! 微软轨:device-code 与授权码(浏览器)两条登录流 + 会话/刷新语义
|
||||||
|
//! (`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5、`research/08` §3)。
|
||||||
//!
|
//!
|
||||||
//! 分四块,各管一件事:
|
//! 分五块,各管一件事:
|
||||||
//! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测);
|
//! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测);
|
||||||
//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock);
|
//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock);
|
||||||
//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层);
|
//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层);
|
||||||
|
//! - [`authcode`] + [`callback`]:授权码流(授权 URL + 本地 HTTP 回调服务器 + code 换 token);
|
||||||
//! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。
|
//! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。
|
||||||
//!
|
//!
|
||||||
|
//! 两条流只差「怎么拿到 MS access_token」:拿到之后都进同一个 [`chain::complete_chain`],
|
||||||
|
//! 落盘/刷新也共用 [`MicrosoftSession`],所以 GUI 账户页的两颗按钮不会长出两套凭据语义。
|
||||||
|
//!
|
||||||
//! # client id 从哪来
|
//! # client id 从哪来
|
||||||
//!
|
//!
|
||||||
//! device-code 流必须带一个**已在 Azure 注册的公共客户端 id**。HMCL 把它烘进 jar 的
|
//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(且租户是 `consumers`、
|
||||||
|
//! 重定向 URI 登记为 `http://localhost`)。HMCL 把它烘进 jar 的
|
||||||
//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由
|
//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由
|
||||||
//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时
|
//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时
|
||||||
//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`] 并说清要配什么——
|
//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`] 并说清要配什么——
|
||||||
//! 这比编一个假 id 让用户看到 `invalid_client` 诚实。
|
//! 这比编一个假 id 让用户看到 `invalid_client` 诚实。
|
||||||
|
|
||||||
|
pub mod authcode;
|
||||||
|
pub mod callback;
|
||||||
pub mod chain;
|
pub mod chain;
|
||||||
pub mod endpoints;
|
pub mod endpoints;
|
||||||
pub mod flow;
|
pub mod flow;
|
||||||
|
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|
||||||
|
pub use authcode::{AuthCodePolicy, AuthCodePrompt, RedirectUri};
|
||||||
|
pub use callback::CallbackServer;
|
||||||
pub use endpoints::MicrosoftEndpoints;
|
pub use endpoints::MicrosoftEndpoints;
|
||||||
pub use flow::{DeviceCodePrompt, PollPolicy};
|
pub use flow::{DeviceCodePrompt, PollPolicy};
|
||||||
pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA};
|
pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA};
|
||||||
@@ -45,6 +55,7 @@ pub struct MicrosoftAuth {
|
|||||||
client_id: String,
|
client_id: String,
|
||||||
endpoints: MicrosoftEndpoints,
|
endpoints: MicrosoftEndpoints,
|
||||||
poll: PollPolicy,
|
poll: PollPolicy,
|
||||||
|
authcode: AuthCodePolicy,
|
||||||
journal: Option<AuthJournal>,
|
journal: Option<AuthJournal>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +73,7 @@ impl MicrosoftAuth {
|
|||||||
client_id: client_id.into(),
|
client_id: client_id.into(),
|
||||||
endpoints: MicrosoftEndpoints::consumers(),
|
endpoints: MicrosoftEndpoints::consumers(),
|
||||||
poll: PollPolicy::default(),
|
poll: PollPolicy::default(),
|
||||||
|
authcode: AuthCodePolicy::default(),
|
||||||
journal: None,
|
journal: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -131,6 +143,13 @@ impl MicrosoftAuth {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 换授权码流参数(回调超时 / 固定端口 / 绑定重试 / 固定 state;测试与 CLI 用)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_authcode_policy(mut self, policy: AuthCodePolicy) -> Self {
|
||||||
|
self.authcode = policy;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// 当前端点表。
|
/// 当前端点表。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn endpoints(&self) -> &MicrosoftEndpoints {
|
pub fn endpoints(&self) -> &MicrosoftEndpoints {
|
||||||
@@ -177,6 +196,34 @@ impl MicrosoftAuth {
|
|||||||
chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await
|
chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 授权码流登录(浏览器 + 本地回调,`research/08` §3 方案 A,GUI 的默认入口)。
|
||||||
|
///
|
||||||
|
/// `present` 在端口绑定之后、等待回调之前被调用:宿主在这一刻拉起系统默认浏览器
|
||||||
|
/// (core 不做 UI,也不假设有 GUI——CLI 可以只把 URL 打到 stderr)。
|
||||||
|
/// 失败时与 [`Self::device_code_login`] 一样往失败日志追加一条结构化记录(M38)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// 端口绑不上/网络失败、等回调超时、用户在授权页取消、OAuth 拒绝,以及整链的
|
||||||
|
/// [`AuthError`](含白名单未批时 `login_with_xbox` 的 403 诊断)。
|
||||||
|
pub async fn authcode_login<F>(&self, present: F) -> AuthResult<MicrosoftSession>
|
||||||
|
where
|
||||||
|
F: FnOnce(&AuthCodePrompt),
|
||||||
|
{
|
||||||
|
let result = authcode::login(
|
||||||
|
&self.client,
|
||||||
|
&self.endpoints,
|
||||||
|
&self.client_id,
|
||||||
|
self.authcode.clone(),
|
||||||
|
present,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Err(error) = &result {
|
||||||
|
self.record_failure("authcode_login", error);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/// 刷新:Live refresh → 整链重跑(**原样覆盖 refresh_token**)。
|
/// 刷新:Live refresh → 整链重跑(**原样覆盖 refresh_token**)。
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! 账户体系:三轨(离线 UUID / 微软 device-code / authlib-injector)、凭据存储边界。
|
//! 账户体系:三轨(离线 UUID / 微软 device-code 与授权码 / authlib-injector)、凭据存储边界。
|
||||||
//!
|
//!
|
||||||
//! 设计定稿:`DevDocs/PlanDoc/07-accounts.md`(决策 D9/Q21),协议细节来自
|
//! 设计定稿:`DevDocs/PlanDoc/07-accounts.md`(决策 D9/Q21),协议细节来自
|
||||||
//! `DevDocs/analyse/04-authentication.md`(HMCL v3.17 拆解,逐报文对照)。
|
//! `DevDocs/analyse/04-authentication.md`(HMCL v3.17 拆解,逐报文对照)。
|
||||||
@@ -19,7 +19,8 @@
|
|||||||
//! - [`account`]:`AccountId` / `AccountKind` / `AccountMeta` / `Credentials`(三轨统一抽象);
|
//! - [`account`]:`AccountId` / `AccountKind` / `AccountMeta` / `Credentials`(三轨统一抽象);
|
||||||
//! - [`store`]:`AccountStore`(加载/增删/落盘,`data/auth/`);
|
//! - [`store`]:`AccountStore`(加载/增删/落盘,`data/auth/`);
|
||||||
//! - [`offline`]:HMCL 兼容离线 UUID + 启动占位;
|
//! - [`offline`]:HMCL 兼容离线 UUID + 启动占位;
|
||||||
//! - [`microsoft`]:device-code 全链(XBL→XSTS→login_with_xbox→entitlements→profile)+ 刷新判定;
|
//! - [`microsoft`]:微软两条登录流(device-code / 授权码+本地回调)+ 共用后链
|
||||||
|
//! (XBL→XSTS→login_with_xbox→entitlements→profile)+ 刷新判定;
|
||||||
//! - [`authlib`]:标准 Yggdrasil 协议(authenticate/refresh/validate/invalidate,账户侧);
|
//! - [`authlib`]:标准 Yggdrasil 协议(authenticate/refresh/validate/invalidate,账户侧);
|
||||||
//! - [`error`]:错误分流(取消 / 超时 / 被拒 / 凭据过期);
|
//! - [`error`]:错误分流(取消 / 超时 / 被拒 / 凭据过期);
|
||||||
//! - [`body`]:非 2xx 应答的正文诊断(分类 JSON/HTML/空体 + 脱敏摘要,M38);
|
//! - [`body`]:非 2xx 应答的正文诊断(分类 JSON/HTML/空体 + 脱敏摘要,M38);
|
||||||
@@ -47,6 +48,8 @@ pub use account::{AccountId, AccountKind, AccountMeta, Credentials};
|
|||||||
pub use body::{BodyKind, ResponseDetail};
|
pub use body::{BodyKind, ResponseDetail};
|
||||||
pub use error::{AuthError, AuthResult};
|
pub use error::{AuthError, AuthResult};
|
||||||
pub use journal::{AuthFailureEvent, AuthJournal};
|
pub use journal::{AuthFailureEvent, AuthJournal};
|
||||||
pub use microsoft::{MicrosoftAuth, MicrosoftSession, RefreshDecision, RefreshReason};
|
pub use microsoft::{
|
||||||
|
AuthCodePolicy, AuthCodePrompt, MicrosoftAuth, MicrosoftSession, RefreshDecision, RefreshReason,
|
||||||
|
};
|
||||||
pub use secret::Secret;
|
pub use secret::Secret;
|
||||||
pub use store::{AccountStore, AuthPaths};
|
pub use store::{AccountStore, AuthPaths};
|
||||||
@@ -186,13 +186,24 @@ pub fn dedupe(runtimes: Vec<JavaRuntime>) -> Vec<JavaRuntime> {
|
|||||||
kept
|
kept
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 去重键:去掉 Windows 的 `\\?\` 前缀、统一分隔符、大小写不敏感(Windows)。
|
/// 去重键:剥掉 Windows 的 `\\?\` 前缀、分隔符一律折成 `/`、大小写只在 Windows 折叠。
|
||||||
|
///
|
||||||
|
/// 前两步与平台无关:同一条 `bin/java[.exe]` 用 `/` 还是 `\` 拼出来都是同一个文件,
|
||||||
|
/// 键必须相同——`JAVA_HOME` 的写法与扫盘拼出来的路径常在这点上不一致,键不齐就重复列。
|
||||||
|
/// 大小写折叠**只能**跟着平台走:NTFS 不区分大小写,而 POSIX 上 `Java` 与 `java` 是两个
|
||||||
|
/// 目录,折了会把两份真实安装误并成一个。
|
||||||
|
///
|
||||||
|
/// 代价(已知且有意的取舍):POSIX 上文件名里的字面反斜杠也会被当分隔符。JDK 安装路径带
|
||||||
|
/// 反斜杠属于病态情况,换来的是「同一条 java 的两种拼法永远同键」这条平台无关的不变量。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn path_key(path: &Path) -> String {
|
pub fn path_key(path: &Path) -> String {
|
||||||
let text = path.display().to_string();
|
let text = path.display().to_string();
|
||||||
let text = text.strip_prefix(r"\\?\").unwrap_or(&text).to_string();
|
let text = text
|
||||||
|
.strip_prefix(r"\\?\")
|
||||||
|
.unwrap_or(&text)
|
||||||
|
.replace('\\', "/");
|
||||||
if cfg!(windows) {
|
if cfg!(windows) {
|
||||||
text.replace('/', "\\").to_ascii_lowercase()
|
text.to_ascii_lowercase()
|
||||||
} else {
|
} else {
|
||||||
text
|
text
|
||||||
}
|
}
|
||||||
@@ -236,11 +247,56 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn path_key_normalises_separators() {
|
fn path_key_normalises_separators_on_every_platform() {
|
||||||
let left = path_key(std::path::Path::new("C:/a/b/java.exe"));
|
// 平台无关的不变量:同一条 `bin/java` 用 `/` 还是 `\` 拼出来,键必须相同。
|
||||||
let right = path_key(std::path::Path::new("C:\\a\\b\\java.exe"));
|
// (M49:原来这里只在 Windows 上成立,Linux 上 `\` 原样留着 → 断言挂。)
|
||||||
assert_eq!(left, right);
|
let forward = path_key(std::path::Path::new("C:/a/b/java.exe"));
|
||||||
assert!(!path_key(std::path::Path::new(r"\\?\C:\a\java.exe")).starts_with(r"\\"));
|
let backward = path_key(std::path::Path::new("C:\\a\\b\\java.exe"));
|
||||||
|
assert_eq!(forward, backward);
|
||||||
|
assert!(
|
||||||
|
!forward.contains('\\'),
|
||||||
|
"键里不该残留反斜杠(与平台无关):{forward}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!forward.starts_with(r"\\"),
|
||||||
|
"`\\\\?\\` 前缀不进键:{forward}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_install_normalises_to_one_key_whatever_the_spelling() {
|
||||||
|
// 回归钉子:`JAVA_HOME` 与扫盘常对同一个 java 给出不同写法,四种拼法必须同键。
|
||||||
|
let spellings = [
|
||||||
|
"C:/Program Files/Java/jdk-21/bin/java.exe",
|
||||||
|
r"C:\Program Files\Java\jdk-21\bin\java.exe",
|
||||||
|
r"\\?\C:\Program Files\Java\jdk-21\bin\java.exe",
|
||||||
|
r"C:\Program Files\Java\jdk-21/bin/java.exe",
|
||||||
|
];
|
||||||
|
let key = path_key(std::path::Path::new(spellings[0]));
|
||||||
|
for spelling in spellings {
|
||||||
|
assert_eq!(
|
||||||
|
path_key(std::path::Path::new(spelling)),
|
||||||
|
key,
|
||||||
|
"同一安装的两种写法必须同键:{spelling}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_key_folds_case_only_where_the_filesystem_does() {
|
||||||
|
// 大小写折叠跟平台走,两侧都钉住:NTFS 不区分大小写 → 折;
|
||||||
|
// POSIX 上 `Java` 与 `java` 是两个目录 → 不折(折了会把两份真实安装误并)。
|
||||||
|
let upper = path_key(std::path::Path::new(
|
||||||
|
"C:/Program Files/Java/jdk-21/bin/java.exe",
|
||||||
|
));
|
||||||
|
let lower = path_key(std::path::Path::new(
|
||||||
|
"c:/program files/java/jdk-21/bin/java.exe",
|
||||||
|
));
|
||||||
|
if cfg!(windows) {
|
||||||
|
assert_eq!(upper, lower, "Windows:大小写不同的同一路径应同键");
|
||||||
|
} else {
|
||||||
|
assert_ne!(upper, lower, "POSIX:大小写是有效区别,不应同键");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -81,6 +81,15 @@ impl RunningProcess {
|
|||||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
||||||
process.creation_flags(CREATE_NEW_PROCESS_GROUP);
|
process.creation_flags(CREATE_NEW_PROCESS_GROUP);
|
||||||
}
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
// 与 Windows 的 CREATE_NEW_PROCESS_GROUP 对称:子进程自成一个进程组,
|
||||||
|
// `tree_kill` 的 `kill -- -<pgid>` 才能覆盖整棵子树。`process_group` 是
|
||||||
|
// std 1.64+ 的**安全** API,不需要 pre_exec / unsafe(旧注释说“unsafe 被挡”已过时)。
|
||||||
|
// 参数 0 = `pgid = 子进程 pid`。
|
||||||
|
process.process_group(0);
|
||||||
|
}
|
||||||
|
|
||||||
let mut child = process.spawn().map_err(|source| LaunchError::Spawn {
|
let mut child = process.spawn().map_err(|source| LaunchError::Spawn {
|
||||||
program: command.program.clone(),
|
program: command.program.clone(),
|
||||||
|
|||||||
@@ -6,27 +6,39 @@
|
|||||||
//! - Windows:`taskkill /T /F /PID`(`/T` 递归子树)。P0 不引入 `windows-sys`,因此没有
|
//! - Windows:`taskkill /T /F /PID`(`/T` 递归子树)。P0 不引入 `windows-sys`,因此没有
|
||||||
//! Job Object——Job Object(`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`)留 P1,替换点就在本文件
|
//! Job Object——Job Object(`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`)留 P1,替换点就在本文件
|
||||||
//! (tower 已批准,见 `launch-接口约定.md` §7-1)。
|
//! (tower 已批准,见 `launch-接口约定.md` §7-1)。
|
||||||
//! - POSIX:`kill -TERM -<pid>`(命不中是常态,因为我们没有 `setsid`——`unsafe` pre_exec 被
|
//! - POSIX:子进程由 [`crate::launch::process`] 用 `CommandExt::process_group(0)` 放进**自己的**
|
||||||
//! workspace `unsafe_code = deny` 挡住)→ 宽限 → `kill -KILL -<pid>`,再退到单 pid。
|
//! 进程组(与 Windows 的 `CREATE_NEW_PROCESS_GROUP` 对称,std 1.64+ 的安全 API),因此
|
||||||
|
//! `kill -TERM -- -<pgid>` 一次覆盖整棵子树 → 宽限 → `kill -KILL -- -<pgid>`,再补一发单 pid。
|
||||||
//!
|
//!
|
||||||
//! 两个平台都先尝试“温和”,宽限期后再“强制”。
|
//! 两个平台都先尝试“温和”,宽限期后再“强制”。
|
||||||
//!
|
//!
|
||||||
|
//! # 两条来自 CI 的硬约束
|
||||||
|
//!
|
||||||
|
//! 1. **组杀必须先验组**([`group_kill_target`]):目标进程必须是它自己进程组的**组长**,
|
||||||
|
//! 且该组不能是我们所在的组。否则一次 `kill -- -<pgid>` 会把测试进程(CI 容器里全体进程
|
||||||
|
//! 常常共用 pgid 1)一起带走——M48 之前 `test (ubuntu-latest)` 就是这么被 SIGTERM 打断的。
|
||||||
|
//! 验不过就只发单 pid。
|
||||||
|
//! 2. **`--` 不能省**:procps-ng 的 `kill` 会把裸 `-<pgid>` 当成又一个信号选项吞掉,**退出码 0
|
||||||
|
//! 却什么都没发**(procps-ng 4.0.2 实测)。旧实现把这种“假成功”当组杀成功直接返回,
|
||||||
|
//! 于是单 pid 兜底永远不执行,`stop()` 只能干等超时——`stop_kills_process_tree` 与
|
||||||
|
//! `pipeline_stop_records_killed_exit` 两条红灯的真正死因。
|
||||||
|
//!
|
||||||
//! ⚠️ 外部命令的退出码**不能**用来判断“进程是否已被结束”:温和阶段成功关掉进程后,
|
//! ⚠️ 外部命令的退出码**不能**用来判断“进程是否已被结束”:温和阶段成功关掉进程后,
|
||||||
//! 再发 `taskkill /F` 会得到 128(“没有这个运行实例”)。所以 [`crate::launch::process`]
|
//! 再发 `taskkill /F` 会得到 128(“没有这个运行实例”)。所以 [`crate::launch::process`]
|
||||||
//! 的实际停止流程以“进程是否真的消失”为准(`try_wait` 轮询),本模块只提供发送动作。
|
//! 的实际停止流程以“进程是否真的消失”为准(`try_wait` 轮询),本模块只提供发送动作。
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::thread;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// 温和关闭(Windows 无 `/F`;POSIX `SIGTERM`)。
|
/// 温和关闭(Windows 无 `/F`;POSIX `SIGTERM`)。
|
||||||
pub fn terminate(pid: u32) -> io::Result<()> {
|
pub fn terminate(pid: u32) -> io::Result<()> {
|
||||||
|
check_pid(pid)?;
|
||||||
imp::signal(pid, false)
|
imp::signal(pid, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 强制关闭(Windows `/F`;POSIX `SIGKILL`),并尽量覆盖整棵进程树。
|
/// 强制关闭(Windows `/F`;POSIX `SIGKILL`),并尽量覆盖整棵进程树。
|
||||||
pub fn force_kill(pid: u32) -> io::Result<()> {
|
pub fn force_kill(pid: u32) -> io::Result<()> {
|
||||||
|
check_pid(pid)?;
|
||||||
imp::signal(pid, true)
|
imp::signal(pid, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,17 +46,70 @@ pub fn force_kill(pid: u32) -> io::Result<()> {
|
|||||||
///
|
///
|
||||||
/// 注意:返回值不代表“进程已经死了”(见模块注释)。需要准确判定请用
|
/// 注意:返回值不代表“进程已经死了”(见模块注释)。需要准确判定请用
|
||||||
/// [`crate::launch::process::RunningProcess::stop`]。
|
/// [`crate::launch::process::RunningProcess::stop`]。
|
||||||
pub fn kill_tree(pid: u32, grace: Duration) -> io::Result<()> {
|
pub fn kill_tree(pid: u32, grace: std::time::Duration) -> io::Result<()> {
|
||||||
if pid == 0 {
|
check_pid(pid)?;
|
||||||
return Err(io::Error::other("无效的 pid 0"));
|
|
||||||
}
|
|
||||||
let _ = terminate(pid);
|
let _ = terminate(pid);
|
||||||
if !grace.is_zero() {
|
if !grace.is_zero() {
|
||||||
thread::sleep(grace);
|
std::thread::sleep(grace);
|
||||||
}
|
}
|
||||||
force_kill(pid)
|
force_kill(pid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `pid` 0 意味着“当前进程组”(POSIX `kill` 的 `-0`)——绝不能顺手发出去。
|
||||||
|
fn check_pid(pid: u32) -> io::Result<()> {
|
||||||
|
if pid == 0 {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"无效的 pid 0:拒绝向当前进程组发信号",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 允许组杀的进程组号;`None` = 只能发单 pid。
|
||||||
|
///
|
||||||
|
/// 三重校验,任一不过就退回单 pid:
|
||||||
|
/// 1. 目标必须是它自己进程组的组长(`pgid == pid`)——否则 `-<pgid>` 指的不是“它的”子树;
|
||||||
|
/// 2. 必须能读到我们自己所在的进程组;
|
||||||
|
/// 3. 目标组不能就是我们所在的组——否则信号会打到自己人身上。
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub fn group_kill_target(pid: u32) -> Option<u32> {
|
||||||
|
if pid == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let target = pgid_of(pid)?;
|
||||||
|
if target != pid {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let own = pgid_of(std::process::id())?;
|
||||||
|
(target != own).then_some(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读 `pid` 所在进程组号。Linux 走 `/proc`,其它 POSIX 用 `ps`;读不到就是 `None`(不发组杀)。
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn pgid_of(pid: u32) -> Option<u32> {
|
||||||
|
// `/proc/<pid>/stat`: `pid (comm) state ppid pgrp ...`,`comm` 可能含空格和括号,
|
||||||
|
// 所以从**最后一个** `)` 之后开始数字段。
|
||||||
|
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||||
|
let tail = &stat[stat.rfind(')')? + 1..];
|
||||||
|
let mut fields = tail.split_whitespace();
|
||||||
|
fields.next()?; // state
|
||||||
|
fields.next()?; // ppid
|
||||||
|
fields.next()?.parse().ok() // pgrp
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(unix, not(target_os = "linux")))]
|
||||||
|
fn pgid_of(pid: u32) -> Option<u32> {
|
||||||
|
let output = Command::new("ps")
|
||||||
|
.args(["-o", "pgid=", "-p", &pid.to_string()])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod imp {
|
mod imp {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -79,28 +144,42 @@ mod imp {
|
|||||||
|
|
||||||
pub(super) fn signal(pid: u32, force: bool) -> io::Result<()> {
|
pub(super) fn signal(pid: u32, force: bool) -> io::Result<()> {
|
||||||
let flag = if force { "-KILL" } else { "-TERM" };
|
let flag = if force { "-KILL" } else { "-TERM" };
|
||||||
// 先按进程组(`-<pid>`);没有 setsid 时通常失败,再退到单 pid。
|
let mut delivered = false;
|
||||||
if run_kill(flag, &format!("-{pid}"))?.is_some() {
|
// 验过的独立进程组:一次覆盖组长 + 它的全部后代。
|
||||||
return Ok(());
|
if let Some(pgid) = group_kill_target(pid) {
|
||||||
|
let group = format!("-{pgid}");
|
||||||
|
delivered |= run_kill(flag, &["--", &group])?;
|
||||||
}
|
}
|
||||||
match run_kill(flag, &pid.to_string())? {
|
// 单 pid 兜底永远执行:组杀不可用/进程已不在组里时靠它。
|
||||||
Some(status) if status.success() => Ok(()),
|
let single = pid.to_string();
|
||||||
Some(status) => Err(io::Error::other(format!("kill 退出码 {status}"))),
|
delivered |= run_kill(flag, &[&single])?;
|
||||||
None => Err(io::Error::other("找不到 kill 命令")),
|
if delivered {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
// 常见于“进程已经退出”,不值得中断关闭流程;但绝不谎报成功。
|
||||||
|
Err(io::Error::other(format!(
|
||||||
|
"kill {flag} {pid} 没有送达(进程可能已退出)"
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_kill(flag: &str, target: &str) -> io::Result<Option<std::process::ExitStatus>> {
|
/// 发一发 `kill <flag> <args...>`,返回是否送达。
|
||||||
|
///
|
||||||
|
/// `kill` 不存在时给**明确**错误而不是静默失败:否则调用方只会看到“进程仍在运行”,
|
||||||
|
/// 排查方向会被带偏(M48 之前的红灯就是这么来的)。
|
||||||
|
fn run_kill(flag: &str, args: &[&str]) -> io::Result<bool> {
|
||||||
match Command::new("kill")
|
match Command::new("kill")
|
||||||
.arg(flag)
|
.arg(flag)
|
||||||
.arg(target)
|
.args(args)
|
||||||
.stdout(std::process::Stdio::null())
|
.stdout(std::process::Stdio::null())
|
||||||
.stderr(std::process::Stdio::null())
|
.stderr(std::process::Stdio::null())
|
||||||
.status()
|
.status()
|
||||||
{
|
{
|
||||||
Ok(status) if status.success() => Ok(Some(status)),
|
Ok(status) => Ok(status.success()),
|
||||||
Ok(_) => Ok(None),
|
Err(err) if err.kind() == io::ErrorKind::NotFound => Err(io::Error::new(
|
||||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
|
io::ErrorKind::NotFound,
|
||||||
|
"找不到 kill 命令:POSIX 进程树回收依赖 procps(Linux)或系统自带 kill(macOS/BSD)",
|
||||||
|
)),
|
||||||
Err(err) => Err(err),
|
Err(err) => Err(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//! - `offline_uuid`:离线 UUID 与 JDK(= HMCL 算法)**逐字节对拍**的已知向量;
|
//! - `offline_uuid`:离线 UUID 与 JDK(= HMCL 算法)**逐字节对拍**的已知向量;
|
||||||
//! - `store_separation`:元数据/凭据分文件、原子写、写序、容错与孤儿清理;
|
//! - `store_separation`:元数据/凭据分文件、原子写、写序、容错与孤儿清理;
|
||||||
//! - `microsoft_flow`:device-code 全链(含 pending / slow_down / 取消 / 超时 / XErr);
|
//! - `microsoft_flow`:device-code 全链(含 pending / slow_down / 取消 / 超时 / XErr);
|
||||||
|
//! - `microsoft_authcode`:授权码流(本地回调服务器 / 授权 URL 向量 / code 换链 / 403 诊断);
|
||||||
//! - `microsoft_diagnostics`:非 2xx 正文诊断(JSON/HTML/空体分流)+ 失败日志落盘(M38);
|
//! - `microsoft_diagnostics`:非 2xx 正文诊断(JSON/HTML/空体分流)+ 失败日志落盘(M38);
|
||||||
//! - `microsoft_refresh`:refresh 轮换、profile 变脸防线、validate 语义、404 二义;
|
//! - `microsoft_refresh`:refresh 轮换、profile 变脸防线、validate 语义、404 二义;
|
||||||
//! - `authlib_api`:Yggdrasil 四动作的报文级对接。
|
//! - `authlib_api`:Yggdrasil 四动作的报文级对接。
|
||||||
@@ -12,6 +13,7 @@ mod support;
|
|||||||
|
|
||||||
mod auth_failure_journal;
|
mod auth_failure_journal;
|
||||||
mod authlib_api;
|
mod authlib_api;
|
||||||
|
mod microsoft_authcode;
|
||||||
mod microsoft_diagnostics;
|
mod microsoft_diagnostics;
|
||||||
mod microsoft_flow;
|
mod microsoft_flow;
|
||||||
mod microsoft_refresh;
|
mod microsoft_refresh;
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
//! 授权码流(`research/08` §3 方案 A):本地回调服务器 + 授权 URL + code 换链。
|
||||||
|
//!
|
||||||
|
//! 覆盖验收口径:授权码换到 MS token → XBL → XSTS 全通;白名单未批前
|
||||||
|
//! `login_with_xbox` 的 403 只要求**诊断文案正确**(M38 版),不绕过。
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
use unlucky_core::auth::microsoft::{
|
||||||
|
AuthCodePolicy, AuthCodePrompt, CallbackServer, MicrosoftAuth, MicrosoftEndpoints,
|
||||||
|
};
|
||||||
|
use unlucky_core::auth::AuthError;
|
||||||
|
|
||||||
|
use crate::support::{full_routes, response_of, AuthMock, MockReply};
|
||||||
|
|
||||||
|
const CLIENT_ID: &str = "test-client-id";
|
||||||
|
|
||||||
|
fn policy(state: &str, timeout_ms: u64) -> AuthCodePolicy {
|
||||||
|
AuthCodePolicy {
|
||||||
|
callback_timeout: Duration::from_millis(timeout_ms),
|
||||||
|
bind_attempts: 3,
|
||||||
|
port: None,
|
||||||
|
state: Some(state.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟浏览器:把 `code` 送到本地回调地址(真发一次 HTTP,走 localhost 解析)。
|
||||||
|
async fn deliver(redirect_uri: &str, query: &str) {
|
||||||
|
let url = format!("{redirect_uri}?{query}");
|
||||||
|
// 回调服务器可能在 accept 中,给一拍让绑定先落地。
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
let response = reqwest::get(&url).await.expect("浏览器回调");
|
||||||
|
assert_eq!(response.status(), 200, "回调页应回 200");
|
||||||
|
let page = response.text().await.expect("回调页正文");
|
||||||
|
assert!(page.contains("登录完成"), "回调页应是给用户看的完成页");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authcode_login_completes_the_chain() {
|
||||||
|
let mock = AuthMock::start(full_routes()).await;
|
||||||
|
let auth = MicrosoftAuth::new(CLIENT_ID)
|
||||||
|
.expect("构造")
|
||||||
|
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
|
||||||
|
.with_authcode_policy(policy("state-abc", 5_000));
|
||||||
|
|
||||||
|
let mut prompted: Option<AuthCodePrompt> = None;
|
||||||
|
let session = auth
|
||||||
|
.authcode_login(|prompt| {
|
||||||
|
prompted = Some(prompt.clone());
|
||||||
|
let redirect = prompt.redirect_uri.clone();
|
||||||
|
tokio::spawn(
|
||||||
|
async move { deliver(&redirect, "code=the-auth-code&state=state-abc").await },
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("授权码登录成功");
|
||||||
|
|
||||||
|
// 1. 宿主拿到的是「授权 URL + 本地回调地址」,两者端口必须一致。
|
||||||
|
let prompt = prompted.expect("必须回调展示授权 URL");
|
||||||
|
let redirect = prompt.redirect_uri.clone();
|
||||||
|
assert!(redirect.starts_with("http://localhost:"), "{redirect}");
|
||||||
|
assert!(redirect.ends_with("/auth"), "{redirect}");
|
||||||
|
assert!(prompt.authorization_url.contains("prompt=select_account"));
|
||||||
|
// 端点表在测试里整体指向 mock,所以授权页的路径是 `/authorize`(租户口径见向量单测)。
|
||||||
|
assert!(
|
||||||
|
prompt.authorization_url.starts_with(&mock.base())
|
||||||
|
&& prompt
|
||||||
|
.authorization_url
|
||||||
|
.contains("/authorize?client_id=test-client-id"),
|
||||||
|
"{}",
|
||||||
|
prompt.authorization_url
|
||||||
|
);
|
||||||
|
assert!(prompt.authorization_url.contains("state=state-abc"));
|
||||||
|
assert_eq!(prompt.timeout, Duration::from_millis(5_000));
|
||||||
|
|
||||||
|
// 2. code 换 token 的表单:grant_type + 原样重定向 URI(URI 严格一致那条坑)。
|
||||||
|
let token_bodies = mock.bodies_on("/token");
|
||||||
|
assert_eq!(token_bodies.len(), 1, "只该换一次 token");
|
||||||
|
let body = &token_bodies[0];
|
||||||
|
assert!(body.contains("grant_type=authorization_code"), "{body}");
|
||||||
|
assert!(body.contains("code=the-auth-code"), "{body}");
|
||||||
|
assert!(
|
||||||
|
body.contains("redirect_uri=http%3A%2F%2Flocalhost%3A") && !body.contains(&redirect),
|
||||||
|
"redirect_uri 必须是编码后的同一字面量:{body}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains("scope=XboxLive.signin+offline_access")
|
||||||
|
|| body.contains("scope=XboxLive.signin%20offline_access"),
|
||||||
|
"{body}"
|
||||||
|
);
|
||||||
|
// 授权 URL 里的 redirect_uri 与 token 请求里的必须是同一个端口。
|
||||||
|
let encoded = redirect.replace(':', "%3A").replace('/', "%2F");
|
||||||
|
assert!(
|
||||||
|
prompt.authorization_url.contains(&encoded),
|
||||||
|
"{}",
|
||||||
|
prompt.authorization_url
|
||||||
|
);
|
||||||
|
assert!(body.contains(&encoded), "{body}");
|
||||||
|
|
||||||
|
// 3. 后链与会话:与设备码流同一套字段语义。
|
||||||
|
assert_eq!(session.profile_name, "Steve");
|
||||||
|
assert_eq!(session.access_token.expose(), "mc-access-token-value");
|
||||||
|
assert_eq!(session.refresh_token.expose(), "live-refresh-token-value");
|
||||||
|
assert!(!format!("{session:?}").contains("mc-access-token-value"));
|
||||||
|
assert_eq!(mock.bodies_on("/xbl").len(), 1);
|
||||||
|
assert_eq!(mock.bodies_on("/xsts").len(), 1);
|
||||||
|
mock.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn callback_server_ignores_unrelated_requests_and_bad_state() {
|
||||||
|
let server = CallbackServer::bind(None, 1, "state-ok")
|
||||||
|
.await
|
||||||
|
.expect("绑定");
|
||||||
|
let port = server.port();
|
||||||
|
let browser = tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
let base = format!("http://localhost:{port}");
|
||||||
|
// favicon / 浏览器预热:404 但绝不能结束等待。
|
||||||
|
let favicon = reqwest::get(format!("{base}/favicon.ico"))
|
||||||
|
.await
|
||||||
|
.expect("favicon");
|
||||||
|
assert_eq!(favicon.status(), 404);
|
||||||
|
// state 不匹配(旧标签页的迟到回调):400,继续等。
|
||||||
|
let stale = reqwest::get(format!("{base}/auth?code=stale-code&state=state-old"))
|
||||||
|
.await
|
||||||
|
.expect("迟到的回调");
|
||||||
|
assert_eq!(stale.status(), 400);
|
||||||
|
let good = reqwest::get(format!("{base}/auth?code=good-code&state=state-ok"))
|
||||||
|
.await
|
||||||
|
.expect("真回调");
|
||||||
|
assert_eq!(good.status(), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
let code = server
|
||||||
|
.wait_for_code(Duration::from_secs(5))
|
||||||
|
.await
|
||||||
|
.expect("应拿到 code");
|
||||||
|
assert_eq!(code, "good-code");
|
||||||
|
browser.await.expect("模拟浏览器任务");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn foreign_state_cannot_cancel_the_login() {
|
||||||
|
// P2 回归:回调先校验 state,再处理 error(RFC 6749 §4.1.2.1 / RFC 9207)。
|
||||||
|
// 修之前 `GET /auth?error=access_denied`(state 随便给或不给)就能把登录判成 Canceled。
|
||||||
|
let server = CallbackServer::bind(None, 1, "state-ok")
|
||||||
|
.await
|
||||||
|
.expect("绑定");
|
||||||
|
let port = server.port();
|
||||||
|
let browser = tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
let base = format!("http://localhost:{port}/auth");
|
||||||
|
// 1. 连 state 都不给:与本次登录无关 → 400,继续等。
|
||||||
|
let bare = reqwest::get(format!("{base}?error=access_denied"))
|
||||||
|
.await
|
||||||
|
.expect("伪造取消");
|
||||||
|
assert_eq!(bare.status(), 400);
|
||||||
|
// 2. state 明显不匹配:同样忽略,绝不当成用户取消。
|
||||||
|
let foreign = reqwest::get(format!("{base}?error=access_denied&state=not-our-state"))
|
||||||
|
.await
|
||||||
|
.expect("伪造取消带错 state");
|
||||||
|
assert_eq!(foreign.status(), 400);
|
||||||
|
// 3. 真正带对 state 的回调仍然能完成登录。
|
||||||
|
let good = reqwest::get(format!("{base}?code=good-code&state=state-ok"))
|
||||||
|
.await
|
||||||
|
.expect("真回调");
|
||||||
|
assert_eq!(good.status(), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
let code = server
|
||||||
|
.wait_for_code(Duration::from_secs(5))
|
||||||
|
.await
|
||||||
|
.expect("伪造的 access_denied 不能中止登录");
|
||||||
|
assert_eq!(code, "good-code");
|
||||||
|
browser.await.expect("模拟浏览器任务");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn access_denied_with_our_state_is_still_canceled() {
|
||||||
|
// 另一半语义:用户真的在授权页点了取消(带本机 state)→ 仍然是 Canceled。
|
||||||
|
let server = CallbackServer::bind(None, 1, "state-ok")
|
||||||
|
.await
|
||||||
|
.expect("绑定");
|
||||||
|
let port = server.port();
|
||||||
|
let browser = tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
let url = format!("http://localhost:{port}/auth?error=access_denied&state=state-ok");
|
||||||
|
let _ = reqwest::get(url).await;
|
||||||
|
});
|
||||||
|
let error = server
|
||||||
|
.wait_for_code(Duration::from_secs(5))
|
||||||
|
.await
|
||||||
|
.expect_err("本机 state 的取消要生效");
|
||||||
|
assert_eq!(error, AuthError::Canceled);
|
||||||
|
browser.await.expect("模拟浏览器任务");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn callback_timeout_is_reported() {
|
||||||
|
let auth = MicrosoftAuth::new(CLIENT_ID)
|
||||||
|
.expect("构造")
|
||||||
|
.with_authcode_policy(policy("state-abc", 30));
|
||||||
|
let error = auth
|
||||||
|
.authcode_login(|_prompt| {})
|
||||||
|
.await
|
||||||
|
.expect_err("没人回调就该超时");
|
||||||
|
assert_eq!(error.code(), "timeout");
|
||||||
|
assert!(
|
||||||
|
error.message().contains("浏览器回调"),
|
||||||
|
"{}",
|
||||||
|
error.message()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn access_denied_on_the_callback_page_is_canceled() {
|
||||||
|
let auth = MicrosoftAuth::new(CLIENT_ID)
|
||||||
|
.expect("构造")
|
||||||
|
.with_authcode_policy(policy("state-abc", 5_000));
|
||||||
|
let error = auth
|
||||||
|
.authcode_login(|prompt| {
|
||||||
|
let redirect = prompt.redirect_uri.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
let url = format!("{redirect}?error=access_denied&state=state-abc");
|
||||||
|
let _ = reqwest::get(&url).await;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("用户取消");
|
||||||
|
assert_eq!(error, AuthError::Canceled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn busy_port_retries_then_reports_which_port() {
|
||||||
|
// 先占住一个端口,再让回调服务器去绑同一个:必须失败并说清是哪个端口。
|
||||||
|
let occupied = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||||
|
.await
|
||||||
|
.expect("占端口");
|
||||||
|
let port = occupied.local_addr().expect("地址").port();
|
||||||
|
let error = CallbackServer::bind(Some(port), 2, "s")
|
||||||
|
.await
|
||||||
|
.expect_err("端口已被占");
|
||||||
|
assert_eq!(error.code(), "network");
|
||||||
|
assert!(
|
||||||
|
error.message().contains(&port.to_string()),
|
||||||
|
"文案要指出端口:{}",
|
||||||
|
error.message()
|
||||||
|
);
|
||||||
|
assert!(error.message().contains("重试 2 次"), "{}", error.message());
|
||||||
|
// 同一台机器上换个端口就能绑上(重试机制不是把错误吞掉)。
|
||||||
|
let ok = CallbackServer::bind(None, 1, "s").await.expect("内核分配");
|
||||||
|
assert_ne!(ok.port(), port);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_with_xbox_403_keeps_the_m38_diagnostic() {
|
||||||
|
// 白名单未批的真机状态:授权码换到 token、XBL/XSTS 全通,最后一步 403。
|
||||||
|
let mut routes = full_routes();
|
||||||
|
let (_, login) = routes
|
||||||
|
.iter_mut()
|
||||||
|
.find(|(path, _)| *path == "/login_with_xbox")
|
||||||
|
.expect("路由表里有 login_with_xbox");
|
||||||
|
*login = vec![MockReply::json_status(
|
||||||
|
403,
|
||||||
|
json!({ "path": "/authentication/login_with_xbox", "errorType": "FORBIDDEN",
|
||||||
|
"error": "Invalid app registration" }),
|
||||||
|
)];
|
||||||
|
let mock = AuthMock::start(routes).await;
|
||||||
|
let auth = MicrosoftAuth::new(CLIENT_ID)
|
||||||
|
.expect("构造")
|
||||||
|
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
|
||||||
|
.with_authcode_policy(policy("state-abc", 5_000));
|
||||||
|
|
||||||
|
let error = auth
|
||||||
|
.authcode_login(|prompt| {
|
||||||
|
let redirect = prompt.redirect_uri.clone();
|
||||||
|
tokio::spawn(async move { deliver(&redirect, "code=c&state=state-abc").await });
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("403 必须冒出来");
|
||||||
|
|
||||||
|
let detail = response_of(&error);
|
||||||
|
assert_eq!(detail.status, 403);
|
||||||
|
assert_eq!(detail.detail_code(), "login_with_xbox_403");
|
||||||
|
// 「授权码这条流本身是通的」——这正是验收止步的位置。
|
||||||
|
assert_eq!(mock.bodies_on("/token").len(), 1);
|
||||||
|
assert_eq!(mock.bodies_on("/xbl").len(), 1);
|
||||||
|
assert_eq!(mock.bodies_on("/xsts").len(), 1);
|
||||||
|
let message = error.message();
|
||||||
|
assert!(message.contains("Minecraft 登录被拒"), "{message}");
|
||||||
|
assert!(message.contains("Invalid app registration"), "{message}");
|
||||||
|
mock.stop();
|
||||||
|
}
|
||||||
@@ -96,17 +96,20 @@ fn scanned_java_is_still_used_when_nothing_else_matches() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn dedupe_collapses_one_install_found_twice() {
|
fn dedupe_collapses_one_install_found_twice() {
|
||||||
// `JAVA_HOME` 指向 Program Files 里的 JDK → 同一个 java 不应列两次。
|
// `JAVA_HOME` 指向 Program Files 里的 JDK → 同一个 java 不应列两次。
|
||||||
|
// 第二条的「另一种写法」随平台给:Windows 上大小写和分隔符都可能不一致(NTFS 不区分
|
||||||
|
// 大小写),POSIX 上大小写是有效区别,只能靠分隔符拼出同一路径的第二种写法。
|
||||||
|
let duplicate = if cfg!(windows) {
|
||||||
|
"c:/program files/java/jdk-21/bin/java.exe"
|
||||||
|
} else {
|
||||||
|
"C:\\Program Files\\Java\\jdk-21/bin/java.exe"
|
||||||
|
};
|
||||||
let candidates = discover::dedupe(vec![
|
let candidates = discover::dedupe(vec![
|
||||||
runtime(
|
runtime(
|
||||||
"C:/Program Files/Java/jdk-21/bin/java.exe",
|
"C:/Program Files/Java/jdk-21/bin/java.exe",
|
||||||
21,
|
21,
|
||||||
JavaOrigin::JavaHome,
|
JavaOrigin::JavaHome,
|
||||||
),
|
),
|
||||||
runtime(
|
runtime(duplicate, 21, JavaOrigin::System),
|
||||||
"c:/program files/java/jdk-21/bin/java.exe",
|
|
||||||
21,
|
|
||||||
JavaOrigin::System,
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
assert_eq!(candidates.len(), 1);
|
assert_eq!(candidates.len(), 1);
|
||||||
assert_eq!(candidates[0].origin, JavaOrigin::JavaHome);
|
assert_eq!(candidates[0].origin, JavaOrigin::JavaHome);
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ mod process_smoke;
|
|||||||
mod pump_buffer;
|
mod pump_buffer;
|
||||||
mod session_events;
|
mod session_events;
|
||||||
mod sink_cases;
|
mod sink_cases;
|
||||||
|
mod tree_kill_cases;
|
||||||
@@ -111,6 +111,46 @@ fn stop_kills_process_tree() {
|
|||||||
assert!(!child.is_running());
|
assert!(!child.is_running());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn spawn_puts_child_in_its_own_process_group() {
|
||||||
|
/// `/proc/<pid>/stat` 里的 `pgrp` 字段。
|
||||||
|
fn pgid(pid: u32) -> u32 {
|
||||||
|
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).expect("读 /proc stat");
|
||||||
|
let tail = &stat[stat.rfind(')').expect("stat 应有 comm 括号") + 1..];
|
||||||
|
tail.split_whitespace()
|
||||||
|
.nth(2)
|
||||||
|
.expect("stat 应有 pgrp 字段")
|
||||||
|
.parse()
|
||||||
|
.expect("pgrp 应是数字")
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut child = RunningProcess::spawn(
|
||||||
|
&shell_command("sleep 30"),
|
||||||
|
None,
|
||||||
|
Arc::new(NullSink),
|
||||||
|
config(),
|
||||||
|
)
|
||||||
|
.expect("spawn 应成功");
|
||||||
|
let pid = child.pid();
|
||||||
|
let own_pgid = pgid(std::process::id());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
pgid(pid),
|
||||||
|
pid,
|
||||||
|
"子进程应是自己的进程组组长(process_group(0))"
|
||||||
|
);
|
||||||
|
assert_ne!(pgid(pid), own_pgid, "子进程不能挂在测试进程的进程组上");
|
||||||
|
assert_eq!(
|
||||||
|
unlucky_core::launch::tree_kill::group_kill_target(pid),
|
||||||
|
Some(pid),
|
||||||
|
"独立组的组长才是合法的组杀目标"
|
||||||
|
);
|
||||||
|
|
||||||
|
child.stop().expect("停止应成功");
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn spawn_reports_missing_program() {
|
fn spawn_reports_missing_program() {
|
||||||
let mut command = shell_command("echo hi");
|
let mut command = shell_command("echo hi");
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
//! 进程树回收的安全钉子:组杀只打“目标自己的独立进程组”,绝不命中我们所在的组。
|
||||||
|
//!
|
||||||
|
//! 这些用例钉的是 M48 的两条根因:
|
||||||
|
//! - 子进程必须自成一个进程组(`process_group(0)`),否则 `kill -- -<pgid>` 没有可用的组;
|
||||||
|
//! - 组杀前必须验组([`tree_kill::group_kill_target`]),否则 CI 容器里“全体进程 pgid=1”
|
||||||
|
//! 的那种环境会被一发组杀连测试进程一起带走。
|
||||||
|
//!
|
||||||
|
//! `/proc` 只有 Linux 有,进程组相关的用例都用 `#[cfg(target_os = "linux")]` 门住;
|
||||||
|
//! `pid == 0` 的守卫与平台无关,各平台都跑。
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use unlucky_core::launch::tree_kill;
|
||||||
|
|
||||||
|
/// `pid` 0 在 POSIX `kill` 里表示“当前进程组”——绝不能顺手发出去。
|
||||||
|
#[test]
|
||||||
|
fn pid_zero_is_rejected() {
|
||||||
|
assert!(tree_kill::terminate(0).is_err(), "terminate(0) 必须拒绝");
|
||||||
|
assert!(tree_kill::force_kill(0).is_err(), "force_kill(0) 必须拒绝");
|
||||||
|
assert!(
|
||||||
|
tree_kill::kill_tree(0, Duration::ZERO).is_err(),
|
||||||
|
"kill_tree(0) 必须拒绝"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux {
|
||||||
|
use super::*;
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
|
||||||
|
use unlucky_core::launch::tree_kill::group_kill_target;
|
||||||
|
|
||||||
|
/// `/proc/<pid>/stat` 里 `)` 之后的字段(state ppid pgrp ...)。
|
||||||
|
fn stat_tail(pid: u32) -> Option<Vec<String>> {
|
||||||
|
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||||
|
let tail = stat.get(stat.rfind(')')? + 1..)?;
|
||||||
|
Some(tail.split_whitespace().map(str::to_string).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pgid(pid: u32) -> Option<u32> {
|
||||||
|
stat_tail(pid)?.get(2)?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 还活着(僵尸不算:已经死了只是没人 wait)。
|
||||||
|
fn running(pid: u32) -> bool {
|
||||||
|
matches!(stat_tail(pid).as_deref().and_then(|fields| fields.first().map(String::as_str)), Some(state) if state != "Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_gone(pid: u32, budget: Duration) -> bool {
|
||||||
|
let deadline = std::time::Instant::now() + budget;
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
if !running(pid) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
!running(pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 组长的子进程(`process_group(0)`):自带一个组。
|
||||||
|
fn spawn_grouped(script: &str) -> Child {
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
Command::new("/bin/sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(script)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.process_group(0)
|
||||||
|
.spawn()
|
||||||
|
.expect("spawn 应成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 和测试进程同组的子进程(不调 `process_group`):组长不是它。
|
||||||
|
fn spawn_in_our_group(script: &str) -> Child {
|
||||||
|
Command::new("/bin/sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(script)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.expect("spawn 应成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试失败时也别把 `sleep 30` 留在机器上。
|
||||||
|
struct KillOnDrop(u32);
|
||||||
|
|
||||||
|
impl Drop for KillOnDrop {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = tree_kill::force_kill(self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 自己的进程组号:组杀安全的基准。
|
||||||
|
fn self_pgid() -> u32 {
|
||||||
|
pgid(std::process::id()).expect("/proc/self/stat 应可读")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_kill_target_accepts_only_a_verified_own_group() {
|
||||||
|
let own_pgid = self_pgid();
|
||||||
|
// 我们自己所在的组永远不是合法目标(哪怕我们正好是组长)。
|
||||||
|
assert_eq!(
|
||||||
|
group_kill_target(std::process::id()),
|
||||||
|
None,
|
||||||
|
"自己的进程号绝不能成为组杀目标"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
group_kill_target(own_pgid),
|
||||||
|
None,
|
||||||
|
"自己的进程组绝不能成为组杀目标"
|
||||||
|
);
|
||||||
|
assert_eq!(group_kill_target(0), None, "pid 0 不是合法目标");
|
||||||
|
|
||||||
|
// 同组的子进程:它不是组长,`-<pid>` 指向的不是“它的”组。
|
||||||
|
let mut child = spawn_in_our_group("sleep 30");
|
||||||
|
let pid = child.id();
|
||||||
|
let _guard = KillOnDrop(pid);
|
||||||
|
assert_eq!(pgid(pid), Some(own_pgid), "同组子进程应继承我们的 pgid");
|
||||||
|
assert_eq!(
|
||||||
|
group_kill_target(pid),
|
||||||
|
None,
|
||||||
|
"非组长子进程不能走组杀(`-<pid>` 会打到别人的组)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 独立组的子进程:合法的组杀目标。
|
||||||
|
let mut grouped = spawn_grouped("sleep 30");
|
||||||
|
let grouped_pid = grouped.id();
|
||||||
|
let _grouped_guard = KillOnDrop(grouped_pid);
|
||||||
|
assert_eq!(pgid(grouped_pid), Some(grouped_pid), "子进程应是组长");
|
||||||
|
assert_eq!(
|
||||||
|
group_kill_target(grouped_pid),
|
||||||
|
Some(grouped_pid),
|
||||||
|
"独立组的组长可以组杀"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
let _ = grouped.kill();
|
||||||
|
let _ = grouped.wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_kill_reaps_grandchildren_without_touching_our_group() {
|
||||||
|
let mut child = spawn_grouped("sleep 30 & sleep 30");
|
||||||
|
let pid = child.id();
|
||||||
|
let _guard = KillOnDrop(pid);
|
||||||
|
std::thread::sleep(Duration::from_millis(300));
|
||||||
|
|
||||||
|
let children_of = || -> Vec<u32> {
|
||||||
|
std::fs::read_to_string(format!("/proc/{pid}/task/{pid}/children"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.split_whitespace()
|
||||||
|
.filter_map(|value| value.parse().ok())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let grandchildren = children_of();
|
||||||
|
assert!(
|
||||||
|
!grandchildren.is_empty(),
|
||||||
|
"shell 应当先 fork 出一个 sleep 子进程:{grandchildren:?}"
|
||||||
|
);
|
||||||
|
for grandchild in &grandchildren {
|
||||||
|
assert_eq!(pgid(*grandchild), Some(pid), "后代必须落在子进程自己的组里");
|
||||||
|
}
|
||||||
|
|
||||||
|
tree_kill::terminate(pid).expect("组杀应送达");
|
||||||
|
assert!(wait_gone(pid, Duration::from_secs(3)), "组长应被组杀带走");
|
||||||
|
for grandchild in &grandchildren {
|
||||||
|
assert!(
|
||||||
|
wait_gone(*grandchild, Duration::from_secs(3)),
|
||||||
|
"组杀应覆盖后代 {grandchild}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组杀没有溢出:我们自己还在原来的组里,而且没有被信号带走。
|
||||||
|
assert_eq!(self_pgid(), pgid(std::process::id()).expect("仍然可读"));
|
||||||
|
assert_ne!(pid, self_pgid(), "目标组和我们的组必须不同");
|
||||||
|
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminate_still_kills_a_child_that_shares_our_group() {
|
||||||
|
let own_pgid = self_pgid();
|
||||||
|
let mut child = spawn_in_our_group("sleep 30");
|
||||||
|
let pid = child.id();
|
||||||
|
let _guard = KillOnDrop(pid);
|
||||||
|
assert_eq!(pgid(pid), Some(own_pgid));
|
||||||
|
|
||||||
|
tree_kill::terminate(pid).expect("单 pid 兜底应送达");
|
||||||
|
assert!(
|
||||||
|
wait_gone(pid, Duration::from_secs(3)),
|
||||||
|
"同组子进程也必须能被单 pid TERM 干掉"
|
||||||
|
);
|
||||||
|
// 走到这里本身就说明“组杀没有命中自身进程组”——否则测试进程已被 TERM。
|
||||||
|
assert_eq!(self_pgid(), pgid(std::process::id()).expect("仍然可读"));
|
||||||
|
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
# 打包与发布工程(packaging/)
|
||||||
|
|
||||||
|
单一真相源:**单文件产物矩阵 + 发布契约**。`.github/workflows/release.yml` 按本文实现,
|
||||||
|
`packaging/make_artifacts.py` 是本地与 CI **共用**的打包脚本(同一份代码 → 同一套目录布局)。
|
||||||
|
本目录只放发布工程资产(脚本 / 说明 / 发布说明模板),不参与任何 crate 的编译。
|
||||||
|
|
||||||
|
## 1. 产物矩阵
|
||||||
|
|
||||||
|
| 槽位 | 产物文件名 | 形态 | 归档内容 |
|
||||||
|
|------|-----------|------|---------|
|
||||||
|
| `windows-x64` | `unlucky-launcher-<ver>-windows-x64.exe` | **裸 exe**(双击即用) | exe 单文件;三份合规文本作为并列 Release 资产 |
|
||||||
|
| `linux-x64` | `unlucky-launcher-<ver>-linux-x64.tar.gz` | 单二进制归档 | `unlucky-launcher` + `LICENSE` + `NOTICE` + `README.md` |
|
||||||
|
| `macos-arm64` | `unlucky-launcher-<ver>-macos-arm64.tar.gz` | 单二进制归档 | 同上 |
|
||||||
|
| `macos-x64` | `unlucky-launcher-<ver>-macos-x64.tar.gz` | 单二进制归档 | 同上 |
|
||||||
|
| 全平台 | `SHA256SUMS.txt` | 校验清单 | 每行 `<sha256>␠␠<文件名>`(`sha256sum -c` 可直吃) |
|
||||||
|
|
||||||
|
约定细节:
|
||||||
|
|
||||||
|
- **"单文件"指可执行文件本身零运行时依赖**(无 WebView、无 JRE、无 sidecar 资源目录、无 DLL 旁挂)。
|
||||||
|
归档里额外放三份合规文本是许可义务要求,不是运行时依赖;Windows 因为"一个 exe 装不下文本",
|
||||||
|
把 `LICENSE` / `NOTICE` / `README.md` 作为 Release 的并列资产分发,条款同样随下载走。
|
||||||
|
- 归档解压后是**一个顶层目录** `unlucky-launcher-<ver>-<槽位>/`,用户不会解压出一堆散文件。
|
||||||
|
- `<ver>` 不带前导 `v`(tag 是 `v0.1.0`,文件名是 `0.1.0`)——由脚本统一裁剪。
|
||||||
|
- UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644),
|
||||||
|
同源构建的 sha256 不受打包时刻影响。
|
||||||
|
|
||||||
|
## 2. 版本来源(唯一真相源)
|
||||||
|
|
||||||
|
版本号只有一个来源:**`Cargo.toml` 的 `[workspace.package] version`**。
|
||||||
|
|
||||||
|
- `<ver>` 由 `make_artifacts.py` 通过 `cargo metadata --no-deps` 读出(不是正则扒文件),
|
||||||
|
四个 workspace member 版本不一致时直接失败。
|
||||||
|
- 带 tag 构建时(CI)额外传 `--version "$TAG"`:脚本裁剪 `v` 后与 `Cargo.toml` 交叉校验,
|
||||||
|
**不一致即失败**——避免出现 tag 说 0.2.0、产物里写 0.1.0 的发布事故。
|
||||||
|
- `ull --version` 必报同一版本(脚本打包前跑一次冒烟 `ull --version` 断言版本串在其中),
|
||||||
|
这是 `env!("CARGO_PKG_VERSION")` 的实际输出,不是估算。
|
||||||
|
- 发布流程:先改 `Cargo.toml` 版本 + 提交 → `git tag v<ver>` → 推送 tag 触发流水线。
|
||||||
|
|
||||||
|
## 3. 怎么构建
|
||||||
|
|
||||||
|
### 本地(当前平台)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release --locked # 产出 target/release/{unlucky-launcher,ull}
|
||||||
|
python packaging/make_artifacts.py --out dist # 产物落在 dist/,含 SHA256SUMS.txt
|
||||||
|
python packaging/make_artifacts.py --version v0.1.0 # 顺带校验版本一致性
|
||||||
|
python packaging/make_artifacts.py --no-smoke # 跳过 ull --version 冒烟
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本按宿主自动判定槽位(Windows x64 / Linux x64 / macOS arm64 / macOS x64),
|
||||||
|
其它组合直接报错退出——不在矩阵里的平台不出"凑合能跑"的产物。
|
||||||
|
|
||||||
|
### CI(三平台矩阵)
|
||||||
|
|
||||||
|
`.github/workflows/release.yml`:
|
||||||
|
|
||||||
|
- 触发:`push` tag `v*`;`workflow_dispatch` 只构建不发布(验证矩阵仍编得过)。
|
||||||
|
`inputs.prerelease` **只在 tag ref 上 dispatch 才可达**——Actions 页面的 "Run workflow" 只能选分支,
|
||||||
|
要跑 tag 得 `gh workflow run release.yml --ref v0.1.0`。分支 ref 的 dispatch 与 push 一样被
|
||||||
|
`release` job 的 `if: github.ref_type == 'tag'` 挡在发布之外,所以「dispatch 只构建不发布」
|
||||||
|
与「dispatch 能关 pre-release」是两条互不冲突的规则。
|
||||||
|
- 矩阵:`windows-latest`(MSVC + `-C target-feature=+crt-static`)、`ubuntu-22.04`(glibc 2.35 地板)、
|
||||||
|
`macos-latest`(arm64)、`macos-15-intel`(Intel x64;`macos-13` 已下线,`macos-26-intel` 为更新替位)。
|
||||||
|
- 每个槽位 `cargo build --release --locked` → `make_artifacts.py` → 归档内容自检 → 上传 artifact。
|
||||||
|
- `release` job:下载全部 artifact → 重算一份全量 `SHA256SUMS.txt` → 用 `packaging/release-notes.md`
|
||||||
|
模板渲染支持矩阵 → `gh release create`(**pre-release 默认开**:首建时 `push` 触发按 pre-release,`workflow_dispatch` 可关)。
|
||||||
|
- **同一 tag 重跑幂等**:`gh release view` 命中已存在的 Release 时改走 `gh release upload --clobber`
|
||||||
|
+ `gh release edit`,覆盖资产与说明而不是再 `create`(`create` 遇已存在会失败,把流水线最后一步染红)。
|
||||||
|
- **pre-release 标记只按显式输入断言**:`gh release edit` 分支在默认重跑(`push` 触发的 Re-run,
|
||||||
|
`inputs.prerelease` 为空)时**不传** `--prerelease`,所以重跑**不会**翻转标记;只有 tag ref 上的
|
||||||
|
`workflow_dispatch` 显式给了输入才断言——`prerelease=true` → `--prerelease`,`prerelease=false` →
|
||||||
|
`--prerelease=false`。要改标记两条路等价且互不打架:① 走流水线
|
||||||
|
`gh workflow run release.yml --ref v0.1.0 -f prerelease=false`(会落到 edit 分支显式断言)
|
||||||
|
② 直接 `gh release edit <tag> --prerelease=false`(不经流水线;后续默认重跑不碰该标记,
|
||||||
|
不会被翻回去)。首建(`create`)仍保持「pre-release 默认开」的老契约,空输入按 `true` 处理。
|
||||||
|
|
||||||
|
Windows 为什么用 MSVC 静态 CRT 而不是本机这套 GNU 工具链:两者都属"标准工具链"(D21),
|
||||||
|
但发布产物要**不依赖 VC++ 可再发行包**,`+crt-static` 把 CRT 静态链进 exe,Win10+ 的 UCRT 本身是
|
||||||
|
系统组件,于是 exe 真正单文件可拷走。GNU 交叉产物在 CI 上还要拉 MinGW 链接器,收益为零。
|
||||||
|
|
||||||
|
## 4. 每平台运行时依赖
|
||||||
|
|
||||||
|
### Windows x64(Win10/11)
|
||||||
|
|
||||||
|
| 依赖 | 来源 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `KERNEL32` / `USER32` / `GDI32` / `ADVAPI32` / `SHELL32` / `ole32` / `WS2_32` … | 系统 | Win10+ 一律自带 |
|
||||||
|
| `ucrtbase` / `api-ms-win-crt-*`(UCRT) | 系统 | Win10+ 内置,**不需要** VC++ 可再发行包 |
|
||||||
|
| MSVC 运行时(`VCRUNTIME140` / `MSVCP140`) | **无** | `+crt-static` 静态链接,故不需要可再发行包 |
|
||||||
|
| OpenGL(`opengl32`) | 系统 | 只在 GL 渲染路径用;缺失走软渲染 |
|
||||||
|
|
||||||
|
本机(GNU 工具链)实测导入表证据见 §7。
|
||||||
|
|
||||||
|
### Linux x64
|
||||||
|
|
||||||
|
| 依赖 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| glibc ≥ 2.35 | 由 `ubuntu-22.04` 构建决定;更老的发行版请自行重建或等 musl 版 |
|
||||||
|
| `libxkbcommon.so.0` | winit 键盘处理 |
|
||||||
|
| `libfontconfig.so.1` + `libfreetype.so.6` | 文本渲染 |
|
||||||
|
| `libGL.so.1` / `libEGL.so.1` | femtovg GL 渲染;缺失走软渲染 |
|
||||||
|
| `libxcb.so.1` / `libX11.so.6` | X11 后端(Wayland 会话下也需 XWayland 相关库) |
|
||||||
|
| `libwayland-client.so.0` | Wayland 后端 |
|
||||||
|
|
||||||
|
Debian/Ubuntu 一行:`sudo apt install libxkbcommon0 fontconfig libgl1 libegl1 libxcb1 libx11-6 libwayland-client0`。
|
||||||
|
运行期**不需要** `-dev` 包、不需要 Java(Java 是启动游戏时才由启动器自己管理的东西)。
|
||||||
|
|
||||||
|
### macOS 12+
|
||||||
|
|
||||||
|
| 依赖 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 系统框架(Cocoa / Metal / IOKit / Security …) | 由操作系统提供,无第三方动态库 |
|
||||||
|
| `libSystem` | 系统 |
|
||||||
|
|
||||||
|
macOS 产物是**裸二进制**而非 `.app` bundle:符合单文件承诺,但没有 Dock 图标、Finder 双击不生效,
|
||||||
|
从终端启动(README 里给了 `xattr -d com.apple.quarantine` 的 Gatekeeper 解法)。
|
||||||
|
签名/公证(需 Apple Developer 账号)与 `.app` 打包是后续工作,不做假承诺。
|
||||||
|
|
||||||
|
## 5. 软渲染兜底(无 GPU / 老驱动 / 远程桌面)
|
||||||
|
|
||||||
|
Slint 默认 `renderer-femtovg`(GL)。两条兜底路径,效果等价,任选其一:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unlucky-launcher --software-renderer # 启动参数(本仓库解析后设 SLINT_BACKEND)
|
||||||
|
SLINT_BACKEND=winit-software unlucky-launcher # 环境变量(Slint 原生开关)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 参数解析在 `crates/app/src/main.rs` 的 `apply_renderer_flag()`,只认完全相等的 `--software-renderer`。
|
||||||
|
- 软渲染是 CPU 光栅化,无 GPU 也能出图;代价是动画与大窗口重绘更慢(Metro 纯色界面影响有限)。
|
||||||
|
- 验收/无交互桌面场景另有进程内截图通道:`--screenshot <png>`(见 `crates/app/src/screenshot.rs`),
|
||||||
|
它同样能在软渲染下工作,是 CI 与远程机唯一可靠的"真出图"证据。
|
||||||
|
- 发布冒烟(本机实跑,见 §7)就是用 `--software-renderer --screenshot` 组合证明"无 GPU 也能起来"。
|
||||||
|
|
||||||
|
## 6. Cargo 体积优化(release profile)
|
||||||
|
|
||||||
|
`Cargo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "z" # 体积优先(启动器不是算力密集;UI 走 Slint 渲染,热路径不在 Rust 侧)
|
||||||
|
lto = "fat" # 全量 LTO:跨 crate 内联+死代码消除,比 thin 再小一截(实测见 §7)
|
||||||
|
codegen-units = 1 # 单 CGU,配合 LTO 拿最大优化面
|
||||||
|
panic = "abort" # 去掉 unwind 表
|
||||||
|
strip = "symbols" # 剥符号表(GNU/MSVC 路径都生效)
|
||||||
|
```
|
||||||
|
|
||||||
|
取舍:`lto = "fat"` + `codegen-units = 1` 让 release 链接明显变慢(本机全量 release 构建 10 分钟量级,
|
||||||
|
见 §7.1),换来的是体积与启动时间。变慢的根源在 `codegen-units = 1`:整个 crate 只切出一个 CGU,
|
||||||
|
LTO 也就只有一个线程可跑(`fat` 本身只管"跨 crate 全量参与",不等于单线程)。
|
||||||
|
本地开发迭代走 `cargo build`(dev profile)不受影响,但 **`ci.yml` 每次 push/PR 都跑
|
||||||
|
`cargo build --workspace --release`**,会实打实吃这份 fat LTO 的链接时间——CI 慢的一部分是 release
|
||||||
|
档构建,不是测试本身。
|
||||||
|
`panic = "abort"` 意味着 panic 不带 backtrace 栈展开——UI 层已用 `Result` 承接错误,
|
||||||
|
`unwrap` 只出现在测试里,这是可接受的取舍。
|
||||||
|
|
||||||
|
## 7. 实测(本机,不是估算)
|
||||||
|
|
||||||
|
构建环境:rustc 1.99.1 stable(host `x86_64-pc-windows-gnu`,MinGW 链接器),
|
||||||
|
`cargo build --release --locked --bin unlucky-launcher --bin ull`,12 核机器(同一时刻另有别的 worker
|
||||||
|
在做全量 debug 构建,属于偏悲观的真实负载)。产物:`target/release/unlucky-launcher.exe`(GUI,交付物)
|
||||||
|
与 `target/release/ull.exe`(CLI)。
|
||||||
|
|
||||||
|
### 7.1 体积(实测字节数,不是估算)
|
||||||
|
|
||||||
|
| release profile | `unlucky-launcher.exe` | `ull.exe` | 冷构建耗时(本机 12 核) |
|
||||||
|
|-----------------|-----------------------|-----------|-----------|
|
||||||
|
| `lto = "thin"`(改前) | 16,366,080 B = 15.61 MiB | 7,572,992 B = 7.22 MiB | 11m52s |
|
||||||
|
| `lto = "fat"`(改后,本仓库采用) | **14,595,584 B = 13.92 MiB** | **6,157,824 B = 5.87 MiB** | 9m27s(增量,依赖已编好) |
|
||||||
|
|
||||||
|
`fat` 相对 `thin` 的实测差:GUI **−1,770,496 B(−10.8%)**,CLI **−1,415,168 B(−18.7%)**。
|
||||||
|
一次全量 LTO 把跨 crate 的死代码与重复实例化一起消掉,收益远超常见印象里的"1~2%";
|
||||||
|
代价是 release 链接更慢——慢在 `codegen-units = 1`(单 CGU → LTO 单线程),不是 `fat` 本身,
|
||||||
|
且只影响发布/验收构建,日常 `cargo build` 走 dev profile 不受影响。
|
||||||
|
|
||||||
|
体积构成(按依赖树判断):Slint 运行时(femtovg + software 双渲染器 + winit/accesskit/托盘)+ rustls/reqwest
|
||||||
|
+ rig/rmcp/axum 是主要来源;`ull` 明显更小是 D18 纪律的直接结果(CLI 零 UI 框架依赖)。
|
||||||
|
|
||||||
|
### 7.2 冒烟(真跑,2026-10-31 夜间 + 11-01 早,本机 Win11 26100)
|
||||||
|
|
||||||
|
| 命令 | 实测结果 |
|
||||||
|
|------|---------|
|
||||||
|
| `ull.exe --version` | 输出 `ull 0.1.0(unlucky-cli)` + `os: Windows 11 · 10.0.26100(x86_64)· 满足最低要求`,exit 0 —— 版本与 workspace `0.1.0` 一致 |
|
||||||
|
| `unlucky-launcher.exe --software-renderer --screenshot … --data-dir …` | exit 0,产出 1180×760 PNG(3,588,298 B);看图确认 Metro 实例页(侧栏/搜索/空态/AI 助手面板)正常渲染 → **软渲染路径真的能出图**,不依赖 GL |
|
||||||
|
| `unlucky-launcher.exe --version` | **无输出**,8 秒后被 timeout 杀掉(exit 124)——GUI 目前不解析 `--version`,会直接开窗口。已作为 finding 上报,见 §10-1 |
|
||||||
|
|
||||||
|
### 7.3 打包验证(本机 Windows 槽位真跑)
|
||||||
|
|
||||||
|
```
|
||||||
|
smoke: ull.exe --version -> 0.1.0 (ok)
|
||||||
|
artifact: unlucky-launcher-0.1.0-windows-x64.exe 13.92 MiB sha256=d4954c62…c3570
|
||||||
|
sums: SHA256SUMS.txt
|
||||||
|
$ sha256sum -c SHA256SUMS.txt
|
||||||
|
unlucky-launcher-0.1.0-windows-x64.exe: OK
|
||||||
|
```
|
||||||
|
|
||||||
|
版本守卫也实测过:`--version v9.9.9` 时脚本以 `tag/version mismatch: requested 9.9.9, Cargo.toml says 0.1.0` 失败退出(exit 1)。
|
||||||
|
|
||||||
|
UNIX 归档的可复现性单独取证(本机 Windows 槽位只走裸 exe 分支,tar 路径要另测):
|
||||||
|
reviewer-packaging 在 M46 review 里连续调 `stage_archive()` 两次 → **两次 sha256 完全一致**
|
||||||
|
`a99d6e9e727cd7e390434c566a15978451f340858fa89836e2f63790bbc392f8`;成员 `mtime=0`、
|
||||||
|
`uid=gid=0`、`uname/gname` 为空、`mode` 0755(二进制)/ 0644(文本),gzip header 的 mtime 字段是
|
||||||
|
`00000000`,归档内单顶层目录且含 `LICENSE`/`NOTICE`/`README.md`。
|
||||||
|
即"同源构建 → 同 sha256"在 tar 路径上成立,不随打包时刻漂移(见 §1 的约定细节)。
|
||||||
|
|
||||||
|
### 7.4 运行时依赖证据(PE 导入表)
|
||||||
|
|
||||||
|
`objdump -p target/release/unlucky-launcher.exe` 共 42 条 DLL 导入记录、33 个不同名字(大小写各记一次),全部是 Windows 自带:
|
||||||
|
`KERNEL32/USER32/GDI32/ADVAPI32/SHELL32/SHLWAPI/ole32/oleaut32/combase/comctl32/dwmapi/dwrite/imm32/
|
||||||
|
uxtheme/uiautomationcore/opengl32/ws2_32/bcrypt/bcryptprimitives/ntdll` + 一组 `api-ms-win-crt-*`(UCRT API set)。
|
||||||
|
|
||||||
|
**没有** `VCRUNTIME140.dll` / `MSVCP140.dll` 这类 MSVC 可再发行包依赖,也没有 `libwinpthread-1.dll` /
|
||||||
|
`libgcc_s_*.dll` 之类 MinGW 运行时 —— 即"拷走一个 exe 就能跑"(Win10+ 自带 UCRT)。
|
||||||
|
CI 的 MSVC `+crt-static` 产物同理(静态 CRT);`release.yml` 里用 `dumpbin -dependents` 把这条变成硬门禁,
|
||||||
|
两侧都 fail、没有降级分支:**出现 VCRUNTIME/MSVCP/ucrtbase 就 fail**,**找不到 dumpbin 同样 fail**
|
||||||
|
(`windows-latest` 自带 VS 工具链,基本找得到;"找不到就跳过"等于让门禁在最需要它的时候失效)。
|
||||||
|
总之不允许"悄悄要用户装可再发行包"。
|
||||||
|
注意:本机只装了 GNU 工具链,MSVC 那个槽位的产物本身**没有在本机验证过**,由 CI 首跑验证。
|
||||||
|
|
||||||
|
## 8. 发行合规
|
||||||
|
|
||||||
|
- `LICENSE`(Apache-2.0 全文)与 `NOTICE`(含 Slint Royalty-Free 2.0 义务说明)随包分发,
|
||||||
|
三份文本在 UNIX 归档内、在 Windows 侧作为并列 Release 资产。
|
||||||
|
- Slint 的 RF 2.0 义务:关于页保留 `AboutSlint` 部件(已在 UI 内),不得移除;
|
||||||
|
选 RF 分支即不触发 GPL 分支义务(见 `deny.toml` 的许可证白名单注释)。
|
||||||
|
- `README.md`(英文主版)随包分发;中文版 `README.zh-CN.md` 留在仓库不进包(避免包体噪音)。
|
||||||
|
- 依赖许可门禁不变:`deny.toml` 白名单(Apache-2.0 / MIT / BSD / Zlib / BSL-1.0 / ISC / CC0 / Unlicense /
|
||||||
|
Unicode-3.0 / CDLA-Permissive-2.0 / Slint-RF-2.0)。发布任务不新增任何依赖。
|
||||||
|
- 发布产物不含游戏本体、不含 JRE、不含任何账号数据;`ull`/GUI 的数据目录在用户侧生成。
|
||||||
|
|
||||||
|
## 9. 支持矩阵(对外口径,与 release-notes 模板逐行一致)
|
||||||
|
|
||||||
|
| 平台 | 级别 | 运行时要求 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| Windows 10/11 x64 | 一等 | 无(UCRT 系统自带) |
|
||||||
|
| macOS 12+(arm64 / x64) | 一等 | 无第三方库;未签名需去 quarantine |
|
||||||
|
| Linux x64 X11 | 一等 | glibc 2.35+、xkbcommon、fontconfig、libGL、xcb |
|
||||||
|
| Linux x64 Wayland | 支持 | 上述 + wayland-client |
|
||||||
|
| Windows 8.1/8/7 | 不支持 | 基线已上调(D21) |
|
||||||
|
| Android Termux + X11 | 试验级(不承诺) | 仅 CLI(源码自建),GUI 未验证,不在本发布矩阵内 |
|
||||||
|
|
||||||
|
`packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版,Termux 行同样标注
|
||||||
|
experimental / not part of this release matrix)——改一边必须同步另一边,否则标题里的"一致"就是假话。
|
||||||
|
|
||||||
|
## 10. 已知边界 / 后续
|
||||||
|
|
||||||
|
1. **GUI 无 `--version`**:`unlucky-launcher.exe` 目前只解析 `--software-renderer` / `--screenshot*` /
|
||||||
|
`--data-dir` / `--mock`,`--version` 会当成普通参数忽略并直接开窗口。版本自证目前靠:
|
||||||
|
①产物名里的 `<ver>` ②`ull --version`(同 workspace 版本)③tag 交叉校验。
|
||||||
|
加一个 `--version` 分支属于 `crates/app` 范围,已作为 finding 上报,不在本 mission 内改。
|
||||||
|
2. **签名/公证**:Windows 无 Authenticode、macOS 无 codesign/notarize(都要证书/开发者账号),
|
||||||
|
当前以 SHA256SUMS + Release 说明作为完整性参考。
|
||||||
|
3. **macOS `.app` bundle / `.dmg`**:与单文件承诺冲突,暂不做;若用户要 Dock 图标再单开 mission。
|
||||||
|
4. **Linux musl 静态版**:可彻底摆脱 glibc 地板,但 Slint 在 musl 上需额外验证,未纳入本矩阵。
|
||||||
|
5. **`ull` CLI 未随包**:本次矩阵只发 GUI 单文件(用户核心卖点是启动器本体);headless 用户可从
|
||||||
|
源码 `cargo build --release --bin ull`,或在后续把 `ull` 加进同一矩阵(脚本已按槽位参数化,改动很小)。
|
||||||
|
6. **本机 GNU 链接器的已知无害告警**:`cargo build --release`(GNU 工具链)会打一行
|
||||||
|
`warning: linker stderr: corrupt .drectve at end of def file`,来自 MinGW `ld` 对 `.drectve` 段的
|
||||||
|
老式抱怨,不影响产物(体积/导入表/冒烟均已实测正常);CI 的 MSVC 槽位不会出现这条。
|
||||||
|
|
||||||
|
## 11. 文件清单
|
||||||
|
|
||||||
|
| 文件 | 作用 | 行数上限 |
|
||||||
|
|------|------|---------|
|
||||||
|
| `packaging/README.md` | 本文:矩阵/依赖/版本/合规契约 | ≤400 |
|
||||||
|
| `packaging/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤400 |
|
||||||
|
| `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 |
|
||||||
|
| `.github/workflows/release.yml` | tag 触发 → 矩阵构建 → Release | ≤400 |
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the single-file release artifacts for the current platform.
|
||||||
|
|
||||||
|
Release matrix (see `packaging/README.md` for the full contract):
|
||||||
|
|
||||||
|
Windows x64 unlucky-launcher-<ver>-windows-x64.exe (bare exe, no zip)
|
||||||
|
Linux x64 unlucky-launcher-<ver>-linux-x64.tar.gz
|
||||||
|
macOS arm64 unlucky-launcher-<ver>-macos-arm64.tar.gz
|
||||||
|
macOS x64 unlucky-launcher-<ver>-macos-x64.tar.gz
|
||||||
|
all SHA256SUMS.txt
|
||||||
|
|
||||||
|
`--version` is taken from the cargo workspace so a tag can never drift away from
|
||||||
|
`Cargo.toml`; pass `--version` to cross-check a tag against it (mismatch is fatal).
|
||||||
|
The archives carry the launcher binary plus LICENSE / NOTICE / README.md, which is
|
||||||
|
what the license obligations ask for; Windows ships the bare `.exe` (the single-file
|
||||||
|
selling point) and the same three documents are attached to the Release next to it.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cargo build --release --locked
|
||||||
|
python3 packaging/make_artifacts.py [--version v0.1.0] [--out dist] [--no-smoke]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import NoReturn
|
||||||
|
|
||||||
|
LAUNCHER_BIN = "unlucky-launcher"
|
||||||
|
CLI_BIN = "ull"
|
||||||
|
# 随包分发的合规文本(仓库根目录);英文主版 README 是对外分发口径。
|
||||||
|
COMPLIANCE_DOCS = ("LICENSE", "NOTICE", "README.md")
|
||||||
|
SUMS_NAME = "SHA256SUMS.txt"
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
TARGET_DIR = REPO_ROOT / "target" / "release"
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> NoReturn:
|
||||||
|
print(f"error: {message}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def workspace_version() -> str:
|
||||||
|
"""Read `[workspace.package] version` through cargo itself (single source of truth)."""
|
||||||
|
try:
|
||||||
|
raw = subprocess.run(
|
||||||
|
["cargo", "metadata", "--no-deps", "--format-version", "1"],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
# Windows 默认 GBK:cargo 的 package description 是中文,不显式指定会解码失败。
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
).stdout
|
||||||
|
except (OSError, subprocess.CalledProcessError) as exc:
|
||||||
|
fail(f"cargo metadata failed: {exc}")
|
||||||
|
packages = json.loads(raw)["packages"]
|
||||||
|
versions = {pkg["version"] for pkg in packages}
|
||||||
|
if len(versions) != 1:
|
||||||
|
fail(f"workspace members disagree on version: {sorted(versions)}")
|
||||||
|
return versions.pop()
|
||||||
|
|
||||||
|
|
||||||
|
def platform_tag() -> str:
|
||||||
|
"""Map the build host to the artifact matrix slot (hard-fail on unsupported ones)."""
|
||||||
|
os_name = sys.platform
|
||||||
|
machine = platform.machine().lower()
|
||||||
|
if os_name.startswith("win") and machine in ("amd64", "x86_64"):
|
||||||
|
return "windows-x64"
|
||||||
|
if os_name.startswith("linux") and machine in ("x86_64", "amd64"):
|
||||||
|
return "linux-x64"
|
||||||
|
if os_name == "darwin" and machine in ("arm64", "aarch64"):
|
||||||
|
return "macos-arm64"
|
||||||
|
if os_name == "darwin" and machine in ("x86_64", "amd64"):
|
||||||
|
return "macos-x64"
|
||||||
|
fail(f"unsupported build host {os_name}/{machine}; matrix is windows-x64 / linux-x64 / macos-arm64 / macos-x64")
|
||||||
|
|
||||||
|
|
||||||
|
def binary(name: str) -> Path:
|
||||||
|
path = TARGET_DIR / (f"{name}.exe" if sys.platform.startswith("win") else name)
|
||||||
|
if not path.is_file():
|
||||||
|
fail(f"missing {path}; run `cargo build --release --locked` first")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def smoke_version(cli: Path, version: str) -> None:
|
||||||
|
"""`ull --version` must report the workspace version (guards tag/version drift)."""
|
||||||
|
proc = subprocess.run(
|
||||||
|
[str(cli), "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
out = (proc.stdout or "") + (proc.stderr or "")
|
||||||
|
if proc.returncode != 0:
|
||||||
|
fail(f"`{cli.name} --version` exited {proc.returncode}: {out.strip()}")
|
||||||
|
if version not in out:
|
||||||
|
fail(f"`{cli.name} --version` does not report {version}: {out.strip()}")
|
||||||
|
print(f"smoke: {cli.name} --version -> {version} (ok)")
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def write_sums(out_dir: Path, assets: list[Path]) -> Path:
|
||||||
|
sums = out_dir / SUMS_NAME
|
||||||
|
lines = [f"{sha256(path)} {path.name}" for path in sorted(assets)]
|
||||||
|
sums.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
|
||||||
|
return sums
|
||||||
|
|
||||||
|
|
||||||
|
def stage_archive(out_dir: Path, tag: str, version: str, launcher: Path) -> Path:
|
||||||
|
"""tar.gz with one top-level dir holding the binary + compliance docs."""
|
||||||
|
stem = f"unlucky-launcher-{version}-{tag}"
|
||||||
|
stage = out_dir / stem
|
||||||
|
if stage.exists():
|
||||||
|
shutil.rmtree(stage)
|
||||||
|
stage.mkdir(parents=True)
|
||||||
|
shutil.copy2(launcher, stage / launcher.name)
|
||||||
|
for doc in COMPLIANCE_DOCS:
|
||||||
|
source = REPO_ROOT / doc
|
||||||
|
if not source.is_file():
|
||||||
|
fail(f"compliance file {source} is missing")
|
||||||
|
shutil.copy2(source, stage / doc)
|
||||||
|
# tar 内的 mtime/uid/mode 与 gzip 头的 mtime 全部归零 → 同源构建的 sha256 与打包时刻无关。
|
||||||
|
archive = out_dir / f"{stem}.tar.gz"
|
||||||
|
with archive.open("wb") as raw, gzip.GzipFile(
|
||||||
|
fileobj=raw, mode="wb", compresslevel=9, mtime=0
|
||||||
|
) as gz, tarfile.open(fileobj=gz, mode="w") as tar:
|
||||||
|
for entry in sorted(stage.iterdir()):
|
||||||
|
info = tar.gettarinfo(str(entry), arcname=f"{stem}/{entry.name}")
|
||||||
|
info.mtime = 0
|
||||||
|
info.uid = info.gid = 0
|
||||||
|
info.uname = info.gname = ""
|
||||||
|
info.mode = 0o755 if entry.name == launcher.name else 0o644
|
||||||
|
with entry.open("rb") as handle:
|
||||||
|
tar.addfile(info, handle)
|
||||||
|
shutil.rmtree(stage)
|
||||||
|
return archive
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--version", help="expected version (a leading 'v' is stripped); cross-checks Cargo.toml")
|
||||||
|
parser.add_argument("--out", default="dist", help="output directory (default: dist/)")
|
||||||
|
parser.add_argument("--no-smoke", action="store_true", help="skip the `ull --version` smoke run")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
version = workspace_version()
|
||||||
|
if args.version:
|
||||||
|
expected = args.version[1:] if args.version.startswith("v") else args.version
|
||||||
|
if expected != version:
|
||||||
|
fail(f"tag/version mismatch: requested {expected}, Cargo.toml says {version}")
|
||||||
|
tag = platform_tag()
|
||||||
|
launcher = binary(LAUNCHER_BIN)
|
||||||
|
cli = binary(CLI_BIN)
|
||||||
|
if not args.no_smoke:
|
||||||
|
smoke_version(cli, version)
|
||||||
|
|
||||||
|
out_dir = Path(args.out)
|
||||||
|
if not out_dir.is_absolute():
|
||||||
|
out_dir = REPO_ROOT / out_dir
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if tag == "windows-x64":
|
||||||
|
asset = out_dir / f"unlucky-launcher-{version}-{tag}.exe"
|
||||||
|
shutil.copy2(launcher, asset)
|
||||||
|
else:
|
||||||
|
asset = stage_archive(out_dir, tag, version, launcher)
|
||||||
|
|
||||||
|
sums = write_sums(out_dir, [asset])
|
||||||
|
size_mb = asset.stat().st_size / (1024 * 1024)
|
||||||
|
print(f"artifact: {asset.name} {size_mb:.2f} MiB sha256={sha256(asset)}")
|
||||||
|
print(f"sums: {sums.name}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Unlucky Launcher {{VERSION}}
|
||||||
|
|
||||||
|
Rust-native **single-file** Minecraft launcher: Slint + Metro UI, built-in AI agent
|
||||||
|
(chat / config edits / crash diagnosis) and an MCP server other agent tools can attach to.
|
||||||
|
No WebView, no bundled runtime, no JRE download needed to start the launcher itself.
|
||||||
|
|
||||||
|
## Downloads
|
||||||
|
|
||||||
|
| Platform | Asset | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Windows 10/11 x64 | `unlucky-launcher-{{VERSION}}-windows-x64.exe` | single exe, double-click to run |
|
||||||
|
| Linux x64 (X11/Wayland) | `unlucky-launcher-{{VERSION}}-linux-x64.tar.gz` | single binary inside, no installer |
|
||||||
|
| macOS 12+ Apple Silicon | `unlucky-launcher-{{VERSION}}-macos-arm64.tar.gz` | single binary inside, no installer |
|
||||||
|
| macOS 12+ Intel | `unlucky-launcher-{{VERSION}}-macos-x64.tar.gz` | single binary inside, no installer |
|
||||||
|
|
||||||
|
`LICENSE`, `NOTICE` and `README.md` ship in every archive; on Windows they are attached to
|
||||||
|
this release next to the `.exe` (a lone exe cannot carry them), so the license terms travel
|
||||||
|
with the download on every platform.
|
||||||
|
|
||||||
|
## Support matrix
|
||||||
|
|
||||||
|
| Platform | Level | Runtime requirements |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Windows 10/11 x64 | first class | nothing beyond the OS (UCRT is part of Win10+) |
|
||||||
|
| macOS 12+ (arm64 / x64) | first class | nothing; unsigned build, see Gatekeeper note |
|
||||||
|
| Linux x64, X11 | first class | `libxkbcommon`, `fontconfig`, `libGL` (or software renderer), `libxcb` |
|
||||||
|
| Linux x64, Wayland | supported | `wayland-client`, `libxkbcommon`, plus the X11 set for XWayland apps |
|
||||||
|
| Windows 8.1/8/7 | unsupported | baseline was raised to Win10 (decision D21) |
|
||||||
|
| Android — Termux + X11 | experimental, not promised | CLI only (build from source), GUI unverified; not part of this release matrix |
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
**Windows** — put the `.exe` anywhere and run it. Portable data lives next to the exe when a
|
||||||
|
`portable.marker` file is present, otherwise under `%APPDATA%`.
|
||||||
|
|
||||||
|
**Linux**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -xzf unlucky-launcher-{{VERSION}}-linux-x64.tar.gz
|
||||||
|
cd unlucky-launcher-{{VERSION}}-linux-x64
|
||||||
|
chmod +x unlucky-launcher && ./unlucky-launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -xzf unlucky-launcher-{{VERSION}}-macos-arm64.tar.gz
|
||||||
|
cd unlucky-launcher-{{VERSION}}-macos-arm64
|
||||||
|
xattr -d com.apple.quarantine unlucky-launcher # unsigned build, Gatekeeper quarantine
|
||||||
|
chmod +x unlucky-launcher && ./unlucky-launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
## No GPU? Software renderer
|
||||||
|
|
||||||
|
The UI prefers OpenGL (femtovg) and falls back to Slint's CPU renderer when no usable GL
|
||||||
|
driver is present. Force it on any machine with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unlucky-launcher --software-renderer # or: SLINT_BACKEND=winit-software unlucky-launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify the download
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux / macOS
|
||||||
|
sha256sum -c SHA256SUMS.txt --ignore-missing
|
||||||
|
# Windows (PowerShell)
|
||||||
|
Get-FileHash .\unlucky-launcher-{{VERSION}}-windows-x64.exe -Algorithm SHA256
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checksums
|
||||||
|
|
||||||
|
```
|
||||||
|
{{SUMS}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known limitations of this build
|
||||||
|
|
||||||
|
- Microsoft (MSA) account login depends on a Mojang app-id whitelist that is still pending;
|
||||||
|
until it is granted, use offline accounts or authlib-injector (the 403 is reported with a
|
||||||
|
readable diagnostic instead of a silent failure).
|
||||||
|
- Binaries are not code-signed / notarized yet: Windows SmartScreen and macOS Gatekeeper warn
|
||||||
|
on first launch. Checksums above are the integrity reference for now.
|
||||||
|
- macOS ships the bare binary (single-file promise), not a `.app` bundle: no Dock icon or
|
||||||
|
Finder double-click — launch it from a terminal.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache-2.0. Third-party notices (including Slint's Royalty-Free 2.0 track) are in `NOTICE`.
|
||||||
|
Unlucky Launcher is not affiliated with Mojang or Microsoft; it does not ship game files.
|
||||||
|
|
||||||
|
Made with [Slint](https://slint.dev) — Slint Royalty-Free 2.0 track, unmodified.
|
||||||
Reference in new issue
Block a user