release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
This commit is contained in:
commit
a5d1dec495
1580 files changed
+320938
No files matched your search
@@ -0,0 +1,50 @@
|
||||
[package]
|
||||
name = "unlucky-presenter"
|
||||
description = "Unlucky-Launcher 的 UI 框架无关宿主层:DTO / 状态 reducer / host 业务装配,Slint 版与 iced 版共用"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
# 零 UI 框架依赖是硬约束(M85 / D26):本 crate 是 Slint 版(`crates/app`)与 iced 版
|
||||
# (`crates/app-iced`)共用的宿主层,任何时候都不得出现 Slint / iced / winit / gtk / egui 等
|
||||
# UI 或渲染框架,也不得出现「把窗口属性灌进去」这种绑定代码。
|
||||
# 证据命令写在 `crates/app/Cargo.toml` 的注释里(那个文件允许出现框架名,本 crate 的守门
|
||||
# 命令本身在自家 grep 范围内会把自己抓出来,所以那两条命令刻意不放在这里)。
|
||||
[dependencies]
|
||||
# 跨边界 DTO 必须可序列化(AGENTS §6):事件与快照是 IPC 契约,序列化能力由 derive 钉住。
|
||||
serde.workspace = true
|
||||
# host 层要读 core 设置/事件里的 JSON 值。
|
||||
serde_json.workspace = true
|
||||
|
||||
# 边界(01c §5 / D3):presenter 只经 DTO 消费 core,**不 import core 的领域类型到 DTO 里**。
|
||||
# host 是 core 的快照/事件流与本 crate DTO 之间的唯一翻译层,所以它需要 core。
|
||||
unlucky-core = { path = "../core", version = "0.2.4" }
|
||||
|
||||
# M251(D54 第 1/4 条):截图缩略图在**后台线程**里解码,解码能力只能来自 palette-extract
|
||||
# (`image` 一个字节都不进 presenter,否则「解码集中在一处」这条边界当场作废)。
|
||||
# 方向只能是 presenter → palette-extract(反向不许);那边也不许反过来依赖 core/presenter。
|
||||
unlucky-palette-extract = { path = "../palette-extract", version = "0.2.4" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# M169:转发 core 的测试通道(默认关)。GUI 侧「更新」这条链的**人工验收**需要它:
|
||||
# `cargo run -p unlucky-app-iced --features update-test-source` 配
|
||||
# `ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1` 才能让设置页指向本地更新源
|
||||
# (`-p` 认 package 名 `unlucky-app-iced`;`unlucky-launcher-iced` 是 bin 名,写 `-p` 会找不到包)。
|
||||
# 官方发布构建不带这个 feature(`packaging/check_test_source_off.py` 守门)。
|
||||
update-test-source = ["unlucky-core/update-test-source"]
|
||||
|
||||
[dev-dependencies]
|
||||
# M190:presenter 的**更新链路用例**要构造「本构建认得」的签名清单(真发布必须带
|
||||
# `manifest.json.sig`,没有它 core 一律拒装)。这里打开 core 的 `update-test-key`:它**只**把一把
|
||||
# **公开**的测试公钥并进信任表,不开任何数据源开关(与 `update-test-source` 是两件事)。
|
||||
# 只出现在 `[dev-dependencies]`:库/发布构建拿不到它。守门:`packaging/check_test_key_off.py`。
|
||||
unlucky-core = { path = "../core", features = ["update-test-key"] }
|
||||
# 夹具用同一套种子现签清单(与 `crates/core/src/update/tests/support_sign.rs` 同源)。
|
||||
ed25519-dalek.workspace = true
|
||||
@@ -0,0 +1,311 @@
|
||||
//! AI 助手(D16 双形态)的 presenter 门面:视图模型 + 提交/确认 + 裁决审计。
|
||||
//!
|
||||
//! # 1. 这一层为什么单独存在
|
||||
//!
|
||||
//! UI 壳(右侧常驻栏 = AGENTRAIL)只做两件事:读 [`AgentRail`] 画、把点击翻成这五个方法。
|
||||
//! 会话怎么攒、未读怎么算、YOLO 怎么裁决、审计记什么,全在这里与
|
||||
//! [`crate::state::AppState::apply_agent`],壳里不重复实现——否则两形态(栏 / 页)会各写一套
|
||||
//! 而漂移。
|
||||
//!
|
||||
//! ```text
|
||||
//! UI 壳 ──agent_submit/agent_confirm/agent_mark_read──► Presenter ──AgentTransport──► 后端
|
||||
//! ▲ │
|
||||
//! └────────── agent_rail()(只读视图模型)◄──────────────┘ events → AppState::apply
|
||||
//! ```
|
||||
//!
|
||||
//! # 2. 与 UI 壳的契约(签名已定,改名要先报 tower)
|
||||
//!
|
||||
//! ```text
|
||||
//! Presenter::agent_rail(&self) -> &AgentRail
|
||||
//! Presenter::agent_submit(&mut self, text: String) -> Result<(), String>
|
||||
//! Presenter::agent_mark_read(&mut self)
|
||||
//! Presenter::agent_confirm(&mut self, accept: bool)
|
||||
//! ```
|
||||
//!
|
||||
//! # 3. 铁律落点(AGENTS §6)
|
||||
//!
|
||||
//! - **AI 永不接触账户凭据**:事件里没有凭据字段;文本一律过 [`rail::scrub_text`](第二道防线)。
|
||||
//! - **写配置受 schema 白名单约束**:白名单在 agent 侧工具层,presenter 只负责「把确认问出来」。
|
||||
//! - **敏感键分级确认,YOLO 只跳确认且审计照记**:[`Presenter::agent_confirm`] 在 YOLO 下
|
||||
//! 一律放行,但**照样**记一条 [`AuditEntry`](`yolo = true, confirmed = false`)——不静默跳过。
|
||||
//!
|
||||
//! # 4. 没做的事(P1 再接)
|
||||
//!
|
||||
//! - 真实后端:本模块只认 [`AgentTransport`] 端口,`rig` 那侧由 host 装配时实现;
|
||||
//! - 落盘审计:agent 侧 `brain::exec` → `audit` 才是权威记录,这里的 [`AuditEntry`] 只是
|
||||
//! 本进程内、给 UI 看的裁决镜像(`presenter.audit()`);
|
||||
//! - 流式时序:端口是同步返回事件分片的(便于单测),真实实现是异步推流。
|
||||
|
||||
use crate::dto::{AppEvent, Sensitivity};
|
||||
use crate::prefs::UiPrefs;
|
||||
use crate::router::Route;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub mod mock;
|
||||
pub mod rail;
|
||||
|
||||
use rail::scrub_text;
|
||||
pub use rail::AgentRail;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
#[path = "agent/tests_red_line.rs"]
|
||||
mod tests_red_line;
|
||||
|
||||
/// 裁决记录上限(只留最近若干条;权威审计不在这里)。
|
||||
pub const MAX_AUDIT_ENTRIES: usize = 256;
|
||||
|
||||
/// 一次确认的结局。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConfirmVerdict {
|
||||
/// 放行(用户点了允许,或 YOLO 自动放行)
|
||||
Approved,
|
||||
/// 拒绝(工具调用会带「用户拒绝」回到模型)
|
||||
Denied,
|
||||
}
|
||||
|
||||
impl ConfirmVerdict {
|
||||
/// 是否放行。
|
||||
#[must_use]
|
||||
pub fn is_approved(self) -> bool {
|
||||
matches!(self, Self::Approved)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条确认裁决的审计记录。
|
||||
///
|
||||
/// `yolo` 与 `confirmed` 必须分开记:`yolo = true, confirmed = false`(自动放行)与
|
||||
/// `yolo = false, confirmed = true`(用户亲手点的允许)在事故复盘里是完全不同的两件事。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuditEntry {
|
||||
/// 请求 id(与 `AgentEvent::ConfirmRequested` 同一个)
|
||||
pub request_id: String,
|
||||
/// 工具名
|
||||
pub tool: String,
|
||||
/// 敏感等级
|
||||
pub sensitivity: Sensitivity,
|
||||
/// 结局
|
||||
pub verdict: ConfirmVerdict,
|
||||
/// 是否运行在 YOLO 模式
|
||||
pub yolo: bool,
|
||||
/// 是否经过用户确认(YOLO 下恒为 false)
|
||||
pub confirmed: bool,
|
||||
}
|
||||
|
||||
/// agent 后端端口:presenter 不认 `rig` / 异步运行时的具体类型(依赖方向见 crate 文档)。
|
||||
///
|
||||
/// 方法同步返回事件分片是刻意的:真实后端在后台线程里跑,事件经
|
||||
/// [`crate::dto::Backend::drain_events`] 回流;端口这层只表达「一轮输入会产生哪些事件」,
|
||||
/// 这样单测可以不碰定时器与网络。
|
||||
pub trait AgentTransport {
|
||||
/// 用户提交一句话。
|
||||
///
|
||||
/// `history` 是当前 rail 的只读快照(真实后端要靠它拼上下文,mock 靠它算消息下标)。
|
||||
/// 返回的事件分片由 [`Presenter`] 顺序 `apply`;`Err` 表示这一轮根本没起来(不产生事件)。
|
||||
fn submit(&mut self, text: &str, history: &AgentRail) -> Result<Vec<AppEvent>, String>;
|
||||
|
||||
/// 用户对写操作做了决定(`accept = false` 表示拒绝)。
|
||||
fn confirm(&mut self, request_id: &str, accept: bool) -> Vec<AppEvent>;
|
||||
}
|
||||
|
||||
/// AI 助手的 presenter 门面:持有一份 [`AppState`] 与一个后端端口。
|
||||
///
|
||||
/// 事件仍走 [`AppState::apply`](唯一状态变更入口)——[`Presenter::apply`] 只是转发,
|
||||
/// 不在这一层另做投影。
|
||||
pub struct Presenter {
|
||||
state: AppState,
|
||||
transport: Box<dyn AgentTransport>,
|
||||
audit: Vec<AuditEntry>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Presenter {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("Presenter")
|
||||
.field("rail", self.agent_rail())
|
||||
.field("audit_len", &self.audit.len())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Presenter {
|
||||
/// 空状态 + 指定后端端口。
|
||||
#[must_use]
|
||||
pub fn new(transport: Box<dyn AgentTransport>) -> Self {
|
||||
Self::with_state(AppState::new(), transport)
|
||||
}
|
||||
|
||||
/// 接管一份已有状态(UI 壳先建 `AppState` 再交给 presenter 时用)。
|
||||
#[must_use]
|
||||
pub fn with_state(state: AppState, transport: Box<dyn AgentTransport>) -> Self {
|
||||
Self {
|
||||
state,
|
||||
transport,
|
||||
audit: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 只读状态(UI 画其它页面用)。
|
||||
#[must_use]
|
||||
pub fn state(&self) -> &AppState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// 可写状态——**只在测试构建里存在**。
|
||||
///
|
||||
/// 非测试代码没有这个后门:`AppState::ai` 的字段全是 pub,返回 `&mut AppState` 就等于
|
||||
/// 让调用方绕开 [`AgentRail::ingest`] 的脱敏与 `busy` 不变量。需要写状态时走下面这些
|
||||
/// 窄入口(prefs / 导航 / rail 的那几个字段),rail 的内容一律走四个契约方法。
|
||||
#[cfg(test)]
|
||||
pub fn state_mut(&mut self) -> &mut AppState {
|
||||
&mut self.state
|
||||
}
|
||||
|
||||
/// 导航(一级页替换、子页压栈;去 AI 独立页会展开栏并清未读)。
|
||||
pub fn navigate(&mut self, route: Route) {
|
||||
self.state.navigate(route);
|
||||
}
|
||||
|
||||
/// 回退一层(子页面);已在根页面时返回 `false`。
|
||||
pub fn back(&mut self) -> bool {
|
||||
self.state.back()
|
||||
}
|
||||
|
||||
/// 折叠/展开 AI 栏(独立页形态下等价于收回侧栏;展开时清未读)。
|
||||
pub fn toggle_ai(&mut self) {
|
||||
self.state.toggle_ai();
|
||||
}
|
||||
|
||||
/// UI 偏好(主题、磁贴尺寸、栏折叠位…)。**不含** rail 字段。
|
||||
pub fn prefs_mut(&mut self) -> &mut UiPrefs {
|
||||
&mut self.state.prefs
|
||||
}
|
||||
|
||||
/// YOLO 开关(设置 → AI 的那一项)。
|
||||
///
|
||||
/// 只翻开关不裁决:若此刻正有待确认请求,调用方仍要走一次
|
||||
/// [`Presenter::agent_confirm`](YOLO 下它会自动放行并记审计)。
|
||||
pub fn agent_set_yolo(&mut self, on: bool) {
|
||||
self.state.ai.yolo = on;
|
||||
}
|
||||
|
||||
/// 输入框草稿(用户打字时同步进来,切形态不丢)。
|
||||
///
|
||||
/// 草稿**是** rail 上唯一能合法躺着凭据原文的字段:它是「还没发出去的字」,写进来时不过
|
||||
/// 脱敏(UI 要能原样显示用户正在打的字)。安全性靠另一条:`agent_submit` 脱敏的是**入参**
|
||||
/// 而不是草稿,所以原文既不会进会话历史,也不会被发给后端。
|
||||
pub fn agent_set_draft(&mut self, text: String) {
|
||||
self.state.ai.draft = text;
|
||||
}
|
||||
|
||||
/// 喂一条事件(纯转发给 [`AppState::apply`])。
|
||||
pub fn apply(&mut self, event: AppEvent) {
|
||||
self.state.apply(event);
|
||||
}
|
||||
|
||||
/// 当前会话的视图模型(唯一读入口;两形态共享同一份)。
|
||||
#[must_use]
|
||||
pub fn agent_rail(&self) -> &AgentRail {
|
||||
&self.state.ai
|
||||
}
|
||||
|
||||
/// 提交一句话。
|
||||
///
|
||||
/// 拒绝的三种情形都不改状态(草稿保留,用户能接着改):
|
||||
/// 空文本、上一轮还在跑、有写操作待确认。
|
||||
///
|
||||
/// 成功时清空草稿、立刻把 `busy` 置真(**然后**才投影后端返回的事件分片),最后按消息
|
||||
/// 重算一次 `streaming`——异步后端在「提交成功 → 第一个事件到达」之间的空窗因此不会
|
||||
/// 让用户再点一次发送(那会产生两条 user 消息、两轮回复)。
|
||||
/// 用户气泡**由事件产生**(不在本地乐观回显,见 `lib.rs` §3)。
|
||||
pub fn agent_submit(&mut self, text: String) -> Result<(), String> {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("请输入内容".to_string());
|
||||
}
|
||||
if self.state.ai.confirm.is_some() {
|
||||
return Err("有写操作待确认:先允许或拒绝,再继续对话".to_string());
|
||||
}
|
||||
if self.state.ai.busy {
|
||||
return Err("上一轮回复还没结束".to_string());
|
||||
}
|
||||
// 用户输入也过脱敏:他可能顺手把 token 粘进对话框,而这段文本会进会话历史、
|
||||
// 会作为 prompt **发到模型服务商**、也会进导出。这道是**出站**闸门(rail 侧那道是
|
||||
// 显示闸门,两者不能互相替代:删掉这道,界面照样干净,但凭据已经出去了)。
|
||||
// 守卫见 agent/tests_red_line.rs::outbound_prompt_is_scrubbed_before_it_reaches_the_backend。
|
||||
let text = scrub_text(trimmed);
|
||||
let events = self
|
||||
.transport
|
||||
.submit(&text, &self.state.ai)
|
||||
// 失败原因同样可能回显 key(provider 的 401 常把 key 抄进 message),
|
||||
// 这条 Err 会直接显示在 UI 上,守卫见 outbound_error_text_is_scrubbed。
|
||||
.map_err(|error| scrub_text(&error))?;
|
||||
self.state.ai.draft.clear();
|
||||
self.state.ai.error = None;
|
||||
self.state.ai.begin_turn();
|
||||
for event in events {
|
||||
self.state.apply(event);
|
||||
}
|
||||
self.state.ai.settle_streaming();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 未读清零(UI 在「用户打开栏 / 停在独立页」时调用)。
|
||||
pub fn agent_mark_read(&mut self) {
|
||||
self.state.ai.mark_read();
|
||||
}
|
||||
|
||||
/// 对当前待确认的写操作做决定。
|
||||
///
|
||||
/// - 没有待确认:no-op(不记审计,不 panic);
|
||||
/// - YOLO 下:`accept` 被忽略,一律放行,审计记 `yolo = true, confirmed = false`;
|
||||
/// - 非 YOLO:按 `accept` 裁决,审计记 `confirmed = true`。
|
||||
///
|
||||
/// 两种情形都走同一条事件回流(后端返回的工具收尾事件),所以会话里看到的工具条目
|
||||
/// 与审计记录不会各行其是。
|
||||
///
|
||||
/// # UI 壳的义务
|
||||
///
|
||||
/// 看到 `rail.confirm.is_some()` 就要给出决定:非 YOLO 弹确认框,YOLO 直接调
|
||||
/// `agent_confirm(true)`(不弹窗,但**必须**调——否则 `busy` 一直锁着输入)。
|
||||
/// 真实后端在 YOLO 下不会发 `ConfirmRequested`(agent 侧 ConfirmGate 已自动放行),
|
||||
/// 这条路径是防御性的;另一条收敛途径是后端发 `AgentEvent::ConfirmResolved`。
|
||||
pub fn agent_confirm(&mut self, accept: bool) {
|
||||
let Some(request) = self.state.ai.take_confirm() else {
|
||||
return;
|
||||
};
|
||||
let yolo = self.state.ai.yolo;
|
||||
let verdict = if yolo || accept {
|
||||
ConfirmVerdict::Approved
|
||||
} else {
|
||||
ConfirmVerdict::Denied
|
||||
};
|
||||
self.record(AuditEntry {
|
||||
request_id: request.id.clone(),
|
||||
tool: request.tool.clone(),
|
||||
sensitivity: request.sensitivity,
|
||||
verdict,
|
||||
yolo,
|
||||
confirmed: !yolo,
|
||||
});
|
||||
let events = self.transport.confirm(&request.id, verdict.is_approved());
|
||||
for event in events {
|
||||
self.state.apply(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// 本进程内的裁决记录(最旧在前;权威审计在 agent 侧)。
|
||||
#[must_use]
|
||||
pub fn audit(&self) -> &[AuditEntry] {
|
||||
&self.audit
|
||||
}
|
||||
|
||||
/// 记一条裁决,超出 [`MAX_AUDIT_ENTRIES`] 时丢最旧的。
|
||||
fn record(&mut self, entry: AuditEntry) {
|
||||
if self.audit.len() == MAX_AUDIT_ENTRIES {
|
||||
self.audit.remove(0);
|
||||
}
|
||||
self.audit.push(entry);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! mock 版 agent 后端:四条路径的脚本化事件流(正常问答 / 流式中途出错 / 需确认的写操作 /
|
||||
//! 未读计数)。
|
||||
//!
|
||||
//! 与 [`crate::mock::MockBackend`] 的分工:那个是**整机演示后端**(按 tick 推进剧情,带定时器);
|
||||
//! 这个是 [`crate::agent::AgentTransport`] 的最小实现,**同步返回整轮事件分片**,专供 presenter
|
||||
//! 单测与 UI 壳的回归预览(没有真实 LLM 也能把栏画全)。
|
||||
//!
|
||||
//! 抓得住什么:事件顺序、投影结果(消息/工具/确认/未读/审计)、脱敏闸门。
|
||||
//! 抓不住什么:真实时序(这里一次性返回,真实后端是逐帧推流)、真实模型的措辞与工具选择、
|
||||
//! 网络/超时/取消语义——那些要接 rig 之后跑回放回归。
|
||||
|
||||
use crate::agent::{AgentRail, AgentTransport};
|
||||
use crate::dto::{AgentEvent, AppEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity};
|
||||
|
||||
/// 脚本:一轮输入会产生哪种剧情。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Script {
|
||||
/// 正常问答:流式增量收敛成一条完整助手消息
|
||||
Qa,
|
||||
/// 流式中途断流:已到的增量保留,整轮以 `Failed` 收尾
|
||||
FailsMidStream,
|
||||
/// 需确认的写操作:工具调用开始后挂一个 High 级确认请求
|
||||
WriteOp,
|
||||
}
|
||||
|
||||
/// 脚本化后端。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockAgentTransport {
|
||||
script: Script,
|
||||
/// 已发起过几轮(mock 用它算消息下标;真实后端自己维护会话)
|
||||
turns: usize,
|
||||
/// 当前待确认的写操作(`None` = 没有)
|
||||
pending: Option<ConfirmRequest>,
|
||||
}
|
||||
|
||||
impl MockAgentTransport {
|
||||
/// 指定脚本。
|
||||
#[must_use]
|
||||
pub fn new(script: Script) -> Self {
|
||||
Self {
|
||||
script,
|
||||
turns: 0,
|
||||
pending: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 正常问答。
|
||||
#[must_use]
|
||||
pub fn qa() -> Self {
|
||||
Self::new(Script::Qa)
|
||||
}
|
||||
|
||||
/// 流式中途出错。
|
||||
#[must_use]
|
||||
pub fn fails_mid_stream() -> Self {
|
||||
Self::new(Script::FailsMidStream)
|
||||
}
|
||||
|
||||
/// 需确认的写操作(改下载并发,High 级)。
|
||||
#[must_use]
|
||||
pub fn write_op() -> Self {
|
||||
Self::new(Script::WriteOp)
|
||||
}
|
||||
|
||||
/// 当前待确认请求(测试断言用)。
|
||||
#[must_use]
|
||||
pub fn pending(&self) -> Option<&ConfirmRequest> {
|
||||
self.pending.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentTransport for MockAgentTransport {
|
||||
fn submit(&mut self, text: &str, history: &AgentRail) -> Result<Vec<AppEvent>, String> {
|
||||
self.turns += 1;
|
||||
// 后端自己算下标:用户气泡先到,助手流式占位紧随其后。
|
||||
let assistant_index = history.messages.len() + 1;
|
||||
let mut events = vec![
|
||||
agent_event(AgentEvent::MessageAppended(ChatMessage::user(text))),
|
||||
agent_event(AgentEvent::MessageAppended(ChatMessage::assistant_stream())),
|
||||
];
|
||||
match self.script {
|
||||
Script::Qa => {
|
||||
for delta in [
|
||||
"先确认版本:",
|
||||
"官方 1.20.4 是正式版,",
|
||||
"装完再加 Fabric。",
|
||||
] {
|
||||
events.push(agent_event(AgentEvent::MessageDelta {
|
||||
index: assistant_index,
|
||||
delta: delta.to_string(),
|
||||
}));
|
||||
}
|
||||
events.push(agent_event(AgentEvent::MessageFinished {
|
||||
index: assistant_index,
|
||||
}));
|
||||
}
|
||||
Script::FailsMidStream => {
|
||||
events.push(agent_event(AgentEvent::MessageDelta {
|
||||
index: assistant_index,
|
||||
delta: "我先读一下最近的日志".to_string(),
|
||||
}));
|
||||
events.push(agent_event(AgentEvent::Failed {
|
||||
message: "provider 断流:read timeout".to_string(),
|
||||
}));
|
||||
}
|
||||
Script::WriteOp => {
|
||||
events.push(agent_event(AgentEvent::MessageDelta {
|
||||
index: assistant_index,
|
||||
delta: "我准备把下载并发从 64 改成 16。".to_string(),
|
||||
}));
|
||||
events.push(agent_event(AgentEvent::MessageFinished {
|
||||
index: assistant_index,
|
||||
}));
|
||||
events.push(agent_event(AgentEvent::ToolCallStarted {
|
||||
name: "set_config".to_string(),
|
||||
summary: "修改 download.concurrency".to_string(),
|
||||
}));
|
||||
let request = ConfirmRequest {
|
||||
id: format!("req-{}", self.turns),
|
||||
tool: "set_config".to_string(),
|
||||
summary: "把下载并发 64 → 16".to_string(),
|
||||
details: serde_json::json!({
|
||||
"path": "download.concurrency",
|
||||
"old": 64,
|
||||
"new": 16,
|
||||
}),
|
||||
sensitivity: Sensitivity::High,
|
||||
};
|
||||
self.pending = Some(request.clone());
|
||||
events.push(agent_event(AgentEvent::ConfirmRequested { request }));
|
||||
}
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn confirm(&mut self, request_id: &str, accept: bool) -> Vec<AppEvent> {
|
||||
self.pending = None;
|
||||
let (ok, summary, reply) = if accept {
|
||||
(true, "已写入 download.concurrency = 16", "改好了。")
|
||||
} else {
|
||||
(false, "用户拒绝,未写入", "那就不改了。")
|
||||
};
|
||||
vec![
|
||||
agent_event(AgentEvent::ToolCallFinished {
|
||||
name: "set_config".to_string(),
|
||||
ok,
|
||||
summary: summary.to_string(),
|
||||
}),
|
||||
agent_event(AgentEvent::ConfirmResolved {
|
||||
id: request_id.to_string(),
|
||||
}),
|
||||
agent_event(AgentEvent::MessageAppended(ChatMessage {
|
||||
role: ChatRole::Assistant,
|
||||
text: reply.to_string(),
|
||||
tool: None,
|
||||
streaming: false,
|
||||
})),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// 包一层 [`AppEvent::Agent`](presenter 的事件队列只认顶层事件)。
|
||||
fn agent_event(event: AgentEvent) -> AppEvent {
|
||||
AppEvent::Agent(event)
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
//! AI 助手栏(D16 双形态:右侧常驻栏 / 独立页)的**唯一**视图模型。
|
||||
//!
|
||||
//! 两形态共用同一份 [`AgentRail`]:折叠/展开、停靠/拆页都只是换渲染位置,切形态不丢会话。
|
||||
//!
|
||||
//! # 1. 事件从哪里来、到哪里去
|
||||
//!
|
||||
//! ```text
|
||||
//! agent/host 后端 ──AgentEvent──► AppState::apply ──► AgentRail::ingest ──► 字段
|
||||
//! └──► unread(栏不可见时累加)
|
||||
//! ```
|
||||
//!
|
||||
//! 投影规则(四类事件各归各位):
|
||||
//!
|
||||
//! | 事件 | 落到哪 |
|
||||
//! | ---- | ------ |
|
||||
//! | `MessageAppended` | 追加一条消息;`streaming` 的置 `rail.streaming` |
|
||||
//! | `MessageDelta` | 追加到最后一条 assistant 消息的正文(**累积在一条上**,不是每条一个气泡) |
|
||||
//! | `MessageFinished` | 清掉该条(以及全部)流式标记,`streaming` 由消息重算 |
|
||||
//! | `ToolCallStarted` / `ToolCallFinished` | 生成 / 收敛 [`ToolCallView`] 条目(D4:工具调用会话可见) |
|
||||
//! | `Failed` | 收敛流式 + 立横幅 + 追加系统条目(错误也要能回翻) |
|
||||
//! | `ConfirmRequested` / `ConfirmResolved` | `rail.confirm` 置位 / 清除 |
|
||||
//!
|
||||
//! # 2. 红线:凭据不得进 rail,也不得**出站**
|
||||
//!
|
||||
//! 两道闸门,各守一侧,**不能互相替代**:
|
||||
//!
|
||||
//! | 闸门 | 位置 | 守什么 |
|
||||
//! | ---- | ---- | ------ |
|
||||
//! | 出站 | [`crate::agent::Presenter::agent_submit`] 的 `scrub_text(trimmed)` / `map_err` | 发给后端的 prompt、回给 UI 的 `Err` 文案 |
|
||||
//! | 显示 | [`AgentRail::ingest`] 这里 | 进入 rail 的所有事件文本(消息正文 / 工具名与摘要 / 错误 / 确认请求) |
|
||||
//!
|
||||
//! 只留出站闸门 → 后端事件里的凭据会直接显示在聊天栏;只留显示闸门 → 界面一片干净而
|
||||
//! **凭据已经发出去了**。两边各有独立守卫(`agent/tests_red_line.rs`)。
|
||||
//!
|
||||
//! [`AgentRail::ingest`] 逐字段过 [`scrub_text`] / [`scrub_details`]。凭据字段(`token` /
|
||||
//! `refresh_token` / `password` / `access_token` …)本就不该出现在事件里(AGENTS §6:AI 永不
|
||||
//! 接触账户凭据),这里的脱敏是**第二道**防线:万一实现方把 JWT 塞进了工具摘要或错误文案,
|
||||
//! rail 上也不会出现原文。
|
||||
//!
|
||||
//! 规则来源(刻意不另写一套正则):
|
||||
//! - 内容侧:复用 `unlucky_core::launch::redact`(M40 上提的「带点 token」+ ≥64 不透明串);
|
||||
//! - 上下文侧:照 `unlucky_agent::redact` 补两条——`Bearer <短令牌>` 掩码、
|
||||
//! `{"path": 敏感键}` 时 `value`/`old`/`new` 一并掩码(`set_config` 的 details 正是它)。
|
||||
//!
|
||||
//! 与 `unlucky_agent::redact` 的实际关系(**别读成「已无差异」,也别读成「已经漂移」**):
|
||||
//! - 键名名单(8 条 needle):**逐字相同**;
|
||||
//! - path 规则:**语义相同**(presenter 额外对字符串值跑 `scrub_text`,比 `redact_args` 更严);
|
||||
//! - [`mask_bearer`]:**行为不完全等价的自写迁移**——停字符集 presenter 8 个(含 `;`,
|
||||
//! 不含 `/` `\`)vs agent `segment_end` 9 个(含 `/` `\`,不含 `;`),占位符也不同
|
||||
//! (`<redacted:token>` vs `***`)。`Bearer abc;def` presenter 掩得更少(`abc` 被掩、
|
||||
//! `;def` 留着),`Bearer abc/def` 掩得更多。方向安全(多掩不漏;少掩只发生在 `;` 上),
|
||||
//! 右边界由 `tests::bearer_right_boundary_is_pinned` 钉住。
|
||||
//! - 两处重复实现(键名名单 + Bearer)是**已知欠账**:上提一份公共实现要单开 mission
|
||||
//! (presenter 不能依赖 `unlucky-agent`,那会拖进 rig / tokio / rmcp)。
|
||||
//!
|
||||
//! # 3. 刻意不做的事
|
||||
//!
|
||||
//! - **不裁剪 `messages`**:`MessageDelta` 用下标寻址,从头丢弃会让下标漂移。会话长度由后端
|
||||
//! 的轮次上限控制;这里只保证下标稳定。
|
||||
//! - **不持有后端**:rail 是纯数据 + 投影,提交/确认在 [`crate::agent::Presenter`]。
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
use unlucky_core::launch::redact::{Redactor, TOKEN_PLACEHOLDER};
|
||||
|
||||
use crate::dto::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, ToolCallView};
|
||||
|
||||
/// 键名命中即整值打码(与 `unlucky_agent::redact::is_sensitive_key` 同一份名单)。
|
||||
///
|
||||
/// 为什么在这里再写一遍:presenter 不依赖 `unlucky-agent`(那会把 rig / tokio / rmcp 整棵树
|
||||
/// 拖进共用宿主层)。名单漂移的风险已记进 M103 报告,长期该由 core 出一份公共实现。
|
||||
const SENSITIVE_KEY_NEEDLES: [&str; 8] = [
|
||||
"api_key",
|
||||
"apikey",
|
||||
"token",
|
||||
"password",
|
||||
"passwd",
|
||||
"secret",
|
||||
"authorization",
|
||||
"credential",
|
||||
];
|
||||
|
||||
/// 文本是否像凭据键名(`access_token` / `refresh_token` / `microsoft.password` …)。
|
||||
#[must_use]
|
||||
pub fn is_sensitive_key(key: &str) -> bool {
|
||||
let lower = key.to_ascii_lowercase();
|
||||
SENSITIVE_KEY_NEEDLES
|
||||
.iter()
|
||||
.any(|needle| lower.contains(needle))
|
||||
}
|
||||
|
||||
/// 文本脱敏:内容规则三件套。
|
||||
///
|
||||
/// 1. JWT 形状(带点 token)/ ≥64 不透明串 —— 复用 core 的 `Redactor`(M40 上提的那份);
|
||||
/// 2. `Bearer` 后面的短令牌 —— core 没有这条,照 `unlucky_agent::redact::mask_bearer` 补;
|
||||
/// 3. 主目录不登记:rail 不持有数据根,`<home>` 掩码是 host 侧的职责。
|
||||
#[must_use]
|
||||
pub fn scrub_text(text: &str) -> String {
|
||||
mask_bearer(&Redactor::new().scrub(text))
|
||||
}
|
||||
|
||||
/// 结构化细节脱敏:按**键名**、按**值随 path 敏感**、按内容三道(见模块文档 §2)。
|
||||
///
|
||||
/// 同样对齐 `unlucky_agent::redact::redact_args` 的两条上下文规则:键名命中即掩码,
|
||||
/// 以及 `{"path": "ai.api_key", "old": …, "new": …}` 这种「值随路径敏感」的形状
|
||||
/// (`set_config` 的 details 正是它)——否则 API key 会原样躺在确认弹窗的 diff 里。
|
||||
#[must_use]
|
||||
pub fn scrub_details(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let sensitive_path = map
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(is_sensitive_key);
|
||||
let mut out = Map::with_capacity(map.len());
|
||||
for (key, child) in map {
|
||||
let masked = is_sensitive_key(key)
|
||||
|| (sensitive_path && matches!(key.as_str(), "value" | "old" | "new"));
|
||||
let child = if masked {
|
||||
Value::String(TOKEN_PLACEHOLDER.to_string())
|
||||
} else {
|
||||
scrub_details(child)
|
||||
};
|
||||
out.insert(key.clone(), child);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
Value::Array(items) => Value::Array(items.iter().map(scrub_details).collect()),
|
||||
Value::String(text) => Value::String(scrub_text(text)),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Bearer` 之后的分隔符(令牌到此为止)。与 agent `segment_end` 的差异见模块文档 §2:
|
||||
/// 多 `;`、少 `/` `\`(方向安全:多掩不漏),右边界由 `tests::bearer_right_boundary_is_pinned` 钉住。
|
||||
const BEARER_STOPS: [char; 8] = [' ', '\t', '\n', '\r', '"', '\'', ',', ';'];
|
||||
|
||||
/// `Bearer <token>` → `Bearer <redacted:token>`。
|
||||
///
|
||||
/// core 的 `Redactor` 只认「点号 token / ≥64 不透明串」,短令牌(`Bearer abcdef123456`)
|
||||
/// 会原样放过——这条规则照 `unlucky_agent::redact::mask_bearer` 的意图补上(实现是自写的,
|
||||
/// 见模块文档 §2 的差异说明)。幂等:已经是 `<redacted:token>` 的再跑一遍结果不变。
|
||||
fn mask_bearer(text: &str) -> String {
|
||||
const MARKER: &str = "Bearer ";
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut cursor = 0usize;
|
||||
while let Some(position) = text[cursor..].find(MARKER) {
|
||||
let token_start = cursor + position + MARKER.len();
|
||||
let token_end = bearer_token_end(text, token_start);
|
||||
out.push_str(&text[cursor..token_start]);
|
||||
if token_end > token_start {
|
||||
out.push_str(TOKEN_PLACEHOLDER);
|
||||
}
|
||||
cursor = token_end;
|
||||
}
|
||||
out.push_str(&text[cursor..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// 从 `from` 起取到下一个分隔符为止(令牌的右边界)。
|
||||
fn bearer_token_end(text: &str, from: usize) -> usize {
|
||||
text[from..]
|
||||
.find(BEARER_STOPS)
|
||||
.map_or(text.len(), |offset| from + offset)
|
||||
}
|
||||
|
||||
/// AI 助手栏视图模型(UI 壳只读它,不自己攒会话状态)。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AgentRail {
|
||||
/// 会话消息(user / assistant / tool / system),下标稳定
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// 栏不可见期间攒下的未读条数(`agent_mark_read` 清零)
|
||||
pub unread: usize,
|
||||
/// 是否该禁用输入(一轮回复进行中,或有写操作待确认)
|
||||
pub busy: bool,
|
||||
/// 输入框草稿(切形态不丢)
|
||||
pub draft: String,
|
||||
/// 待确认的写操作(`Some` = 弹确认框;非 YOLO 下 `busy` 必为 true)
|
||||
pub confirm: Option<ConfirmRequest>,
|
||||
/// 是否有流式消息在跑(Slint 壳读它切换「发送/停止」;新壳用 `busy` 即可)
|
||||
pub streaming: bool,
|
||||
/// YOLO 开关(跳过确认,审计照记)
|
||||
pub yolo: bool,
|
||||
/// 端点标签(栏顶显示)
|
||||
pub endpoint_label: String,
|
||||
/// 最近一次错误(横幅;下一次提交或 ingest 到新消息时清掉)
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AgentRail {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentRail {
|
||||
/// 空会话。默认端点标签是 P0 的本地 llama.cpp(P1 由设置页决定)。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
unread: 0,
|
||||
busy: false,
|
||||
draft: String::new(),
|
||||
confirm: None,
|
||||
streaming: false,
|
||||
yolo: false,
|
||||
endpoint_label: "llama.cpp · 本地".to_string(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式收敛:清掉全部流式标记并按消息重算 `streaming` / `busy`。
|
||||
pub fn finish_stream(&mut self) {
|
||||
for message in &mut self.messages {
|
||||
message.streaming = false;
|
||||
}
|
||||
self.settle_streaming();
|
||||
}
|
||||
|
||||
/// 提交成功、后端还没回事件时先把「一轮在跑」点亮。
|
||||
///
|
||||
/// 异步后端在「提交成功 → 第一个事件到达」之间有空窗;不先置忙的话用户能再点一次发送,
|
||||
/// 于是两条 user 消息、两轮回复并行。反正事件流收敛时会用
|
||||
/// [`AgentRail::settle_streaming`] 按消息重算,不会留下假忙。
|
||||
pub fn begin_turn(&mut self) {
|
||||
self.streaming = true;
|
||||
self.sync_busy();
|
||||
}
|
||||
|
||||
/// 按消息重算 `streaming`(有任一条还在流式即为真)并同步 `busy`。
|
||||
pub fn settle_streaming(&mut self) {
|
||||
self.streaming = self.messages.iter().any(|message| message.streaming);
|
||||
self.sync_busy();
|
||||
}
|
||||
|
||||
/// 清零未读(用户正在看 / 主动点掉红点)。
|
||||
pub fn mark_read(&mut self) {
|
||||
self.unread = 0;
|
||||
}
|
||||
|
||||
/// 取走待确认请求(`confirm` 置空并按需重算 `busy`);没有则返回 `None`。
|
||||
pub fn take_confirm(&mut self) -> Option<ConfirmRequest> {
|
||||
let request = self.confirm.take();
|
||||
self.sync_busy();
|
||||
request
|
||||
}
|
||||
|
||||
/// 投影一条 agent 事件。
|
||||
///
|
||||
/// 返回 `true` 表示这是一条「用户不在栏前也会想知道」的新内容(未读计数的原料):
|
||||
/// 助手/工具消息、错误、确认请求算;用户自己说的话与纯增量不算。
|
||||
pub fn ingest(&mut self, event: &AgentEvent) -> bool {
|
||||
match event {
|
||||
AgentEvent::MessageAppended(message) => {
|
||||
let message = scrub_message(message);
|
||||
let fresh = matches!(message.role, ChatRole::Assistant | ChatRole::Tool);
|
||||
if message.streaming {
|
||||
self.streaming = true;
|
||||
}
|
||||
if fresh {
|
||||
self.error = None;
|
||||
}
|
||||
self.messages.push(message);
|
||||
self.sync_busy();
|
||||
fresh
|
||||
}
|
||||
AgentEvent::MessageDelta { index, delta } => {
|
||||
// 只追加正文,不动流式标记:迟到的尾增量(`Failed` 之后)不该把
|
||||
// 「一轮在跑」重新点亮,否则输入框会一直禁用。
|
||||
match self.messages.get_mut(*index) {
|
||||
Some(message) => message.text.push_str(&scrub_text(delta)),
|
||||
// 分片乱序/重放时正文会无声消失(用户只看到半截话):开发期吵起来,
|
||||
// 发布期宁可丢这一片也不去猜该拼到哪条上。
|
||||
None => debug_assert!(
|
||||
false,
|
||||
"MessageDelta 指向不存在的消息下标 {index}(会话 {} 条)",
|
||||
self.messages.len()
|
||||
),
|
||||
}
|
||||
self.sync_busy();
|
||||
false
|
||||
}
|
||||
AgentEvent::MessageFinished { index } => {
|
||||
if let Some(message) = self.messages.get_mut(*index) {
|
||||
message.streaming = false;
|
||||
}
|
||||
// 以消息为准重算:并发工具条目没收敛时 `streaming` 不能提前熄
|
||||
self.settle_streaming();
|
||||
false
|
||||
}
|
||||
AgentEvent::ToolCallStarted { name, summary } => {
|
||||
let summary = scrub_text(summary);
|
||||
self.streaming = true;
|
||||
self.error = None;
|
||||
self.messages.push(ChatMessage {
|
||||
role: ChatRole::Tool,
|
||||
text: summary.clone(),
|
||||
tool: Some(ToolCallView {
|
||||
name: scrub_text(name),
|
||||
ok: false,
|
||||
summary,
|
||||
}),
|
||||
streaming: true,
|
||||
});
|
||||
self.sync_busy();
|
||||
true
|
||||
}
|
||||
AgentEvent::ToolCallFinished { name, ok, summary } => {
|
||||
let name = scrub_text(name);
|
||||
let summary = scrub_text(summary);
|
||||
// 只收敛「还在跑的同名调用」。找不到就**忽略**,绝不退回改写已收敛的旧条目
|
||||
// ——那会把历史结果覆盖掉(重放、重复结束事件、丢过 ToolCallStarted 都会命中)。
|
||||
let pending = self
|
||||
.messages
|
||||
.iter()
|
||||
.rposition(|message| message.streaming && tool_named(message, &name));
|
||||
if let Some(index) = pending {
|
||||
let message = &mut self.messages[index];
|
||||
message.streaming = false;
|
||||
message.text.clone_from(&summary);
|
||||
if let Some(tool) = &mut message.tool {
|
||||
tool.ok = *ok;
|
||||
tool.summary = summary;
|
||||
}
|
||||
}
|
||||
self.settle_streaming();
|
||||
false
|
||||
}
|
||||
AgentEvent::Failed { message } => {
|
||||
let text = scrub_text(message);
|
||||
self.finish_stream();
|
||||
self.error = Some(text.clone());
|
||||
self.messages.push(ChatMessage::system(text));
|
||||
true
|
||||
}
|
||||
AgentEvent::ConfirmRequested { request } => {
|
||||
self.confirm = Some(scrub_confirm(request));
|
||||
self.sync_busy();
|
||||
true
|
||||
}
|
||||
AgentEvent::ConfirmResolved { id } => {
|
||||
if self
|
||||
.confirm
|
||||
.as_ref()
|
||||
.is_some_and(|pending| &pending.id == id)
|
||||
{
|
||||
self.confirm = None;
|
||||
}
|
||||
self.sync_busy();
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `busy` 的唯一写入口:`busy == streaming || confirm.is_some()`。
|
||||
///
|
||||
/// UI 靠 `busy` 禁用输入框,所以「等确认」必须算忙——否则用户能在一轮写操作中间再发一句,
|
||||
/// 两个工具调用会踩同一份配置。
|
||||
fn sync_busy(&mut self) {
|
||||
self.busy = self.streaming || self.confirm.is_some();
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息脱敏(正文 + 工具摘要;工具名也过一遍,防止实现方把凭据塞进名字)。
|
||||
fn scrub_message(message: &ChatMessage) -> ChatMessage {
|
||||
let tool = message.tool.as_ref().map(|tool| ToolCallView {
|
||||
name: scrub_text(&tool.name),
|
||||
ok: tool.ok,
|
||||
summary: scrub_text(&tool.summary),
|
||||
});
|
||||
ChatMessage {
|
||||
role: message.role,
|
||||
text: scrub_text(&message.text),
|
||||
tool,
|
||||
streaming: message.streaming,
|
||||
}
|
||||
}
|
||||
|
||||
/// 确认请求脱敏:摘要按内容、细节按键名 + 内容双道。
|
||||
fn scrub_confirm(request: &ConfirmRequest) -> ConfirmRequest {
|
||||
ConfirmRequest {
|
||||
id: request.id.clone(),
|
||||
tool: scrub_text(&request.tool),
|
||||
summary: scrub_text(&request.summary),
|
||||
details: scrub_details(&request.details),
|
||||
sensitivity: request.sensitivity,
|
||||
}
|
||||
}
|
||||
|
||||
/// 这条消息是不是指定名字的工具条目。
|
||||
fn tool_named(message: &ChatMessage, name: &str) -> bool {
|
||||
message.tool.as_ref().is_some_and(|tool| tool.name == name)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
//! `agent` 模块的单元测试:四条路径(正常问答 / 流式中途出错 / 需确认的写操作 / 未读计数)+
|
||||
//! YOLO 语义 + 提交前置条件 + 投影健壮性。
|
||||
//!
|
||||
//! 红线(凭据形状的串不得进 rail)单列在 `agent/tests_red_line.rs`(单文件 ≤400 行)。
|
||||
//! 断言口径照 M103 的要求写清「抓得住什么 / 抓不住什么」:这些测试盯的是**投影与裁决**,
|
||||
//! 不盯真实模型的措辞、真实时序与网络错误——那些要接 rig 之后跑回放回归。
|
||||
|
||||
use super::mock::{MockAgentTransport, Script};
|
||||
use super::{AgentTransport, ConfirmVerdict, Presenter};
|
||||
use crate::agent::rail::scrub_text;
|
||||
use crate::agent::AgentRail;
|
||||
use crate::dto::{AgentEvent, AppEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity};
|
||||
|
||||
fn presenter(script: Script) -> Presenter {
|
||||
Presenter::new(Box::new(MockAgentTransport::new(script)))
|
||||
}
|
||||
|
||||
// ───────────────────────── 路径 1:正常问答 ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn normal_answer_accumulates_into_one_assistant_message() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
assert!(presenter.agent_submit("怎么装 1.20.4".to_string()).is_ok());
|
||||
|
||||
let rail = presenter.agent_rail();
|
||||
// 抓得住:用户气泡 + 一条助手消息(增量累积在同一条上,不是一个字一个气泡)
|
||||
assert_eq!(rail.messages.len(), 2);
|
||||
assert_eq!(rail.messages[0].role, ChatRole::User);
|
||||
assert_eq!(rail.messages[0].text, "怎么装 1.20.4");
|
||||
assert_eq!(rail.messages[1].role, ChatRole::Assistant);
|
||||
assert_eq!(
|
||||
rail.messages[1].text,
|
||||
"先确认版本:官方 1.20.4 是正式版,装完再加 Fabric。"
|
||||
);
|
||||
assert!(!rail.messages[1].streaming);
|
||||
assert!(!rail.streaming);
|
||||
assert!(!rail.busy);
|
||||
assert!(rail.confirm.is_none());
|
||||
assert!(rail.error.is_none());
|
||||
// 抓不住:不校验助手措辞是否正确(那是工具/模型的事)
|
||||
}
|
||||
|
||||
// ─────────────────── 路径 2:流式中途出错 ───────────────────
|
||||
|
||||
#[test]
|
||||
fn mid_stream_error_keeps_partial_text_and_flags_the_turn() {
|
||||
let mut presenter = presenter(Script::FailsMidStream);
|
||||
assert!(presenter.agent_submit("日志里有错吗".to_string()).is_ok());
|
||||
|
||||
let rail = presenter.agent_rail();
|
||||
// 抓得住:已到的增量不丢、流式收敛(不会永远转圈)、错误进横幅也进会话历史
|
||||
assert_eq!(rail.messages[1].role, ChatRole::Assistant);
|
||||
assert_eq!(rail.messages[1].text, "我先读一下最近的日志");
|
||||
assert!(!rail.streaming);
|
||||
assert!(!rail.busy, "出错后输入框必须重新可用");
|
||||
assert_eq!(rail.error.as_deref(), Some("provider 断流:read timeout"));
|
||||
assert_eq!(rail.messages[2].role, ChatRole::System);
|
||||
assert_eq!(rail.messages[2].text, "provider 断流:read timeout");
|
||||
// 抓不住:不判断这是网络问题还是模型问题(归因在 core::diagnose)
|
||||
}
|
||||
|
||||
// ───────────── 路径 3a/3b:需确认的写操作(接受 / 拒绝)─────────────
|
||||
|
||||
#[test]
|
||||
fn write_op_accept_applies_and_audits() {
|
||||
let mut presenter = presenter(Script::WriteOp);
|
||||
assert!(presenter.agent_submit("并发调小点".to_string()).is_ok());
|
||||
|
||||
// 确认之前:rail 挂着请求,且 UI 靠 busy 禁用输入
|
||||
{
|
||||
let rail = presenter.agent_rail();
|
||||
assert!(rail.busy);
|
||||
let request = rail.confirm.as_ref().expect("应当有待确认请求");
|
||||
assert_eq!(request.tool, "set_config");
|
||||
assert_eq!(request.details["path"], "download.concurrency");
|
||||
assert!(request.sensitivity.needs_confirm());
|
||||
assert_eq!(rail.messages.len(), 3); // user / assistant / tool
|
||||
assert_eq!(rail.messages[2].role, ChatRole::Tool);
|
||||
}
|
||||
|
||||
presenter.agent_confirm(true);
|
||||
let rail = presenter.agent_rail();
|
||||
assert!(rail.confirm.is_none());
|
||||
assert!(!rail.busy);
|
||||
assert_eq!(rail.messages[2].text, "已写入 download.concurrency = 16");
|
||||
assert!(rail.messages[2].tool.as_ref().is_some_and(|tool| tool.ok));
|
||||
assert_eq!(
|
||||
rail.messages.last().map(|message| message.role),
|
||||
Some(ChatRole::Assistant)
|
||||
);
|
||||
|
||||
let audit = presenter.audit();
|
||||
assert_eq!(audit.len(), 1);
|
||||
assert_eq!(audit[0].verdict, ConfirmVerdict::Approved);
|
||||
assert!(!audit[0].yolo && audit[0].confirmed, "用户亲手点的允许");
|
||||
assert_eq!(audit[0].sensitivity, Sensitivity::High);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_op_reject_denies_and_audits() {
|
||||
let mut presenter = presenter(Script::WriteOp);
|
||||
assert!(presenter.agent_submit("并发调小点".to_string()).is_ok());
|
||||
presenter.agent_confirm(false);
|
||||
|
||||
let rail = presenter.agent_rail();
|
||||
assert!(rail.confirm.is_none());
|
||||
assert!(!rail.busy);
|
||||
assert_eq!(rail.messages[2].text, "用户拒绝,未写入");
|
||||
assert!(rail.messages[2].tool.as_ref().is_some_and(|tool| !tool.ok));
|
||||
assert_eq!(
|
||||
rail.messages.last().map(|text| text.text.clone()),
|
||||
Some("那就不改了。".to_string())
|
||||
);
|
||||
|
||||
let audit = presenter.audit();
|
||||
assert_eq!(audit[0].verdict, ConfirmVerdict::Denied);
|
||||
assert!(!audit[0].yolo && audit[0].confirmed);
|
||||
}
|
||||
|
||||
// ─────────────────────── 路径 4:未读计数 ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn unread_grows_while_collapsed_and_mark_read_clears() {
|
||||
let mut presenter = presenter(Script::WriteOp);
|
||||
presenter.state_mut().prefs.ai_collapsed = true; // 收成 icon 轨 = 用户离开了栏
|
||||
presenter
|
||||
.agent_submit("并发调小点".to_string())
|
||||
.expect("提交应当成功");
|
||||
|
||||
// 抓得住:助手消息 + 工具条目 + 待确认各算一条;用户自己的话与纯增量不算
|
||||
assert_eq!(presenter.agent_rail().unread, 3);
|
||||
|
||||
presenter.agent_confirm(true);
|
||||
// 工具收尾不是新内容,收尾的助手消息算一条
|
||||
assert_eq!(presenter.agent_rail().unread, 4);
|
||||
|
||||
presenter.agent_mark_read();
|
||||
assert_eq!(presenter.agent_rail().unread, 0);
|
||||
}
|
||||
|
||||
// ───────────────────────── YOLO 语义 ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn yolo_auto_accepts_but_still_records_the_verdict() {
|
||||
let mut presenter = presenter(Script::WriteOp);
|
||||
presenter.state_mut().ai.yolo = true;
|
||||
presenter
|
||||
.agent_submit("并发调小点".to_string())
|
||||
.expect("提交应当成功");
|
||||
|
||||
// 用户点了「拒绝」,但 YOLO 下自动放行
|
||||
presenter.agent_confirm(false);
|
||||
|
||||
let rail = presenter.agent_rail();
|
||||
assert!(rail.confirm.is_none());
|
||||
assert_eq!(rail.messages[2].text, "已写入 download.concurrency = 16");
|
||||
|
||||
// 不得静默跳过:审计里必须有这一条,且两种标记分开记
|
||||
let audit = presenter.audit();
|
||||
assert_eq!(audit.len(), 1);
|
||||
assert_eq!(audit[0].verdict, ConfirmVerdict::Approved);
|
||||
assert!(audit[0].yolo, "YOLO 自动放行要留痕");
|
||||
assert!(!audit[0].confirmed, "自动放行不是「用户确认」");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_confirm_forces_busy_without_yolo() {
|
||||
let mut presenter = presenter(Script::WriteOp);
|
||||
assert!(!presenter.agent_rail().yolo);
|
||||
presenter
|
||||
.agent_submit("并发调小点".to_string())
|
||||
.expect("提交应当成功");
|
||||
assert!(presenter.agent_rail().confirm.is_some());
|
||||
|
||||
// 先把流式标记清干净:否则 `busy` 可能只是「streaming 顺手置真」,
|
||||
// 这条测试就钉不住「待确认算忙」这条规则(r1 P2-2:变异掉 `confirm.is_some()`
|
||||
// 之后这里必须变红)。
|
||||
presenter.state_mut().ai.finish_stream();
|
||||
let rail = presenter.agent_rail();
|
||||
assert!(!rail.streaming, "此刻没有流式消息在跑");
|
||||
assert!(rail.busy, "非 YOLO 下待确认必须算忙,UI 靠它禁用输入");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn begin_turn_marks_busy_before_any_event_arrives() {
|
||||
// 提交成功到第一个事件之间异步后端有空窗:这段时间必须已经算忙,
|
||||
// 否则用户能再点一次发送(两条 user 消息、两轮回复)。
|
||||
let mut rail = AgentRail::new();
|
||||
rail.begin_turn();
|
||||
assert!(rail.busy && rail.streaming);
|
||||
|
||||
// 空事件流也不会把输入框永久锁死:收敛时按消息重算
|
||||
rail.settle_streaming();
|
||||
assert!(!rail.busy && !rail.streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_with_a_silent_backend_does_not_leave_the_shell_busy() {
|
||||
let mut presenter = Presenter::new(Box::new(SilentTransport));
|
||||
presenter
|
||||
.agent_submit("在吗".to_string())
|
||||
.expect("提交应当成功");
|
||||
assert!(
|
||||
!presenter.agent_rail().busy,
|
||||
"后端一个事件都没回,输入框必须重新可用"
|
||||
);
|
||||
}
|
||||
|
||||
/// 工具收尾事件早于开始事件(或重复投递):不许覆盖已收敛的历史条目(r1 P2-3)。
|
||||
#[test]
|
||||
fn tool_call_finished_without_a_pending_call_never_rewrites_history() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallStarted {
|
||||
name: "set_config".to_string(),
|
||||
summary: "修改 download.concurrency".to_string(),
|
||||
}));
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallFinished {
|
||||
name: "set_config".to_string(),
|
||||
ok: true,
|
||||
summary: "并发已改成 16".to_string(),
|
||||
}));
|
||||
// 重放 / 乱序:没有 pending 的收尾事件只能被忽略
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallFinished {
|
||||
name: "set_config".to_string(),
|
||||
ok: false,
|
||||
summary: "另一次失败".to_string(),
|
||||
}));
|
||||
|
||||
let tool = presenter.agent_rail().messages[0]
|
||||
.tool
|
||||
.as_ref()
|
||||
.expect("工具条目");
|
||||
assert_eq!(tool.summary, "并发已改成 16", "历史条目被改写了");
|
||||
assert!(tool.ok);
|
||||
}
|
||||
|
||||
// ────────────────── scrub_text 的 Bearer 右边界 ──────────────────
|
||||
|
||||
/// 钉住 `mask_bearer` 的**右边界**(r2 P2-2:停字符集收窄或换字符都必须让这条红)。
|
||||
///
|
||||
/// 方向说明:停字符越少 = 吞得越多(安全);放宽才会裸奔。所以句点故意不在停字符集里
|
||||
/// (吞掉句点,换「短 JWT 只掩前半段」是不划算的)。
|
||||
#[test]
|
||||
fn bearer_right_boundary_is_pinned() {
|
||||
// 空白 / 逗号 / 分号 / 引号是停字符:令牌之后的内容原样保留
|
||||
assert_eq!(
|
||||
scrub_text("Authorization: Bearer abc123, retry=1"),
|
||||
"Authorization: Bearer <redacted:token>, retry=1"
|
||||
);
|
||||
assert_eq!(
|
||||
scrub_text("Bearer abc123; rest"),
|
||||
"Bearer <redacted:token>; rest"
|
||||
);
|
||||
assert_eq!(
|
||||
scrub_text("\"Bearer abc123\""),
|
||||
"\"Bearer <redacted:token>\""
|
||||
);
|
||||
assert_eq!(scrub_text("Bearer abc123"), "Bearer <redacted:token>");
|
||||
// 句点不是停字符:整段吞掉(宁可多掩,也不让短 JWT 只掩前半段)
|
||||
assert_eq!(scrub_text("Bearer abc123."), "Bearer <redacted:token>");
|
||||
assert_eq!(scrub_text("Bearer eyJ.abc.def"), "Bearer <redacted:token>");
|
||||
}
|
||||
|
||||
// ─────────────────────── 提交前置条件 ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn submit_rejects_empty_busy_and_pending_confirm() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
assert!(presenter.agent_submit(" \n ".to_string()).is_err());
|
||||
assert!(presenter.agent_rail().messages.is_empty());
|
||||
|
||||
// 上一轮还在跑
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::MessageAppended(
|
||||
ChatMessage::assistant_stream(),
|
||||
)));
|
||||
assert!(presenter.agent_submit("再说一句".to_string()).is_err());
|
||||
|
||||
// 有写操作待确认
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::Failed {
|
||||
message: "断流".to_string(),
|
||||
}));
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::ConfirmRequested {
|
||||
request: ConfirmRequest {
|
||||
id: "req-1".to_string(),
|
||||
tool: "set_config".to_string(),
|
||||
summary: "改内存".to_string(),
|
||||
details: serde_json::json!({}),
|
||||
sensitivity: Sensitivity::High,
|
||||
},
|
||||
}));
|
||||
let error = presenter.agent_submit("再说一句".to_string());
|
||||
assert!(error.is_err(), "待确认时不许再提交");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_keeps_the_draft_when_the_backend_fails() {
|
||||
let mut presenter = Presenter::new(Box::new(BrokenTransport));
|
||||
presenter.state_mut().ai.draft = "还没发出去的话".to_string();
|
||||
assert!(presenter
|
||||
.agent_submit("还没发出去的话".to_string())
|
||||
.is_err());
|
||||
// 抓得住:起不来的一轮不产生事件、草稿保留(用户能改完再发)
|
||||
assert!(presenter.agent_rail().messages.is_empty());
|
||||
assert_eq!(presenter.agent_rail().draft, "还没发出去的话");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_is_cleared_only_on_successful_submit() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
presenter.state_mut().ai.draft = " 怎么装 ".to_string();
|
||||
presenter
|
||||
.agent_submit(" 怎么装 ".to_string())
|
||||
.expect("提交应当成功");
|
||||
assert!(presenter.agent_rail().draft.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_without_pending_is_a_noop() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
presenter.agent_confirm(true);
|
||||
presenter.agent_confirm(false);
|
||||
assert!(presenter.audit().is_empty());
|
||||
assert!(presenter.agent_rail().messages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_forwards_every_event_to_app_state() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::MessageAppended(
|
||||
ChatMessage::assistant_stream(),
|
||||
)));
|
||||
assert_eq!(presenter.state().ai.messages.len(), 1);
|
||||
assert_eq!(presenter.agent_rail(), &presenter.state().ai);
|
||||
}
|
||||
|
||||
// ───────────────────────── 测试替身 ─────────────────────────
|
||||
|
||||
/// 起不来的后端(返回 `Err`,不产生任何事件)。
|
||||
struct BrokenTransport;
|
||||
|
||||
impl AgentTransport for BrokenTransport {
|
||||
fn submit(&mut self, _text: &str, _history: &AgentRail) -> Result<Vec<AppEvent>, String> {
|
||||
Err("本地模型还没加载".to_string())
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _request_id: &str, _accept: bool) -> Vec<AppEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 收下输入但一个事件都不回的后端(异步推流的极端形态:这一拍还没到)。
|
||||
struct SilentTransport;
|
||||
|
||||
impl AgentTransport for SilentTransport {
|
||||
fn submit(&mut self, _text: &str, _history: &AgentRail) -> Result<Vec<AppEvent>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _request_id: &str, _accept: bool) -> Vec<AppEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
//! 红线测试:凭据形状的串不得进 [`crate::agent::AgentRail`],也不得**出站**。
|
||||
//!
|
||||
//! 与 `agent/tests.rs` 同源、拆文件只为守「单文件 ≤400 行」。这一组测试的目标是**守住闸门**:
|
||||
//! 两道闸门(出站 [`Presenter::agent_submit`] 的 prompt/Err、显示 [`crate::agent::rail`] 的
|
||||
//! ingest 投影)与每条具体入口各自注入一次,任何一条把脱敏摘掉都必须变红。
|
||||
//! r1 / r2 复审都是靠「逐入口造变异」抓出假绿的,改动这里时请照做自证。
|
||||
//!
|
||||
//! 注入的凭据形状两类:
|
||||
//! - JWT(`header.payload.signature`,靠 core `Redactor` 的「带点 token」规则拦);
|
||||
//! - 非 JWT 形状(`{"password":"hunter2"}`、短 `Bearer abcdef123456`,靠键名与 Bearer 规则拦)。
|
||||
//!
|
||||
//! **`rail.draft` 不在这组断言的覆盖范围**:草稿是「还没发出去的字」,UI 要原样显示,
|
||||
//! 它按设计可以躺着凭据原文;安全性由「`agent_submit` 脱敏的是入参而非草稿」保证。
|
||||
//! 所以本文件的 `rail_text()` 刻意不含 draft——别把「rail 全文无 JWT」读成覆盖了 draft。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use super::mock::{MockAgentTransport, Script};
|
||||
use super::{AgentTransport, Presenter};
|
||||
use crate::agent::rail::scrub_text;
|
||||
use crate::agent::AgentRail;
|
||||
use crate::dto::{
|
||||
AgentEvent, AppEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView,
|
||||
};
|
||||
|
||||
/// 真实形状的 JWT(header.payload.signature,三段各 ≥8)。
|
||||
const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g";
|
||||
|
||||
fn presenter(script: Script) -> Presenter {
|
||||
Presenter::new(Box::new(MockAgentTransport::new(script)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwt_shaped_text_never_reaches_the_rail() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
// 五个入口各注入一次:用户提交、增量、工具摘要、错误、确认请求细节
|
||||
presenter
|
||||
.agent_submit(format!("我的 token 是 {JWT}"))
|
||||
.expect("提交应当成功");
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::MessageDelta {
|
||||
index: 1,
|
||||
delta: format!("顺手贴一个 {JWT}"),
|
||||
}));
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallStarted {
|
||||
name: "read_game_log".to_string(),
|
||||
summary: format!("Authorization: Bearer {JWT}"),
|
||||
}));
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::Failed {
|
||||
message: format!("401: access_token={JWT}"),
|
||||
}));
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::ConfirmRequested {
|
||||
request: ConfirmRequest {
|
||||
id: "req-jwt".to_string(),
|
||||
tool: "set_config".to_string(),
|
||||
summary: format!("把 access_token 改成 {JWT}"),
|
||||
details: serde_json::json!({ "access_token": JWT, "note": JWT }),
|
||||
sensitivity: Sensitivity::High,
|
||||
},
|
||||
}));
|
||||
|
||||
let rail = presenter.agent_rail();
|
||||
let text = rail_text(rail);
|
||||
assert!(
|
||||
!text.contains("eyJhbGci"),
|
||||
"rail 上不得出现 JWT 原文:{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("<redacted:token>"),
|
||||
"应当被脱敏成占位符:{text}"
|
||||
);
|
||||
// 凭据键名的值整段打码(不只是靠内容规则)
|
||||
let details = &rail.confirm.as_ref().expect("确认请求仍在").details;
|
||||
assert_eq!(details["access_token"], "<redacted:token>");
|
||||
assert!(!details["note"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("eyJhbGci"));
|
||||
}
|
||||
|
||||
/// 上一条测试里 `MessageAppended` 走的是 `agent_submit`(已先 scrub),踩不到 rail 自己的
|
||||
/// `scrub_message`。这条**绕过 submit** 直接投递,钉住那条入口(r1 P1 变异 A)。
|
||||
#[test]
|
||||
fn message_appended_from_the_backend_is_scrubbed_inside_the_rail() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::MessageAppended(
|
||||
ChatMessage::user(format!("access_token={JWT}")),
|
||||
)));
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::MessageAppended(ChatMessage {
|
||||
role: ChatRole::Assistant,
|
||||
text: format!("听到你贴了 {JWT}"),
|
||||
tool: None,
|
||||
streaming: false,
|
||||
})));
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::MessageAppended(
|
||||
ChatMessage::system(format!("refresh_token={JWT}")),
|
||||
)));
|
||||
|
||||
let text = rail_text(presenter.agent_rail());
|
||||
assert!(
|
||||
!text.contains("eyJhbGci"),
|
||||
"MessageAppended 入口未脱敏:{text}"
|
||||
);
|
||||
assert_eq!(
|
||||
presenter.agent_rail().messages.len(),
|
||||
3,
|
||||
"三条消息都要在(脱敏不等于丢消息)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 工具**收尾**事件(`ToolCallFinished`)同样带 name + summary,必须过闸门(r1 P1 变异 C2)。
|
||||
///
|
||||
/// 两边用同一个「名字里带 JWT」的工具名:脱敏后仍然相等,所以**只要有一侧没脱敏**,
|
||||
/// 名字就对不上(条目收敛不了、`ok` 仍是 false),断言会红。
|
||||
#[test]
|
||||
fn tool_call_finished_summary_is_scrubbed_inside_the_rail() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
let name = format!("set_config_{JWT}");
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallStarted {
|
||||
name: name.clone(),
|
||||
summary: "修改 download.concurrency".to_string(),
|
||||
}));
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallFinished {
|
||||
name,
|
||||
ok: true,
|
||||
summary: format!("写进去了:Authorization: Bearer {JWT}"),
|
||||
}));
|
||||
|
||||
let text = rail_text(presenter.agent_rail());
|
||||
assert!(
|
||||
!text.contains("eyJhbGci"),
|
||||
"ToolCallFinished 入口未脱敏:{text}"
|
||||
);
|
||||
let tool = presenter.agent_rail().messages[0]
|
||||
.tool
|
||||
.as_ref()
|
||||
.expect("工具条目");
|
||||
assert!(tool.ok, "结论位要照实写");
|
||||
assert!(!tool.name.contains("eyJhbGci"), "工具名也要过闸门");
|
||||
}
|
||||
|
||||
/// 非 JWT 形状的凭据(`{"password":"hunter2"}`)只能靠**键名**规则拦(r1 P1 变异 H)。
|
||||
///
|
||||
/// 同时钉住「值随 path 敏感」这条上下文规则(`set_config` 的 details 形状)。
|
||||
#[test]
|
||||
fn sensitive_keys_in_confirm_details_are_masked_without_jwt_shape() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::ConfirmRequested {
|
||||
request: ConfirmRequest {
|
||||
id: "req-keys".to_string(),
|
||||
tool: "set_config".to_string(),
|
||||
summary: "改几个设置".to_string(),
|
||||
details: serde_json::json!({
|
||||
"password": "hunter2",
|
||||
"api_key": "short-key-123",
|
||||
"nested": { "refresh_token": "rt-1" },
|
||||
"list": [{ "token": "xyz" }],
|
||||
"plain": "nothing here",
|
||||
"path": "ai.api_key",
|
||||
"old": "",
|
||||
"new": "sk-proj-abcdefghijklmnop",
|
||||
}),
|
||||
sensitivity: Sensitivity::High,
|
||||
},
|
||||
}));
|
||||
|
||||
let details = &presenter
|
||||
.agent_rail()
|
||||
.confirm
|
||||
.as_ref()
|
||||
.expect("确认请求")
|
||||
.details;
|
||||
let placeholder = serde_json::json!("<redacted:token>");
|
||||
// 键名命中:对象 / 嵌套 / 数组各一
|
||||
assert_eq!(details["password"], placeholder);
|
||||
assert_eq!(details["api_key"], placeholder);
|
||||
assert_eq!(details["nested"]["refresh_token"], placeholder);
|
||||
assert_eq!(details["list"][0]["token"], placeholder);
|
||||
// 值随 path 敏感:old/new 一并掩码(不然 API key 就躺在确认弹窗的 diff 里)
|
||||
assert_eq!(details["new"], placeholder);
|
||||
assert_eq!(details["old"], placeholder);
|
||||
// 无关键原样保留(审计要看得出改的是什么)
|
||||
assert_eq!(details["plain"], serde_json::json!("nothing here"));
|
||||
assert_eq!(details["path"], serde_json::json!("ai.api_key"));
|
||||
|
||||
let text = rail_text(presenter.agent_rail());
|
||||
for leak in [
|
||||
"hunter2",
|
||||
"short-key-123",
|
||||
"rt-1",
|
||||
"xyz",
|
||||
"sk-proj-abcdefghijklmnop",
|
||||
] {
|
||||
assert!(!text.contains(leak), "凭据泄漏到 rail:{leak} / {text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 短 `Bearer` 令牌不是 JWT 形状,core 的 `Redactor` 会放过它(r1 P2-1)。
|
||||
#[test]
|
||||
fn short_bearer_token_is_masked() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallStarted {
|
||||
name: "read_game_log".to_string(),
|
||||
summary: "Authorization: Bearer abcdef123456".to_string(),
|
||||
}));
|
||||
let text = rail_text(presenter.agent_rail());
|
||||
assert!(
|
||||
!text.contains("abcdef123456"),
|
||||
"短 Bearer 令牌要掩码:{text}"
|
||||
);
|
||||
assert!(text.contains("Bearer <redacted:token>"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrub_text_is_idempotent_on_already_redacted_text() {
|
||||
let once = scrub_text(JWT);
|
||||
assert_eq!(scrub_text(&once), once);
|
||||
let bearer = scrub_text("Authorization: Bearer abcdef123456");
|
||||
assert_eq!(scrub_text(&bearer), bearer);
|
||||
}
|
||||
|
||||
// ───────────────── 出站闸门:发给后端的那份 payload ─────────────────
|
||||
|
||||
/// 记录 `submit` 实参的后端替身:红线里最重要的是「**真正发出去的是什么**」,
|
||||
/// 而不是 rail 上显示成什么(显示层自己还会再脱一次,会把出站的漏洞盖住)。
|
||||
#[derive(Clone, Default)]
|
||||
struct RecordingTransport {
|
||||
seen: Rc<RefCell<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl AgentTransport for RecordingTransport {
|
||||
fn submit(&mut self, text: &str, _history: &AgentRail) -> Result<Vec<AppEvent>, String> {
|
||||
self.seen.borrow_mut().push(text.to_string());
|
||||
// 照常回一条用户气泡,让整轮流程跟真实后端一致(rail 会再脱一次,所以
|
||||
// 只断言 rail 干净是抓不住出站泄漏的)
|
||||
Ok(vec![AppEvent::Agent(AgentEvent::MessageAppended(
|
||||
ChatMessage::user(text),
|
||||
))])
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _request_id: &str, _accept: bool) -> Vec<AppEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 一律报错的后端替身(用来守 Err 文案那道 scrub)。
|
||||
struct ErrorTransport(String);
|
||||
|
||||
impl AgentTransport for ErrorTransport {
|
||||
fn submit(&mut self, _text: &str, _history: &AgentRail) -> Result<Vec<AppEvent>, String> {
|
||||
Err(self.0.clone())
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _request_id: &str, _accept: bool) -> Vec<AppEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 出站守卫(r2 P1):`agent_submit` 送去后端的那句 prompt 必须已经脱敏。
|
||||
///
|
||||
/// 关键在**看后端收到什么**:rail 侧也会脱敏,所以「界面上没有 JWT」证明不了出站安全。
|
||||
#[test]
|
||||
fn outbound_prompt_is_scrubbed_before_it_reaches_the_backend() {
|
||||
let seen = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut presenter = Presenter::new(Box::new(RecordingTransport {
|
||||
seen: Rc::clone(&seen),
|
||||
}));
|
||||
presenter
|
||||
.agent_submit(format!("帮我看看这个 token:{JWT}"))
|
||||
.expect("提交应当成功");
|
||||
|
||||
let seen = seen.borrow();
|
||||
assert_eq!(seen.len(), 1, "一轮输入只该发一次");
|
||||
assert!(
|
||||
!seen[0].contains("eyJhbGci"),
|
||||
"出站 prompt 里还有 JWT 原文:{}",
|
||||
seen[0]
|
||||
);
|
||||
assert!(
|
||||
seen[0].contains("<redacted:token>"),
|
||||
"出站 prompt 应当带占位符:{}",
|
||||
seen[0]
|
||||
);
|
||||
}
|
||||
|
||||
/// 失败原因(provider 的 401 常把 key 抄进 message)同样是 UI 可见文本(r2 P2-1 / N)。
|
||||
#[test]
|
||||
fn outbound_error_text_is_scrubbed() {
|
||||
let mut presenter = Presenter::new(Box::new(ErrorTransport(format!(
|
||||
"401 Unauthorized: key={JWT}"
|
||||
))));
|
||||
let error = presenter
|
||||
.agent_submit("随便问一句".to_string())
|
||||
.expect_err("后端报错时应当返回 Err");
|
||||
assert!(!error.contains("eyJhbGci"), "Err 文案里还有 JWT:{error}");
|
||||
assert!(error.contains("<redacted:token>"), "{error}");
|
||||
}
|
||||
|
||||
// ─────────── 显示闸门:另三处 scrub 站点(r2 P2-1 / K、M)───────────
|
||||
|
||||
/// `ConfirmRequest.tool` 也过闸门(工具名来自注册表,凭据落名字不现实,但「删了没人发现」)。
|
||||
#[test]
|
||||
fn confirm_tool_name_is_scrubbed() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::ConfirmRequested {
|
||||
request: ConfirmRequest {
|
||||
id: "req-tool".to_string(),
|
||||
tool: format!("set_config_{JWT}"),
|
||||
summary: "改设置".to_string(),
|
||||
details: serde_json::json!({}),
|
||||
sensitivity: Sensitivity::High,
|
||||
},
|
||||
}));
|
||||
|
||||
let tool = &presenter
|
||||
.agent_rail()
|
||||
.confirm
|
||||
.as_ref()
|
||||
.expect("确认请求")
|
||||
.tool;
|
||||
assert!(!tool.contains("eyJhbGci"), "confirm.tool 未脱敏:{tool}");
|
||||
assert!(!rail_text(presenter.agent_rail()).contains("eyJhbGci"));
|
||||
}
|
||||
|
||||
/// `MessageAppended` 里带 `ToolCallView` 的那条分支(role = Tool)也要脱敏——
|
||||
/// mock 只会发 user,只有真实后端会走到,所以必须单独钉住。
|
||||
#[test]
|
||||
fn tool_message_from_the_backend_has_its_tool_view_scrubbed() {
|
||||
let mut presenter = presenter(Script::Qa);
|
||||
presenter.apply(AppEvent::Agent(AgentEvent::MessageAppended(ChatMessage {
|
||||
role: ChatRole::Tool,
|
||||
text: format!("工具摘要:{JWT}"),
|
||||
tool: Some(ToolCallView {
|
||||
name: format!("read_game_log_{JWT}"),
|
||||
ok: true,
|
||||
summary: format!("Authorization: Bearer {JWT}"),
|
||||
}),
|
||||
streaming: false,
|
||||
})));
|
||||
|
||||
let tool = presenter.agent_rail().messages[0]
|
||||
.tool
|
||||
.as_ref()
|
||||
.expect("工具条目");
|
||||
assert!(
|
||||
!tool.name.contains("eyJhbGci"),
|
||||
"tool.name 未脱敏:{}",
|
||||
tool.name
|
||||
);
|
||||
assert!(
|
||||
!tool.summary.contains("eyJhbGci"),
|
||||
"tool.summary 未脱敏:{}",
|
||||
tool.summary
|
||||
);
|
||||
assert!(!rail_text(presenter.agent_rail()).contains("eyJhbGci"));
|
||||
}
|
||||
|
||||
// ───────────────────────── 断言辅助 ─────────────────────────
|
||||
|
||||
/// 把 rail 上所有对用户可见的文本拼起来(红线断言用)。
|
||||
///
|
||||
/// **不含 `draft`**(见模块文档:草稿按设计可以存原文)。
|
||||
fn rail_text(rail: &AgentRail) -> String {
|
||||
let mut out = String::new();
|
||||
for message in &rail.messages {
|
||||
out.push_str(&message.text);
|
||||
if let Some(tool) = &message.tool {
|
||||
out.push_str(&tool.name);
|
||||
out.push_str(&tool.summary);
|
||||
}
|
||||
}
|
||||
out.push_str(rail.error.as_deref().unwrap_or_default());
|
||||
out.push_str(&rail.endpoint_label);
|
||||
if let Some(confirm) = &rail.confirm {
|
||||
out.push_str(&confirm.tool);
|
||||
out.push_str(&confirm.summary);
|
||||
out.push_str(&confirm.details.to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! 后端选择与启动选项:真实 core host 优先,解析不出数据根时退回 mock。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::dto::Backend;
|
||||
use crate::host::CoreHost;
|
||||
use crate::host::HostEnv;
|
||||
use crate::mock::MockBackend;
|
||||
|
||||
/// 启动选项(截图/演示通道用;正常启动全用默认值)。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AppOptions {
|
||||
/// 钉死数据根(`<root>/.ULL` + `<root>/.minecraft`);None = 按便携配置解析
|
||||
pub data_root: Option<PathBuf>,
|
||||
/// 强制 mock 后端(UI 回归/无数据根演示)
|
||||
pub mock: bool,
|
||||
}
|
||||
|
||||
/// `<root>/.ULL/config/launcher.json`(与 `SettingsBridge::open` 同落点)。
|
||||
pub fn config_path(root: &Path) -> PathBuf {
|
||||
root.join(".ULL").join("config").join("launcher.json")
|
||||
}
|
||||
|
||||
/// 选后端:真实 host 优先;数据根解析不出来或显式 `--mock` 时退回假后端。
|
||||
pub fn build_backend(options: &AppOptions) -> Box<dyn Backend> {
|
||||
if options.mock {
|
||||
return Box::new(MockBackend::new());
|
||||
}
|
||||
match &options.data_root {
|
||||
Some(root) => Box::new(CoreHost::new(HostEnv::at_root(root))),
|
||||
None => match CoreHost::open() {
|
||||
Some(host) => Box::new(host),
|
||||
None => Box::new(MockBackend::new()),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
//! 对话框队列(HMCL 队列语义 + Metro 化,09 §3)。
|
||||
//!
|
||||
//! 语义:**一次只显示一条**。新请求按来源插队(用户操作 > 后台任务 > 提示),同来源 FIFO;
|
||||
//! 当前条被确认/取消后由 [`DialogQueue::advance`] 推进到下一条,UI 只渲染 `current`。
|
||||
//! 非阻塞提示走 [`crate::toast`],不占模态队列。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 对话框标识。
|
||||
pub type DialogId = u64;
|
||||
|
||||
/// 对话框来源:决定插队优先级。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum DialogSource {
|
||||
/// 提示类(教程、首次使用说明)
|
||||
Hint = 0,
|
||||
/// 后台任务(下载完成、需要选择镜像源)
|
||||
BackgroundTask = 1,
|
||||
/// 用户操作触发(AI 敏感写操作的二次确认)
|
||||
UserAction = 2,
|
||||
}
|
||||
|
||||
impl DialogSource {
|
||||
/// 数值越大越优先。
|
||||
pub fn priority(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// 对话框类型(09 §3:确认 / 进度 / 输入 / 错误)。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DialogKind {
|
||||
/// 确认:主按钮 + 次按钮
|
||||
Confirm {
|
||||
/// 主按钮文字(如「允许」)
|
||||
primary: String,
|
||||
/// 次按钮文字(如「拒绝」)
|
||||
secondary: String,
|
||||
},
|
||||
/// 输入:单行文本
|
||||
Input {
|
||||
/// 输入框标签
|
||||
label: String,
|
||||
/// 初始值
|
||||
value: String,
|
||||
/// 主按钮文字
|
||||
primary: String,
|
||||
},
|
||||
/// 进度:不可点主按钮(任务结束后由 [`crate::dto::AppEvent::DialogClosed`] 收起)
|
||||
Progress {
|
||||
/// 阶段文案
|
||||
label: String,
|
||||
/// 0.0~1.0
|
||||
progress: f32,
|
||||
},
|
||||
/// 错误:含诊断入口提示
|
||||
Error {
|
||||
/// 主按钮文字
|
||||
primary: String,
|
||||
/// 诊断/修复提示
|
||||
hint: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl DialogKind {
|
||||
/// 主按钮文字。
|
||||
pub fn primary_label(&self) -> &str {
|
||||
match self {
|
||||
Self::Confirm { primary, .. }
|
||||
| Self::Input { primary, .. }
|
||||
| Self::Error { primary, .. } => primary,
|
||||
Self::Progress { .. } => "取消",
|
||||
}
|
||||
}
|
||||
|
||||
/// 次按钮文字(None 表示不显示次按钮)。
|
||||
pub fn secondary_label(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Confirm { secondary, .. } => Some(secondary),
|
||||
Self::Error { .. } => Some("忽略"),
|
||||
Self::Input { .. } | Self::Progress { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否可交互(进度条不可点主按钮)。
|
||||
pub fn is_blocking_progress(&self) -> bool {
|
||||
matches!(self, Self::Progress { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条对话框请求。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DialogRequest {
|
||||
/// 队列内唯一 id(由 [`DialogQueue::push_request`] 重新编号)
|
||||
pub id: DialogId,
|
||||
/// 来源(决定插队优先级)
|
||||
pub source: DialogSource,
|
||||
/// 标题
|
||||
pub title: String,
|
||||
/// 正文
|
||||
pub body: String,
|
||||
/// 类型与按钮
|
||||
pub kind: DialogKind,
|
||||
}
|
||||
|
||||
/// 模态队列:一次一条 + 来源插队。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DialogQueue {
|
||||
current: Option<DialogRequest>,
|
||||
pending: Vec<DialogRequest>,
|
||||
next_id: DialogId,
|
||||
}
|
||||
|
||||
impl DialogQueue {
|
||||
/// 新建空队列。
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 当前应显示的对话框。
|
||||
pub fn current(&self) -> Option<&DialogRequest> {
|
||||
self.current.as_ref()
|
||||
}
|
||||
|
||||
/// 排队中(不含当前)条数。
|
||||
pub fn pending_len(&self) -> usize {
|
||||
self.pending.len()
|
||||
}
|
||||
|
||||
/// 是否完全没有对话框(当前 + 排队)。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.current.is_none() && self.pending.is_empty()
|
||||
}
|
||||
|
||||
/// 入队一条请求(UI 内部调用方用,id 由队列自增分配)。没有当前条时立即成为当前条。
|
||||
///
|
||||
/// 返回值用于后续 [`Self::close`] / 进度更新。
|
||||
pub fn push(
|
||||
&mut self,
|
||||
source: DialogSource,
|
||||
title: impl Into<String>,
|
||||
body: impl Into<String>,
|
||||
kind: DialogKind,
|
||||
) -> DialogId {
|
||||
self.next_id += 1;
|
||||
self.push_request(DialogRequest {
|
||||
id: self.next_id,
|
||||
source,
|
||||
title: title.into(),
|
||||
body: body.into(),
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
/// 入队一条已经组装好的请求(事件通道用)。
|
||||
///
|
||||
/// **id 归生产者**:这里原样保留 `request.id`(不重编号),因为关闭事件
|
||||
/// [`crate::dto::AppEvent::DialogClosed`] 携带的是生产者 id;重编号会让
|
||||
/// 「任务完成 → 自动收起进度框」在多来源弹框时静默失配。生产者负责自身 id 唯一。
|
||||
pub fn push_request(&mut self, request: DialogRequest) -> DialogId {
|
||||
let id = request.id;
|
||||
let source = request.source;
|
||||
if self.current.is_none() {
|
||||
self.current = Some(request);
|
||||
} else {
|
||||
let at = self
|
||||
.pending
|
||||
.iter()
|
||||
.position(|queued| queued.source < source)
|
||||
.unwrap_or(self.pending.len());
|
||||
self.pending.insert(at, request);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// 关闭指定对话框:若是当前条则推进到下一条,否则从队列移除。
|
||||
///
|
||||
/// 返回 `true` 表示确实移除了。
|
||||
pub fn close(&mut self, id: DialogId) -> bool {
|
||||
if self
|
||||
.current
|
||||
.as_ref()
|
||||
.is_some_and(|current| current.id == id)
|
||||
{
|
||||
self.advance();
|
||||
return true;
|
||||
}
|
||||
if let Some(at) = self.pending.iter().position(|queued| queued.id == id) {
|
||||
self.pending.remove(at);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 推进到下一条排队请求(当前条已被处理)。
|
||||
pub fn advance(&mut self) {
|
||||
self.current = if self.pending.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.pending.remove(0))
|
||||
};
|
||||
}
|
||||
|
||||
/// 关闭当前条并推进到下一条(确认 / 取消 / 点遮罩都走这里)。
|
||||
///
|
||||
/// 返回 `false` 表示本来就没有当前条(幂等,双击不会误推进队列)。
|
||||
pub fn close_current(&mut self) -> bool {
|
||||
if self.current.is_none() {
|
||||
return false;
|
||||
}
|
||||
self.advance();
|
||||
true
|
||||
}
|
||||
|
||||
/// 更新进度对话框的进度(任务回调直接喂进来)。
|
||||
pub fn update_progress(&mut self, id: DialogId, progress: f32) {
|
||||
let Some(current) = &mut self.current else {
|
||||
return;
|
||||
};
|
||||
if current.id != id {
|
||||
return;
|
||||
}
|
||||
if let DialogKind::Progress {
|
||||
progress: value, ..
|
||||
} = &mut current.kind
|
||||
{
|
||||
*value = progress.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DialogKind, DialogQueue, DialogSource};
|
||||
|
||||
fn confirm() -> DialogKind {
|
||||
DialogKind::Confirm {
|
||||
primary: "确定".into(),
|
||||
secondary: "取消".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_one_dialog_at_a_time() {
|
||||
let mut queue = DialogQueue::new();
|
||||
let first = queue.push(DialogSource::BackgroundTask, "A", "", confirm());
|
||||
let second = queue.push(DialogSource::BackgroundTask, "B", "", confirm());
|
||||
|
||||
assert_eq!(queue.current().map(|d| d.id), Some(first));
|
||||
assert_eq!(queue.pending_len(), 1);
|
||||
|
||||
queue.advance();
|
||||
assert_eq!(queue.current().map(|d| d.id), Some(second));
|
||||
assert_eq!(queue.pending_len(), 0);
|
||||
|
||||
queue.advance();
|
||||
assert!(queue.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_action_jumps_the_queue() {
|
||||
let mut queue = DialogQueue::new();
|
||||
queue.push(DialogSource::Hint, "提示", "", confirm());
|
||||
queue.push(DialogSource::Hint, "提示2", "", confirm());
|
||||
queue.push(DialogSource::BackgroundTask, "后台", "", confirm());
|
||||
let urgent = queue.push(DialogSource::UserAction, "确认删除", "", confirm());
|
||||
|
||||
// 当前仍是队首(一次一条),但排队里用户操作插到最前 → 关掉当前条后立刻轮到它
|
||||
queue.advance();
|
||||
assert_eq!(queue.current().map(|d| d.id), Some(urgent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_non_current_dialog_removes_it_from_pending() {
|
||||
let mut queue = DialogQueue::new();
|
||||
let first = queue.push(DialogSource::Hint, "A", "", confirm());
|
||||
let second = queue.push(DialogSource::Hint, "B", "", confirm());
|
||||
|
||||
assert!(queue.close(second));
|
||||
assert_eq!(queue.pending_len(), 0);
|
||||
assert_eq!(queue.current().map(|d| d.id), Some(first));
|
||||
assert!(!queue.close(999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_dialog_can_be_updated() {
|
||||
let mut queue = DialogQueue::new();
|
||||
let id = queue.push(
|
||||
DialogSource::BackgroundTask,
|
||||
"下载中",
|
||||
"",
|
||||
DialogKind::Progress {
|
||||
label: "资源".into(),
|
||||
progress: 0.0,
|
||||
},
|
||||
);
|
||||
queue.update_progress(id, 1.7);
|
||||
match queue.current().map(|d| &d.kind) {
|
||||
Some(DialogKind::Progress { progress, .. }) => {
|
||||
assert!((*progress - 1.0).abs() < f32::EPSILON);
|
||||
}
|
||||
other => panic!("unexpected dialog kind: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
//! 账户页 DTO:三条轨道的统一抽象 + 微软登录(设备码 / 授权码浏览器)的可见状态。
|
||||
//!
|
||||
//! **凭据红线**:本模块(以及整个 `crate::dto`)只有 [`AccountView`] 这种公开字段视图。
|
||||
//! `core::auth` 的 `Credentials` / `MicrosoftSession` / token 永远不进 app 层——
|
||||
//! host 侧在拿到账户快照的那一刻就丢掉凭据,只留「能显示什么」。
|
||||
//!
|
||||
//! | 文件 | 职责 |
|
||||
//! |---|---|
|
||||
//! | 本文件 | 账户/登录**可见状态**(全部是可上屏字段) |
|
||||
//! | [`redact`] | 形状脱敏:JWT / 不透明串 / `token=...` 一律抹掉(第二道脱敏器) |
|
||||
//! | `tests` | 契约 round-trip + 凭据红线的入口断言 |
|
||||
|
||||
mod failure;
|
||||
mod kind;
|
||||
mod name;
|
||||
mod redact;
|
||||
#[cfg(test)]
|
||||
#[path = "accounts/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
pub use failure::{AccountNotice, AccountNoticeCode, BrowserLoginNote, LoginFailure};
|
||||
pub use kind::AccountKind;
|
||||
pub use name::valid_offline_name;
|
||||
pub use redact::{looks_like_jwt, redact, REDACTED};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 账户列表行(**只含可公开字段**)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AccountView {
|
||||
/// 稳定 id(`account:...`)
|
||||
pub id: String,
|
||||
/// 展示名
|
||||
pub name: String,
|
||||
/// 轨道
|
||||
pub kind: AccountKind,
|
||||
/// 档案 UUID(公开)
|
||||
pub profile_id: String,
|
||||
/// 登录名(微软轨才有)
|
||||
pub login_name: String,
|
||||
/// 创建时间展示串(`2026-10-31 20:12`)。
|
||||
///
|
||||
/// core 的 `AccountMeta` 只有 `created_at_ms`(无 last-used),所以列表贴「添加于 {created}」,不伪造「最近使用」。
|
||||
pub created: String,
|
||||
/// 是否是当前启动使用的账户
|
||||
pub active: bool,
|
||||
/// 第三方轨的认证服务器 API 根(其余轨为空串)
|
||||
#[serde(default)]
|
||||
pub server_url: String,
|
||||
/// 皮肤原图(第三方轨才可能有;`None` = 没有/没取到,UI 退回首字母块)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skin: Option<SkinImage>,
|
||||
}
|
||||
|
||||
impl AccountView {
|
||||
/// 首字母块上的字:展示名第一个非空字符(大写化),空名字回落 `?`。
|
||||
#[must_use]
|
||||
pub fn monogram(&self) -> String {
|
||||
self.name
|
||||
.chars()
|
||||
.find(|c| !c.is_whitespace())
|
||||
.map(|c| c.to_uppercase().to_string())
|
||||
.unwrap_or_else(|| "?".to_string())
|
||||
}
|
||||
|
||||
/// 首字母块取色索引(0..6):由 `profile_id` 稳定推导,同一个人每次同色。
|
||||
#[must_use]
|
||||
pub fn monogram_tone(&self) -> u8 {
|
||||
let mut hash: u32 = 2_166_136_261;
|
||||
for byte in self.profile_id.as_bytes() {
|
||||
hash ^= u32::from(*byte);
|
||||
hash = hash.wrapping_mul(16_777_619);
|
||||
}
|
||||
u8::try_from(hash % 6).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一张皮肤原图。**停在「原图」这层是刻意的**:presenter 不引图片解码依赖,裁剪/缩放归 UI 层;
|
||||
/// 离线轨永远没有它——没有皮肤就不给假皮肤。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SkinImage {
|
||||
/// PNG 字节(Minecraft 皮肤:64×64 或旧版 64×32)
|
||||
pub png: Vec<u8>,
|
||||
/// 来源 URL(**只进取证日志,不上屏**)
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
impl SkinImage {
|
||||
/// 是否有可用字节(0 字节等同没有皮肤)。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.png.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 微软登录阶段(UI 据此切换面板;两条流共用)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DeviceLoginPhase {
|
||||
/// 没有登录在进行
|
||||
Idle,
|
||||
/// 正在向微软申请设备码(短暂)
|
||||
Requesting,
|
||||
/// 已拿到用户码,等待用户在浏览器完成
|
||||
Waiting,
|
||||
/// 成功(账户已入库)
|
||||
Succeeded,
|
||||
/// 失败(原因在 `message`,已脱敏)
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl DeviceLoginPhase {
|
||||
/// 面板是否要显示(非 Idle 都显示)。
|
||||
pub fn visible(self) -> bool {
|
||||
!matches!(self, Self::Idle)
|
||||
}
|
||||
|
||||
/// 是否还在跑(Requesting / Waiting)。
|
||||
pub fn busy(self) -> bool {
|
||||
matches!(self, Self::Requesting | Self::Waiting)
|
||||
}
|
||||
}
|
||||
|
||||
/// 设备码登录的可见状态。
|
||||
///
|
||||
/// `user_code` / `verification_uri` 来自 `core::auth::microsoft::DeviceCodePrompt`(给用户看的,不是凭据)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DeviceLoginView {
|
||||
/// 阶段
|
||||
pub phase: DeviceLoginPhase,
|
||||
/// 用户码(`ABCD-EFGH`)
|
||||
pub user_code: String,
|
||||
/// 验证地址(`https://www.microsoft.com/link`)
|
||||
pub verification_uri: String,
|
||||
/// 二维码内容(= 带码的链接)
|
||||
pub qr_link: String,
|
||||
/// 有效期(秒;0 = 未知)
|
||||
pub expires_in: u64,
|
||||
/// 状态/错误文案(**失败时是原始诊断,只进日志,不上屏**)
|
||||
pub message: String,
|
||||
/// 失败原因码(`None` = 不是失败态 / 未分类)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub failure: Option<LoginFailure>,
|
||||
/// 浏览器登录没走成、已回退设备码的说明码(M178;`None` = 没这回事)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub browser_note: Option<BrowserLoginNote>,
|
||||
/// 成功后入库的账户展示名
|
||||
pub account_name: String,
|
||||
}
|
||||
|
||||
impl Default for DeviceLoginView {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceLoginView {
|
||||
/// 空闲态。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Idle,
|
||||
user_code: String::new(),
|
||||
verification_uri: String::new(),
|
||||
qr_link: String::new(),
|
||||
expires_in: 0,
|
||||
message: String::new(),
|
||||
failure: None,
|
||||
browser_note: None,
|
||||
account_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 已拿到用户码(`present` 回调时刻)。
|
||||
pub fn waiting(
|
||||
user_code: &str,
|
||||
verification_uri: &str,
|
||||
expires_in: u64,
|
||||
qr_hint: &str,
|
||||
) -> Self {
|
||||
let qr_link = if qr_hint.is_empty() {
|
||||
format!("{verification_uri}?code={user_code}")
|
||||
} else {
|
||||
qr_hint.to_string()
|
||||
};
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Waiting,
|
||||
user_code: user_code.to_string(),
|
||||
verification_uri: verification_uri.to_string(),
|
||||
qr_link,
|
||||
expires_in,
|
||||
message: format!(
|
||||
"在浏览器打开 {verification_uri} 并输入用户码 {user_code}({} 秒内有效)",
|
||||
expires_in
|
||||
),
|
||||
failure: None,
|
||||
browser_note: None,
|
||||
account_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 授权码流:本地回调已就绪、浏览器已拉起(`present` 回调时刻)。
|
||||
///
|
||||
/// 面板不展示用户码,改展示**可复制的授权链接**;`warning` 是「浏览器没拉起来」这类非致命提示。
|
||||
pub fn browser_waiting(
|
||||
authorization_url: &str,
|
||||
expires_in: u64,
|
||||
warning: Option<&str>,
|
||||
) -> Self {
|
||||
let mut message = format!(
|
||||
"已在系统浏览器打开微软登录页,请完成授权({} 秒内有效);没弹出就把下面的链接粘到浏览器里",
|
||||
expires_in.max(1)
|
||||
);
|
||||
if let Some(warning) = warning {
|
||||
message.push_str(&format!("({})", redact(warning)));
|
||||
}
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Waiting,
|
||||
user_code: String::new(),
|
||||
verification_uri: authorization_url.to_string(),
|
||||
qr_link: authorization_url.to_string(),
|
||||
expires_in,
|
||||
message,
|
||||
failure: None,
|
||||
browser_note: None,
|
||||
account_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 附一条回退说明码(M178)。与 `message`(原文只进日志)分开:页面只拿码去查 i18n。
|
||||
#[must_use]
|
||||
pub fn with_browser_note(mut self, note: BrowserLoginNote) -> Self {
|
||||
self.browser_note = Some(note);
|
||||
self
|
||||
}
|
||||
|
||||
/// 失败态(文案在入口就脱敏:错误原文里可能带着服务端回显的 token)。
|
||||
pub fn failed(message: impl Into<String>) -> Self {
|
||||
Self::failed_as(LoginFailure::Other, message)
|
||||
}
|
||||
|
||||
/// 带**原因码**的失败态(页面据码出 i18n;原文只进日志)。
|
||||
pub fn failed_as(failure: LoginFailure, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Failed,
|
||||
message: redact(&message.into()),
|
||||
failure: Some(failure),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 成功态。
|
||||
pub fn succeeded(account_name: impl Into<String>) -> Self {
|
||||
let account_name = account_name.into();
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Succeeded,
|
||||
message: format!("已登录:{account_name}"),
|
||||
failure: None,
|
||||
account_name,
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 第三方轨(authlib-injector)登录请求。
|
||||
///
|
||||
/// **凭据红线**:`password` 只是「这一次输入的请求值」,host 取走即弃——不入账户库、不进日志、
|
||||
/// 不进任何回程 DTO。所以本类型只有一个方向(UI → host),回程只有 [`AuthlibLoginView`]。
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuthlibRequest {
|
||||
/// 认证服务器地址(用户原样输入;host 侧 `normalize_api_root` 规范化)
|
||||
pub server: String,
|
||||
/// 登录名(邮箱或用户名)
|
||||
pub login: String,
|
||||
/// 密码(只在本调用栈里出现)
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// **手写 `Debug`**:任何 `{:?}`(日志/panic 消息/测试失败输出)都不得带出明文密码。
|
||||
impl std::fmt::Debug for AuthlibRequest {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AuthlibRequest")
|
||||
.field("server", &redact(&self.server))
|
||||
.field("login", &redact(&self.login))
|
||||
.field("password", &REDACTED)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthlibRequest {
|
||||
/// 构造。
|
||||
pub fn new(
|
||||
server: impl Into<String>,
|
||||
login: impl Into<String>,
|
||||
password: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
server: server.into(),
|
||||
login: login.into(),
|
||||
password: password.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 可打印形态(日志/诊断/错误路径用):**密码恒被抹掉**。
|
||||
#[must_use]
|
||||
pub fn redacted(&self) -> String {
|
||||
format!(
|
||||
"server={} login={} password={REDACTED}",
|
||||
redact(&self.server),
|
||||
redact(&self.login)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 第三方轨登录的可见状态(阶段复用 [`DeviceLoginPhase`]:「申请 → 等待 → 成/败」)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuthlibLoginView {
|
||||
/// 阶段
|
||||
pub phase: DeviceLoginPhase,
|
||||
/// 认证服务器地址(规范化后的 API 根)
|
||||
pub server: String,
|
||||
/// 服务发现里的服务器名(校验通过后才有;空 = 没校验)
|
||||
pub server_name: String,
|
||||
/// 状态/错误文案(**失败时是原始诊断,只进日志,不上屏**)
|
||||
pub message: String,
|
||||
/// 失败原因码(`None` = 不是失败态 / 未分类)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub failure: Option<LoginFailure>,
|
||||
/// 成功后入库的账户展示名
|
||||
pub account_name: String,
|
||||
}
|
||||
|
||||
impl Default for AuthlibLoginView {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthlibLoginView {
|
||||
/// 空闲态。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Idle,
|
||||
server: String::new(),
|
||||
server_name: String::new(),
|
||||
message: String::new(),
|
||||
failure: None,
|
||||
account_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 进行中(校验服务器地址 / 换 token)。
|
||||
pub fn running(server: &str, message: &str) -> Self {
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Requesting,
|
||||
server: server.to_string(),
|
||||
server_name: String::new(),
|
||||
message: redact(message),
|
||||
failure: None,
|
||||
account_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 地址校验通过(服务发现拿到了服务器名)。
|
||||
pub fn reachable(server: &str, server_name: &str) -> Self {
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Waiting,
|
||||
server: server.to_string(),
|
||||
server_name: redact(server_name),
|
||||
message: format!("服务器可用:{}", server_name.trim()),
|
||||
failure: None,
|
||||
account_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败态(文案在入口就脱敏)。
|
||||
pub fn failed(message: impl Into<String>) -> Self {
|
||||
Self::failed_as(LoginFailure::Other, message)
|
||||
}
|
||||
|
||||
/// 带**原因码**的失败态(页面据码出 i18n;原文只进日志)。
|
||||
pub fn failed_as(failure: LoginFailure, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Failed,
|
||||
message: redact(&message.into()),
|
||||
failure: Some(failure),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 成功态。
|
||||
pub fn succeeded(server_name: &str, account_name: impl Into<String>) -> Self {
|
||||
let account_name = account_name.into();
|
||||
Self {
|
||||
phase: DeviceLoginPhase::Succeeded,
|
||||
server_name: server_name.to_string(),
|
||||
message: format!("已登录:{account_name}"),
|
||||
account_name,
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! 失败原因码与账户页横幅(M114 r2 / 复审 P1-1、P2-2)。
|
||||
//!
|
||||
//! ## 为什么这一层必须有
|
||||
//!
|
||||
//! core/presenter 的诊断串是中文人话。把它们直接画到英文界面上,就是复审 r1 那条 p1
|
||||
//! (「全英文界面里整句中文」)。根因不是 catalog 缺条目,而是**后端诊断串没有结构**:
|
||||
//! 页面除了原样画没有别的选择。
|
||||
//!
|
||||
//! 这里补的就是那个缺环——把「失败属于哪一类」「这次操作的结果是什么」变成**枚举**。
|
||||
//! 页面据 `code()` 选 i18n key 出标题 + 正文 + 一行 `Code: …`;原始诊断留在 `message` /
|
||||
//! `detail` 里,**只进日志与详细信息,不上屏**。
|
||||
//!
|
||||
//! ## 唯一一个「说清原因」的例外(M132)
|
||||
//!
|
||||
//! [`LoginFailure::MsaNotConfigured`]。它上屏的**不是后端诊断串**,而是 catalog 里一条固定
|
||||
//! 文案(`Key::AccountFailureMsaNotConfigured` / `Key::AccountLoginNotConfigured`,不含任何
|
||||
//! 凭据、token 或内部细节)——「原文不上屏」这条纪律原样保留,页面仍拿不到、也画不出
|
||||
//! `message`/`detail`。允许它说清原因的理由只有一条:**原因本身就是用户能执行的下一步**
|
||||
//! (缺 `ULL_MSA_CLIENT_ID`,设上或构建时注入即可)。判据:没读过代码的用户碰上它,必须能
|
||||
//! 从界面自己看出「是缺一个配置」并知道怎么补,而不是去翻日志。其余一切(网络 / 拒绝 /
|
||||
//! 落盘…)仍旧只给码与分类文案,原文只进日志。
|
||||
//!
|
||||
//! 约束:`code()` 一律 ascii 且**不随语言变**(日志、测试、用户报障都靠它对齐)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::redact;
|
||||
|
||||
/// 登录失败的**结构化原因码**(M114 r2 / 复审 P1-1)。
|
||||
///
|
||||
/// 为什么要有它:core/presenter 的诊断串是中文人话,**直接上屏**会让英文界面里出现整句中文
|
||||
/// (复审 r1 的 p1)。这里把「失败属于哪一类」变成**可判据的枚举**,页面据码选 i18n key 出
|
||||
/// 标题 + 正文 + 一个稳定 code;原始诊断只留在 `message` 里进日志/详细信息,**不上屏**。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LoginFailure {
|
||||
/// 服务器/微软明确拒绝(403、OAuth `error`、XErr)
|
||||
Denied,
|
||||
/// 应用注册还没获批(AADSTS7000218 一类「白名单未批」)
|
||||
NotWhitelisted,
|
||||
/// 网络不可达 / 超时
|
||||
Unreachable,
|
||||
/// 目标地址不是 authlib-injector 服务器(响应不是服务发现 JSON)
|
||||
NotAuthlibServer,
|
||||
/// 服务器拒绝了这个账号(密码错 / 账号不存在)
|
||||
BadCredentials,
|
||||
/// 服务器没有返回角色(账号还没创建角色)
|
||||
NoProfile,
|
||||
/// 落盘失败(账户库读写)
|
||||
StoreFailed,
|
||||
/// 微软轨的 client id 没配(`ULL_MSA_CLIENT_ID` 运行期/构建期两处都没有)——**配置缺失**,
|
||||
/// 不是登录被拒、也不是「没实现」(M132)。
|
||||
///
|
||||
/// 单独成码的理由见模块文档「唯一一个『说清原因』的例外」:这一类的原因就是用户能执行的
|
||||
/// 下一步,所以它的正文必须说清「要设哪个环境变量」,而不是「完整原因在日志里」。
|
||||
MsaNotConfigured,
|
||||
/// 其它(未知原因;原文只在 `message`)
|
||||
Other,
|
||||
}
|
||||
|
||||
impl LoginFailure {
|
||||
/// 稳定的 ascii code(日志 / 页面「Code: …」/ 测试断言都用它,**不随语言变**)。
|
||||
#[must_use]
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::Denied => "denied",
|
||||
Self::NotWhitelisted => "whitelist_pending",
|
||||
Self::Unreachable => "unreachable",
|
||||
Self::NotAuthlibServer => "not_authlib_server",
|
||||
Self::BadCredentials => "bad_credentials",
|
||||
Self::NoProfile => "no_profile",
|
||||
Self::StoreFailed => "store_failed",
|
||||
Self::MsaNotConfigured => "msa_not_configured",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 「浏览器登录没走成,已自动改用设备码」的说明码(M178)。
|
||||
///
|
||||
/// 为什么与 [`LoginFailure`] 分开:这不是登录失败——设备码流接上了,用户照常在等码。
|
||||
/// 它解释的是「为什么面板变成了设备码」以及**要不要去改应用注册**,所以单独一个码族,
|
||||
/// 页面画成一行提示而不是错误横幅。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BrowserLoginNote {
|
||||
/// 应用注册里没有回环重定向 URI(授权端点当场拒绝)——**唯一带「怎么修」的一句**
|
||||
RedirectNotRegistered,
|
||||
/// 预检没问出结论(网络/超时),只能先用设备码
|
||||
PreflightUnavailable,
|
||||
/// 预检通过、授权码流仍然失败(超时 / 换 token 被拒 / 端口绑不上)
|
||||
AttemptFailed,
|
||||
}
|
||||
|
||||
impl BrowserLoginNote {
|
||||
/// 稳定 ascii code(日志/测试用,**不随语言变**)。
|
||||
#[must_use]
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::RedirectNotRegistered => "redirect_not_registered",
|
||||
Self::PreflightUnavailable => "preflight_unavailable",
|
||||
Self::AttemptFailed => "attempt_failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 账户页的**一次性横幅**(添加/删除/切换的结果;页面据 `code` 选 i18n)。
|
||||
/// 为什么不是 toast:app-iced 不渲染 toast(复审 r1 P2-2 实锤),发 toast 等于用户什么都看不到。
|
||||
/// 横幅是页面上的真实控件,用户点了「Create profile」失败后一定看得见。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AccountNotice {
|
||||
/// 结果码(决定标题/正文/颜色)
|
||||
pub code: AccountNoticeCode,
|
||||
/// 涉及的账户名或服务器(拼进文案;可为空)
|
||||
pub subject: String,
|
||||
/// 原始诊断(已脱敏;**只进日志/详细信息,不上屏**)
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl AccountNotice {
|
||||
/// 构造。
|
||||
pub fn new(
|
||||
code: AccountNoticeCode,
|
||||
subject: impl Into<String>,
|
||||
detail: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
code,
|
||||
subject: subject.into(),
|
||||
detail: redact(&detail.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 横幅的结果码。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AccountNoticeCode {
|
||||
/// 已添加
|
||||
Added,
|
||||
/// 已删除
|
||||
Removed,
|
||||
/// 已切换
|
||||
Switched,
|
||||
/// 名字不合法(长度/字符集)
|
||||
OfflineNameInvalid,
|
||||
/// 同名账户已存在
|
||||
AccountExists,
|
||||
/// 落盘失败
|
||||
StoreFailed,
|
||||
/// 找不到这个账户
|
||||
NotFound,
|
||||
/// 其它(原文在 `detail`)
|
||||
Other,
|
||||
}
|
||||
|
||||
impl AccountNoticeCode {
|
||||
/// 是否是一个「失败」结果(决定横幅配色)。
|
||||
#[must_use]
|
||||
pub const fn is_failure(self) -> bool {
|
||||
!matches!(self, Self::Added | Self::Removed | Self::Switched)
|
||||
}
|
||||
|
||||
/// 稳定 ascii code(日志/测试用)。
|
||||
#[must_use]
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::Added => "added",
|
||||
Self::Removed => "removed",
|
||||
Self::Switched => "switched",
|
||||
Self::OfflineNameInvalid => "offline_name_invalid",
|
||||
Self::AccountExists => "account_exists",
|
||||
Self::StoreFailed => "store_failed",
|
||||
Self::NotFound => "not_found",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! 账户轨道(三轨统一抽象,`09 §G6` / `D9`)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 账户轨道(三轨统一抽象,`09 §G6` / `D9`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AccountKind {
|
||||
/// 离线账户(HMCL 兼容 UUID)
|
||||
Offline,
|
||||
/// 微软正版
|
||||
Microsoft,
|
||||
/// authlib-injector(第三方皮肤站)
|
||||
Authlib,
|
||||
}
|
||||
|
||||
impl AccountKind {
|
||||
/// 徽标文字(CLI/终端用;GUI 走 i18n 词表,不读这里的中文)。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Offline => "离线",
|
||||
Self::Microsoft => "微软",
|
||||
Self::Authlib => "第三方",
|
||||
}
|
||||
}
|
||||
|
||||
/// 稳定 id(事件/持久化用;与 core 的 `kind.as_str()` 同词表)。
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Offline => "offline",
|
||||
Self::Microsoft => "microsoft",
|
||||
Self::Authlib => "authlib_injector",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 core 的稳定 id 解析(未知轨道按离线处理,不让一个坏条目毁掉整个列表)。
|
||||
pub fn parse(raw: &str) -> Self {
|
||||
match raw {
|
||||
"microsoft" => Self::Microsoft,
|
||||
"authlib_injector" => Self::Authlib,
|
||||
_ => Self::Offline,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! 离线档案的名字契约(**UI 与 host 共用的同一个判据**)。
|
||||
//!
|
||||
//! 放在 `dto` 而不是 host 里:UI 用它决定「创建档案」按钮亮不亮,host 用它做写盘前校验。
|
||||
//! 两处各写一遍,迟早会漂成「按钮亮着但必然失败」——那正是复审 r1 P2-2 的一半。
|
||||
//! core 侧还有一道同名规则做兜底(`auth::offline::offline_meta`),这里只管界面这条。
|
||||
|
||||
/// 3–16 位 ascii 字母 / 数字 / 下划线。
|
||||
#[must_use]
|
||||
pub fn valid_offline_name(name: &str) -> bool {
|
||||
let len = name.chars().count();
|
||||
(3..=16).contains(&len) && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_rule_is_three_to_sixteen_ascii_word_chars() {
|
||||
assert!(valid_offline_name("Steve"));
|
||||
assert!(valid_offline_name("a_1"));
|
||||
assert!(valid_offline_name("abcdefghijklmnop"), "16 位是上限");
|
||||
assert!(!valid_offline_name("ab"), "太短");
|
||||
assert!(!valid_offline_name("abcdefghijklmnopq"), "太长");
|
||||
assert!(!valid_offline_name("has space"));
|
||||
assert!(!valid_offline_name("中文名字"), "非 ascii");
|
||||
assert!(!valid_offline_name(""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! 凭据红线的**形状脱敏**:把 JWT / 不透明 token / `token=...` 这类串从任何要上屏的文案里抹掉。
|
||||
//!
|
||||
//! ## 为什么还要第二道脱敏器
|
||||
//!
|
||||
//! `core::launch::redact` 是**按已知明文值**替换的(拿着会话里的 token 原串去抹)。这里要挡的是
|
||||
//! **没登记过的**字符串:服务端在错误响应里回显一个 token、HTTP 库把 header 拼进错误文案、
|
||||
//! 某段调试串被画到面板上。两者互补,缺一不可:
|
||||
//!
|
||||
//! | 场景 | `core::launch::redact` | 本模块 |
|
||||
//! |---|---|---|
|
||||
//! | 已知会话 token 出现在日志里 | ✅ | ❌(不知道明文) |
|
||||
//! | 服务端回包里的陌生 JWT 被画到错误面板 | ❌ | ✅ |
|
||||
//!
|
||||
//! 走形状识别(三段 base64url / 超长不透明串 / 敏感键名)就够拦住「一眼能看出是凭据」的串,
|
||||
//! 而普通文案(URL、用户码 `ABCD-EFGH`、服务器名)**一个字都不动**——脱敏器本身不能变成噪音源。
|
||||
|
||||
/// 脱敏占位符(**故意刺眼**:宁可难看,也不许静默把凭据画到屏幕上)。
|
||||
pub const REDACTED: &str = "⟪redacted⟫";
|
||||
|
||||
/// 把一段要上屏的文案里的凭据形状串抹掉(空白与其余字符**原样保留**)。
|
||||
#[must_use]
|
||||
pub fn redact(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for piece in text.split_inclusive(char::is_whitespace) {
|
||||
let split = piece.find(char::is_whitespace).unwrap_or(piece.len());
|
||||
let (chunk, space) = piece.split_at(split);
|
||||
out.push_str(&redact_chunk(chunk));
|
||||
out.push_str(space);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 单个「词」的脱敏:① 敏感键名的值 ② 整个词本身就是凭据形状。
|
||||
fn redact_chunk(chunk: &str) -> String {
|
||||
if let Some((key, _value)) = chunk.split_once('=') {
|
||||
if is_sensitive_key(key) || looks_opaque(chunk) {
|
||||
return format!("{key}={REDACTED}");
|
||||
}
|
||||
}
|
||||
if looks_opaque(chunk) {
|
||||
return REDACTED.to_string();
|
||||
}
|
||||
chunk.to_string()
|
||||
}
|
||||
|
||||
/// 键名是否属于「它的值一定是凭据」(`accessToken` / `refresh_token` / `password` …)。
|
||||
fn is_sensitive_key(key: &str) -> bool {
|
||||
let key = key
|
||||
.trim_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
|
||||
.to_lowercase();
|
||||
[
|
||||
"token",
|
||||
"secret",
|
||||
"password",
|
||||
"passwd",
|
||||
"authorization",
|
||||
"bearer",
|
||||
"session",
|
||||
]
|
||||
.iter()
|
||||
.any(|needle| key.contains(needle))
|
||||
}
|
||||
|
||||
/// 词的形状是否像凭据:① 三段 base64url(JWT)② 长度 ≥40 的不透明串。
|
||||
fn looks_opaque(chunk: &str) -> bool {
|
||||
let trimmed = chunk.trim_matches(|c: char| {
|
||||
!c.is_ascii_alphanumeric() && c != '_' && c != '-' && c != '.' && c != '+'
|
||||
});
|
||||
looks_like_jwt(trimmed) || (trimmed.len() >= 40 && trimmed.chars().all(is_base64url))
|
||||
}
|
||||
|
||||
/// 是否是 JWT 形状:`header.payload.signature`,三段都非空且够长、全是 base64url 字符。
|
||||
///
|
||||
/// 公开是为了单测能直接钉这条判据(UI 侧「注入 JWT 必须被抹掉」的测试也用它)。
|
||||
#[must_use]
|
||||
pub fn looks_like_jwt(token: &str) -> bool {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
parts.len() == 3
|
||||
&& parts
|
||||
.iter()
|
||||
.all(|part| part.len() >= 8 && part.chars().all(is_base64url))
|
||||
}
|
||||
|
||||
/// base64url 字母表(含 `=` 填充与 `+`/`/` 的宽容分支)。
|
||||
fn is_base64url(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '+' | '/' | '=' | '.')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 标准 JWT 形状(2 段 header/payload + 1 段签名)。
|
||||
const JWT: &str =
|
||||
"eyJhbGciOiJSUzI1NiIsIng1dCI6IkFCQyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVl";
|
||||
|
||||
#[test]
|
||||
fn the_jwt_shape_judgement_is_not_a_length_heuristic() {
|
||||
assert!(looks_like_jwt(JWT));
|
||||
assert!(!looks_like_jwt("not.a.jwt"), "短段不算 JWT");
|
||||
assert!(!looks_like_jwt("a.b.c.d"), "四段不算 JWT");
|
||||
assert!(!looks_like_jwt("ABCD-EFGH"), "设备码不是 JWT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_jwts_and_sensitive_pairs_are_redacted() {
|
||||
assert_eq!(redact(JWT), REDACTED);
|
||||
assert_eq!(
|
||||
redact(&format!("Bearer {JWT}")),
|
||||
format!("Bearer {REDACTED}")
|
||||
);
|
||||
assert_eq!(
|
||||
redact(&format!("accessToken={JWT}")),
|
||||
format!("accessToken={REDACTED}")
|
||||
);
|
||||
// 敏感键名 + 短值(真实 refresh token 常常很短,靠键名兜)
|
||||
assert_eq!(redact("password=hunter2"), format!("password={REDACTED}"));
|
||||
assert_eq!(
|
||||
redact("refresh_token=abc"),
|
||||
format!("refresh_token={REDACTED}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_copy_is_left_byte_for_byte() {
|
||||
for text in [
|
||||
"在浏览器打开 https://www.microsoft.com/link 并输入 ABCD-EFGH",
|
||||
"服务器不可达:dns error",
|
||||
"HTTP 403 Forbidden — 该服务器要求白名单",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(redact(text), text, "普通文案被改了:{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_and_line_breaks_survive() {
|
||||
let text = format!("第一行 token={JWT}\n\n第二行 缩进");
|
||||
let out = redact(&text);
|
||||
assert!(out.contains("\n\n"), "{out}");
|
||||
assert!(out.contains(" 缩进"), "{out}");
|
||||
assert!(!out.contains(JWT), "{out}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! 账户 DTO 的契约测试(凭据红线的主要断言都在这里)。
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kind_round_trips_through_stable_ids() {
|
||||
for kind in [
|
||||
AccountKind::Offline,
|
||||
AccountKind::Microsoft,
|
||||
AccountKind::Authlib,
|
||||
] {
|
||||
assert_eq!(AccountKind::parse(kind.as_str()), kind);
|
||||
assert!(!kind.label().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_login_view_never_carries_tokens() {
|
||||
let view = DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "");
|
||||
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
|
||||
assert!(view.phase.busy());
|
||||
assert!(view.qr_link.contains("ABCD-EFGH"));
|
||||
let json = serde_json::to_string(&view).expect("序列化");
|
||||
for forbidden in ["token", "secret", "password", "refresh"] {
|
||||
assert!(
|
||||
!json.to_lowercase().contains(forbidden),
|
||||
"视图里不该出现 {forbidden}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_waiting_shows_a_copyable_link_instead_of_a_user_code() {
|
||||
let view = DeviceLoginView::browser_waiting(
|
||||
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=x",
|
||||
300,
|
||||
Some("拉起浏览器失败"),
|
||||
);
|
||||
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
|
||||
assert!(view.phase.busy());
|
||||
assert!(view.user_code.is_empty(), "授权码流没有用户码");
|
||||
assert_eq!(view.qr_link, view.verification_uri);
|
||||
assert!(view.message.contains("浏览器"));
|
||||
assert!(view.message.contains("拉起浏览器失败"), "{}", view.message);
|
||||
let json = serde_json::to_string(&view).expect("序列化");
|
||||
for forbidden in ["refresh", "mc-access"] {
|
||||
assert!(!json.contains(forbidden), "视图里不该出现 {forbidden}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn succeeded_and_failed_clear_the_code() {
|
||||
let ok = DeviceLoginView::succeeded("Steve");
|
||||
assert_eq!(ok.phase, DeviceLoginPhase::Succeeded);
|
||||
assert!(ok.user_code.is_empty());
|
||||
let bad = DeviceLoginView::failed("网络超时");
|
||||
assert_eq!(bad.phase, DeviceLoginPhase::Failed);
|
||||
assert_eq!(bad.message, "网络超时");
|
||||
}
|
||||
|
||||
/// 凭据红线(审核项):JWT 形状串从**任何**入口进来都必须被抹掉。
|
||||
///
|
||||
/// 抓得住:`redact` 被摘掉、判据被放宽成「长度 ≥40 就行」、或者某个构造器绕过它。
|
||||
/// 抓不住:真实凭据的明文值(那是 `core::launch::redact` 的活儿,按已知值替换)。
|
||||
#[test]
|
||||
fn jwt_shaped_text_is_redacted_at_every_entry_point() {
|
||||
let jwt =
|
||||
"eyJhbGciOiJSUzI1NiIsIng1dCI6IkFCQyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVl";
|
||||
|
||||
let device = DeviceLoginView::failed(format!("登录失败:accessToken={jwt}"));
|
||||
assert!(!device.message.contains(jwt), "{}", device.message);
|
||||
assert!(device.message.contains(REDACTED), "{}", device.message);
|
||||
|
||||
let authlib = AuthlibLoginView::failed(format!("服务器回包异常 {jwt}"));
|
||||
assert!(!authlib.message.contains(jwt), "{}", authlib.message);
|
||||
assert!(authlib.message.contains(REDACTED), "{}", authlib.message);
|
||||
|
||||
let ready = AuthlibLoginView::reachable("https://littleskin.cn/", "LittleSkin");
|
||||
assert!(!ready.message.contains(jwt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authlib_request_never_prints_the_password() {
|
||||
let request = AuthlibRequest::new(
|
||||
"https://littleskin.cn/api/yggdrasil/",
|
||||
"me@example.com",
|
||||
"hunter2",
|
||||
);
|
||||
let shown = request.redacted();
|
||||
assert!(!shown.contains("hunter2"), "{shown}");
|
||||
assert!(shown.contains("littleskin"), "{shown}");
|
||||
assert!(shown.contains(REDACTED), "{shown}");
|
||||
}
|
||||
|
||||
/// `Debug` 也不许带出密码(复审 r1 P3-5):日志一行 `{:?}` 就破红线。
|
||||
#[test]
|
||||
fn authlib_request_debug_never_prints_the_password() {
|
||||
let request = AuthlibRequest::new("https://littleskin.cn/api/yggdrasil/", "me", "hunter2");
|
||||
let shown = format!("{request:?}");
|
||||
assert!(!shown.contains("hunter2"), "{shown}");
|
||||
assert!(shown.contains(REDACTED), "{shown}");
|
||||
assert!(shown.contains("littleskin"), "{shown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authlib_view_carries_the_server_name_and_no_tokens() {
|
||||
let view = AuthlibLoginView::reachable("https://littleskin.cn/api/yggdrasil/", "LittleSkin");
|
||||
assert_eq!(view.phase, DeviceLoginPhase::Waiting);
|
||||
assert_eq!(view.server_name, "LittleSkin");
|
||||
assert!(view.message.contains("LittleSkin"));
|
||||
|
||||
let ok = AuthlibLoginView::succeeded("LittleSkin", "Steve");
|
||||
assert_eq!(ok.phase, DeviceLoginPhase::Succeeded);
|
||||
assert_eq!(ok.account_name, "Steve");
|
||||
let json = serde_json::to_string(&ok).expect("序列化");
|
||||
for forbidden in ["accessToken", "clientToken", "refresh", "password"] {
|
||||
assert!(!json.contains(forbidden), "视图里不该出现 {forbidden}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_view_serializes_without_credentials() {
|
||||
let view = AccountView {
|
||||
id: "account:a".to_string(),
|
||||
name: "Steve".to_string(),
|
||||
kind: AccountKind::Authlib,
|
||||
profile_id: "0f8f".to_string(),
|
||||
login_name: "me@example.com".to_string(),
|
||||
created: "2026-10-31 20:12".to_string(),
|
||||
active: true,
|
||||
server_url: "https://littleskin.cn/api/yggdrasil/".to_string(),
|
||||
skin: Some(SkinImage {
|
||||
png: vec![1, 2, 3],
|
||||
source: "textures".to_string(),
|
||||
}),
|
||||
};
|
||||
let json = serde_json::to_string(&view).expect("序列化");
|
||||
for forbidden in ["token", "secret", "password", "refresh"] {
|
||||
assert!(
|
||||
!json.to_lowercase().contains(forbidden),
|
||||
"账户视图里不该出现 {forbidden}"
|
||||
);
|
||||
}
|
||||
assert!(json.contains("\"png\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monogram_is_stable_and_never_empty() {
|
||||
let mut view = AccountView {
|
||||
id: "account:a".to_string(),
|
||||
name: " steve".to_string(),
|
||||
kind: AccountKind::Microsoft,
|
||||
profile_id: "0f8f".to_string(),
|
||||
login_name: String::new(),
|
||||
created: String::new(),
|
||||
active: false,
|
||||
server_url: String::new(),
|
||||
skin: None,
|
||||
};
|
||||
assert_eq!(view.monogram(), "S");
|
||||
let tone = view.monogram_tone();
|
||||
assert!(tone < 6);
|
||||
view.profile_id = "0f8f".to_string();
|
||||
assert_eq!(view.monogram_tone(), tone, "同一个人每次同一个色块");
|
||||
view.name = String::new();
|
||||
assert_eq!(view.monogram(), "?", "空名字也要有字,不能画白块");
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
//! 「导入已有版本」的 IPC 面(M223 GUIADOPT):扫描 → 预检 → 确认三段。
|
||||
//!
|
||||
//! 能力本身在 core(`unlucky_core::game::adopt`,M221 的契约见
|
||||
//! `DevDocs/SubAgentsTalk/adoptcore-契约.md`);本文件只有**可序列化的视图**,
|
||||
//! 一个 `unlucky_core::*` 类型都不出现(AGENTS §6 的跨边界纪律)。
|
||||
//!
|
||||
//! ## 三段:前两段零写盘,第三段只写 ULL 自己的配置
|
||||
//!
|
||||
//! | 段 | 指令 | 回程 | 写盘 |
|
||||
//! |---|---|---|---|
|
||||
//! | 扫描 | [`AdoptCommand::Scan`] | [`AdoptEvent::Scan`] | 零(不建目录、不改 mtime) |
|
||||
//! | 预检 | [`AdoptCommand::Preview`] | [`AdoptEvent::Planned`] / [`AdoptEvent::Failed`] | 零 |
|
||||
//! | 确认 | [`AdoptCommand::Confirm`] | [`AdoptEvent::Done`] / [`AdoptEvent::Failed`] | 实例设置 + `profiles.json`(+ 缺省运行目录) |
|
||||
//!
|
||||
//! ## 三条硬纪律(契约 §5 的 1/2/3)
|
||||
//!
|
||||
//! 1. **运行目录只认回程**:[`AdoptPlanView::run_dir`] / [`AdoptDoneView::run_dir`] 是唯一来源。
|
||||
//! 缺省档会带 `-2`/`-3` 后缀消歧(两个不同展示名可能落到同一个物理目录),**只有 core 知道
|
||||
//! 最后用了哪个**;页面拿展示名自己派生一次就是错的(会与实例真正启动时用的目录分叉)。
|
||||
//! 2. **后果话术只有一份**:`Shared` / `VersionDir` 的后果**不在本文件里另写**一句,
|
||||
//! 由预检把 core `target_notes()` 的原文放进 [`AdoptPlanView::notes`]——于是界面上的
|
||||
//! 「mods/saves/config 会落进别人的 `.minecraft`」与 CLI 打的是同一句。
|
||||
//! 想显示后果就必须先预检(预检零写盘,所以这不是成本)。
|
||||
//! 3. **问题逐条**:core 的结构化问题([`AdoptProblemView`],带稳定 `code`)与整次失败
|
||||
//! ([`AdoptFailureView`])都原样搬过来,**不折叠成一句「导入失败」**。
|
||||
//!
|
||||
//! ## 缺省档为什么是 `InstanceDir` 而不是 `isolated`
|
||||
//!
|
||||
//! 全局缺省 `isolated` 的运行目录就是 `versions/<id>/`——那是**别人的版本目录**。沿用它会让第一次
|
||||
//! 启动就在别人的 `.minecraft` 里长出 `mods/` `saves/` `logs/`。所以 [`AdoptTargetView`] 的
|
||||
//! 缺省是 `InstanceDir`(ULL 数据区新建独立目录),另两档必须**明确点选**。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// app → host 的导入意图。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AdoptCommand {
|
||||
/// 扫当前游戏根的 `versions/`(只读;顺带得到每个版本能不能登记、已登记给谁)
|
||||
Scan,
|
||||
/// **预检**:按请求算落点与后果(零写盘)。页面拿回 [`AdoptEvent::Planned`] 才画确认按钮。
|
||||
Preview(Box<AdoptRequestView>),
|
||||
/// 确认登记(唯一写盘入口)。
|
||||
Confirm(Box<AdoptRequestView>),
|
||||
/// 关掉面板(扫描结果 / 预检 / 结果卡都收起来)
|
||||
Dismiss,
|
||||
}
|
||||
|
||||
/// host → app 的回程事件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AdoptEvent {
|
||||
/// 扫描结果(`versions/` 里的全部条目,逐项带问题)
|
||||
Scan(Box<AdoptScanView>),
|
||||
/// 预检结果(落点 + 后果原文 + 将写的展示名)
|
||||
Planned(Box<AdoptPlanView>),
|
||||
/// 失败:`code` 稳定、`message` 人读、`problems` 逐条(可能为空)
|
||||
Failed(Box<AdoptFailureView>),
|
||||
/// 登记完成
|
||||
Done(Box<AdoptDoneView>),
|
||||
/// 面板已关(状态复位)
|
||||
Cleared,
|
||||
}
|
||||
|
||||
/// 登记后的**运行目录从哪来**(隔离档由它决定)。
|
||||
///
|
||||
/// GUI 只开三档:[`Self::InstanceDir`](缺省)/[`Self::Shared`] / [`Self::VersionDir`]。
|
||||
/// `custom`(用户手输绝对路径)留在 CLI:GUI 要它就得配目录选择器,不在本 mission 的交付面里。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum AdoptTargetView {
|
||||
/// 缺省:ULL 数据区新建独立运行目录(`custom` 档,与别人的 `.minecraft` 隔离)
|
||||
#[default]
|
||||
InstanceDir,
|
||||
/// 共用游戏根(别人的 `.minecraft`)——必须明确点选
|
||||
Shared,
|
||||
/// 版本目录 `versions/<id>/`(`isolated` 档)——会在别人的版本目录里长玩家数据
|
||||
VersionDir,
|
||||
}
|
||||
|
||||
impl AdoptTargetView {
|
||||
/// 三档的声明顺序(= 面板上的先后,缺省在最前)。
|
||||
pub const ALL: [AdoptTargetView; 3] = [
|
||||
AdoptTargetView::InstanceDir,
|
||||
AdoptTargetView::Shared,
|
||||
AdoptTargetView::VersionDir,
|
||||
];
|
||||
|
||||
/// 稳定 slug(与 core 的 `AdoptTarget::as_str` 同词表;`instance_dir` 不进 CLI 词表)。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::InstanceDir => "instance_dir",
|
||||
Self::Shared => "shared",
|
||||
Self::VersionDir => "version_dir",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 slug 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|target| target.slug() == slug)
|
||||
}
|
||||
|
||||
/// 这一档会不会把玩家数据写进**别人的**目录(界面据此加警示标记)。
|
||||
#[must_use]
|
||||
pub const fn touches_foreign_dir(self) -> bool {
|
||||
matches!(self, Self::Shared | Self::VersionDir)
|
||||
}
|
||||
}
|
||||
|
||||
/// 生效的隔离档(`core::game::install::instance::Isolation` 的视图形态)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AdoptIsolationView {
|
||||
/// `versions/<id>/` 当运行目录
|
||||
Isolated,
|
||||
/// 游戏根当运行目录
|
||||
Shared,
|
||||
/// 指定/新建目录当运行目录(缺省档就是这一档)
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl AdoptIsolationView {
|
||||
/// 稳定 slug(与 core 的 `Isolation::as_str` 同一套字面量)。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Isolated => "isolated",
|
||||
Self::Shared => "shared",
|
||||
Self::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 slug 反解(未知值返回 `None`,界面照原样上屏而不是猜)。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"isolated" => Some(Self::Isolated),
|
||||
"shared" => Some(Self::Shared),
|
||||
"custom" => Some(Self::Custom),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次预检/登记的请求(页面表单 → host)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AdoptRequestView {
|
||||
/// 要接进来的版本 id(= `versions/<id>/` 的目录名)
|
||||
pub version_id: String,
|
||||
/// 展示名(`None` / 空串 = 用版本 id)
|
||||
pub name: Option<String>,
|
||||
/// 运行目录选择(缺省 [`AdoptTargetView::InstanceDir`])
|
||||
pub target: AdoptTargetView,
|
||||
}
|
||||
|
||||
impl AdoptRequestView {
|
||||
/// 最小请求(缺省名 + 缺省运行目录)。
|
||||
#[must_use]
|
||||
pub fn new(version_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
version_id: version_id.into(),
|
||||
name: None,
|
||||
target: AdoptTargetView::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条**结构化**的不合格原因(`code` 稳定,`message` 人读)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AdoptProblemView {
|
||||
/// 稳定机读码(`manifest_missing` / `missing_parent` / `outside_game_root` …)
|
||||
pub code: String,
|
||||
/// 人读原因(core 的 `Display` 原文:带路径、行列号、缺哪个父版本)
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 盘上的一个候选版本。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AdoptCandidateView {
|
||||
/// 版本 id(= 目录名,登记后写进 `profiles.json`)
|
||||
pub version_id: String,
|
||||
/// 客户端 jar 在不在(只报事实,不校验内容)
|
||||
pub jar_present: bool,
|
||||
/// `inheritsFrom` 链(自身在前、最祖先在后)
|
||||
pub inherits: Vec<String>,
|
||||
/// 已经绑定这个版本的实例名(空 = 还没登记过)
|
||||
pub registered: Vec<String>,
|
||||
/// 问题清单(空 = 可登记)
|
||||
pub problems: Vec<AdoptProblemView>,
|
||||
}
|
||||
|
||||
impl AdoptCandidateView {
|
||||
/// 能不能登记(没有任何问题)。
|
||||
#[must_use]
|
||||
pub fn adoptable(&self) -> bool {
|
||||
self.problems.is_empty()
|
||||
}
|
||||
|
||||
/// 已登记过(再登记会得到第二个实例,共用同一份版本文件)。
|
||||
#[must_use]
|
||||
pub fn is_registered(&self) -> bool {
|
||||
!self.registered.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次扫描的结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AdoptScanView {
|
||||
/// 被扫的目录(`<游戏根>/versions`)
|
||||
pub versions_dir: PathBuf,
|
||||
/// 候选(按版本 id 字典序)
|
||||
pub candidates: Vec<AdoptCandidateView>,
|
||||
}
|
||||
|
||||
impl AdoptScanView {
|
||||
/// 候选条数。
|
||||
#[must_use]
|
||||
pub fn total(&self) -> usize {
|
||||
self.candidates.len()
|
||||
}
|
||||
|
||||
/// 可登记的条数。
|
||||
#[must_use]
|
||||
pub fn adoptable(&self) -> usize {
|
||||
self.candidates
|
||||
.iter()
|
||||
.filter(|candidate| candidate.adoptable())
|
||||
.count()
|
||||
}
|
||||
|
||||
/// `versions/` 存在但一个候选都没有(新数据根,或只剩别的启动器的空目录)。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.candidates.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 预检结果:落点 + 后果原文 + 将写的展示名。**零写盘**。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AdoptPlanView {
|
||||
/// 版本 id
|
||||
pub version_id: String,
|
||||
/// 将写进档案的展示名(空名已回落到版本 id)
|
||||
pub name: String,
|
||||
/// 运行目录选择
|
||||
pub target: AdoptTargetView,
|
||||
/// 生效隔离档
|
||||
pub isolation: AdoptIsolationView,
|
||||
/// **生效运行目录**(可能是顺延后的 `-2`/`-3`;页面不许自己派生)
|
||||
pub run_dir: PathBuf,
|
||||
/// 人读提示(含 `target_notes()` 的原文:共享档/版本目录档会把玩家数据写进别人的目录)
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// 登记完成。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AdoptDoneView {
|
||||
/// 实例 id(稳定 UUID)
|
||||
pub instance_id: String,
|
||||
/// 展示名
|
||||
pub name: String,
|
||||
/// 绑定的版本 id
|
||||
pub version_id: String,
|
||||
/// 生效隔离档
|
||||
pub isolation: AdoptIsolationView,
|
||||
/// 生效运行目录(**唯一来源**:启动时用的就是它)
|
||||
pub run_dir: PathBuf,
|
||||
/// 是否新建了缺省运行目录(`false` = 目录已存在但为空、直接复用)
|
||||
pub dir_created: bool,
|
||||
/// 人读提示(与预检同一份)
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// 一次失败(整次调用,不是单条候选问题)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AdoptFailureView {
|
||||
/// 稳定机读码(`adopt_not_adoptable` / `adopt_duplicate_name` / `adopt_target_not_empty` …)
|
||||
pub code: String,
|
||||
/// 人读原因(core 的 `Display` 原文)
|
||||
pub message: String,
|
||||
/// 候选问题清单(`adopt_not_adoptable` 时逐条给出;其它失败为空)
|
||||
pub problems: Vec<AdoptProblemView>,
|
||||
}
|
||||
|
||||
/// 向导的**事件态**(`AppState::adopt` 的全部内容)。
|
||||
///
|
||||
/// 只有从 [`AdoptEvent`] 来的事实:扫描结果、预检结果、失败、完成。页面的表单字段
|
||||
/// (选中的版本、展示名输入、目标选择、面板开没开)属于 UI 本地态,**不在这里**——
|
||||
/// 状态只有一个写入点,才不会有「页面显示的落点和真正写盘的落点不是一回事」。
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct AdoptState {
|
||||
/// 最近一次扫描(`None` = 还没扫过 / 已被新一轮顶掉)
|
||||
pub scan: Option<AdoptScanView>,
|
||||
/// 最近一次预检(`None` = 还没预检 / 已过期)
|
||||
pub plan: Option<AdoptPlanView>,
|
||||
/// 最近一次失败
|
||||
pub failure: Option<AdoptFailureView>,
|
||||
/// 最近一次完成的登记
|
||||
pub done: Option<AdoptDoneView>,
|
||||
}
|
||||
|
||||
impl AdoptState {
|
||||
/// 事件 → 状态(**唯一**写入点,与 `PackState::apply` 同一条纪律)。
|
||||
pub fn apply(&mut self, event: AdoptEvent) {
|
||||
match event {
|
||||
AdoptEvent::Scan(scan) => {
|
||||
// 新一轮扫描:上一轮的预检/失败/完成都作废(候选集合可能已经变了)
|
||||
self.scan = Some(*scan);
|
||||
self.plan = None;
|
||||
self.failure = None;
|
||||
self.done = None;
|
||||
}
|
||||
AdoptEvent::Planned(plan) => {
|
||||
self.plan = Some(*plan);
|
||||
self.failure = None;
|
||||
self.done = None;
|
||||
}
|
||||
AdoptEvent::Failed(failure) => {
|
||||
// 预检失败时上一份预检必须消失:留着会让确认按钮继续亮着
|
||||
self.plan = None;
|
||||
self.failure = Some(*failure);
|
||||
}
|
||||
AdoptEvent::Done(done) => {
|
||||
// 登记成功:预检与失败都过期(这一轮已经有结论)
|
||||
self.plan = None;
|
||||
self.failure = None;
|
||||
self.done = Some(*done);
|
||||
}
|
||||
AdoptEvent::Cleared => {
|
||||
self.scan = None;
|
||||
self.plan = None;
|
||||
self.failure = None;
|
||||
self.done = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 面板此刻该画的那一段(扫描 / 选版本 / 预检 / 结果)。
|
||||
///
|
||||
/// 优先级:完成 > 失败 > 预检 > 扫描列表。写成一个方法而不是让页面各判一遍,
|
||||
/// 是为了「同一时刻只画一段」在单测里可断言。
|
||||
#[must_use]
|
||||
pub fn stage(&self) -> AdoptStage {
|
||||
if self.done.is_some() {
|
||||
return AdoptStage::Done;
|
||||
}
|
||||
if self.failure.is_some() {
|
||||
return AdoptStage::Failed;
|
||||
}
|
||||
if self.plan.is_some() {
|
||||
return AdoptStage::Preview;
|
||||
}
|
||||
AdoptStage::Pick
|
||||
}
|
||||
}
|
||||
|
||||
/// 面板当前画哪一段([`AdoptState::stage`] 的产物)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AdoptStage {
|
||||
/// 扫出来的候选列表(选版本)
|
||||
Pick,
|
||||
/// 预检结果(落点 + 后果原文 + 确认按钮)
|
||||
Preview,
|
||||
/// 失败(原因逐条)
|
||||
Failed,
|
||||
/// 完成
|
||||
Done,
|
||||
}
|
||||
|
||||
impl AdoptStage {
|
||||
/// 稳定 slug(进日志/测试断言用)。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pick => "pick",
|
||||
Self::Preview => "preview",
|
||||
Self::Failed => "failed",
|
||||
Self::Done => "done",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,136 @@
|
||||
//! `dto::adopt` 的 IPC 契约用例(M223 从 `dto/adopt.rs` **纯搬移**,断言一条未改)。
|
||||
//!
|
||||
//! 为什么搬:`dto/adopt.rs` 落笔 512 行,越过 400 行硬线(AGENTS §3.1);口径与
|
||||
//! [`crate::dto::events::tests`] 完全一致——**纯搬移、一行不改**。`adopt.rs` 是非 `mod.rs`
|
||||
//! 文件,`mod tests;` 的落点就是本文件。
|
||||
|
||||
use super::*;
|
||||
|
||||
fn candidate(version_id: &str, problems: Vec<AdoptProblemView>) -> AdoptCandidateView {
|
||||
AdoptCandidateView {
|
||||
version_id: version_id.to_string(),
|
||||
jar_present: true,
|
||||
inherits: Vec::new(),
|
||||
registered: Vec::new(),
|
||||
problems,
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_view() -> AdoptScanView {
|
||||
AdoptScanView {
|
||||
versions_dir: PathBuf::from("/game/versions"),
|
||||
candidates: vec![
|
||||
candidate("1.20.1", Vec::new()),
|
||||
candidate(
|
||||
"1.19.4",
|
||||
vec![AdoptProblemView {
|
||||
code: "manifest_missing".to_string(),
|
||||
message: "版本 1.19.4 没有清单文件".to_string(),
|
||||
}],
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_round_trip_through_json() {
|
||||
let event = AdoptEvent::Failed(Box::new(AdoptFailureView {
|
||||
code: "adopt_not_adoptable".to_string(),
|
||||
message: "版本 1.19.4 不能登记".to_string(),
|
||||
problems: vec![AdoptProblemView {
|
||||
code: "manifest_missing".to_string(),
|
||||
message: "清单缺了".to_string(),
|
||||
}],
|
||||
}));
|
||||
let text = serde_json::to_string(&event).expect("序列化");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<AdoptEvent>(&text).expect("反序列化"),
|
||||
event
|
||||
);
|
||||
|
||||
let command = AdoptCommand::Preview(Box::new(AdoptRequestView {
|
||||
version_id: "1.20.1".to_string(),
|
||||
name: Some("生存".to_string()),
|
||||
target: AdoptTargetView::Shared,
|
||||
}));
|
||||
let text = serde_json::to_string(&command).expect("序列化");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<AdoptCommand>(&text).expect("反序列化"),
|
||||
command
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_summarises_adoptable_and_empty() {
|
||||
let view = scan_view();
|
||||
assert_eq!(view.total(), 2);
|
||||
assert_eq!(view.adoptable(), 1);
|
||||
assert!(!view.is_empty());
|
||||
assert!(AdoptScanView {
|
||||
versions_dir: PathBuf::from("/game/versions"),
|
||||
candidates: Vec::new(),
|
||||
}
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_slugs_round_trip_and_mark_foreign_dirs() {
|
||||
for target in AdoptTargetView::ALL {
|
||||
assert_eq!(AdoptTargetView::from_slug(target.slug()), Some(target));
|
||||
}
|
||||
assert_eq!(AdoptTargetView::default(), AdoptTargetView::InstanceDir);
|
||||
assert!(!AdoptTargetView::InstanceDir.touches_foreign_dir());
|
||||
assert!(AdoptTargetView::Shared.touches_foreign_dir());
|
||||
assert!(AdoptTargetView::VersionDir.touches_foreign_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_follows_the_four_stages() {
|
||||
let mut state = AdoptState::default();
|
||||
assert_eq!(state.stage(), AdoptStage::Pick);
|
||||
|
||||
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
|
||||
assert_eq!(state.stage(), AdoptStage::Pick);
|
||||
assert_eq!(state.scan.as_ref().map(AdoptScanView::adoptable), Some(1));
|
||||
|
||||
let plan = AdoptPlanView {
|
||||
version_id: "1.20.1".to_string(),
|
||||
name: "1.20.1".to_string(),
|
||||
target: AdoptTargetView::InstanceDir,
|
||||
isolation: AdoptIsolationView::Custom,
|
||||
run_dir: PathBuf::from("/ull/data/instances/1.20.1"),
|
||||
notes: vec!["运行目录 = /ull/data/instances/1.20.1".to_string()],
|
||||
};
|
||||
state.apply(AdoptEvent::Planned(Box::new(plan.clone())));
|
||||
assert_eq!(state.stage(), AdoptStage::Preview);
|
||||
assert_eq!(
|
||||
state.plan.as_ref().map(|plan| &plan.run_dir),
|
||||
Some(&plan.run_dir)
|
||||
);
|
||||
|
||||
// 失败会把预检顶掉:留着预检,确认按钮就会在「不能登记」的结论上继续亮着
|
||||
state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView {
|
||||
code: "adopt_duplicate_name".to_string(),
|
||||
message: "已有同名实例".to_string(),
|
||||
problems: Vec::new(),
|
||||
})));
|
||||
assert_eq!(state.stage(), AdoptStage::Failed);
|
||||
assert!(state.plan.is_none());
|
||||
|
||||
state.apply(AdoptEvent::Done(Box::new(AdoptDoneView {
|
||||
instance_id: "id-1".to_string(),
|
||||
name: "1.20.1".to_string(),
|
||||
version_id: "1.20.1".to_string(),
|
||||
isolation: AdoptIsolationView::Custom,
|
||||
run_dir: PathBuf::from("/ull/data/instances/1.20.1"),
|
||||
dir_created: true,
|
||||
notes: Vec::new(),
|
||||
})));
|
||||
assert_eq!(state.stage(), AdoptStage::Done);
|
||||
assert!(state.failure.is_none());
|
||||
|
||||
// 关面板 = 全清(再开要重新扫,否则画的是上一轮的候选)
|
||||
state.apply(AdoptEvent::Cleared);
|
||||
assert_eq!(state.stage(), AdoptStage::Pick);
|
||||
assert!(state.scan.is_none() && state.done.is_none());
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! AI 会话 DTO:消息、工具调用可视化、确认请求与 agent 事件。
|
||||
//!
|
||||
//! 与 agent 侧的对应关系(`crates/agent/src/event.rs` / `confirm.rs`):这里是**扁平镜像**,
|
||||
//! 不 import `unlucky_agent::*`(dto 是 IPC 边界,见 `dto/mod.rs` 的纪律)。字段口径刻意逐字
|
||||
//! 对齐,翻译在接线层做:`AgentEvent::ConfirmRequested` ← `confirm::ConfirmRequest`。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 会话消息角色。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ChatRole {
|
||||
/// 用户
|
||||
User,
|
||||
/// 助手(可流式)
|
||||
Assistant,
|
||||
/// 工具调用(会话里可见,D4 要求)
|
||||
Tool,
|
||||
/// 系统提示(弱化展示)
|
||||
System,
|
||||
}
|
||||
|
||||
/// 工具调用可视化条目。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ToolCallView {
|
||||
/// 工具名(与 ToolSpec 注册表同名)
|
||||
pub name: String,
|
||||
/// 是否成功
|
||||
pub ok: bool,
|
||||
/// 一行结果摘要
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// 一条会话消息。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
/// 角色
|
||||
pub role: ChatRole,
|
||||
/// 正文(流式时持续追加)
|
||||
pub text: String,
|
||||
/// role == Tool 时带上工具调用详情
|
||||
pub tool: Option<ToolCallView>,
|
||||
/// 流式未收敛
|
||||
pub streaming: bool,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
/// 用户消息。
|
||||
pub fn user(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: ChatRole::User,
|
||||
text: text.into(),
|
||||
tool: None,
|
||||
streaming: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 助手流式消息起点(空文本 + streaming)。
|
||||
pub fn assistant_stream() -> Self {
|
||||
Self {
|
||||
role: ChatRole::Assistant,
|
||||
text: String::new(),
|
||||
tool: None,
|
||||
streaming: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 系统提示(弱化展示;错误横幅也进会话历史,用户回翻时看得到)。
|
||||
pub fn system(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: ChatRole::System,
|
||||
text: text.into(),
|
||||
tool: None,
|
||||
streaming: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 敏感等级(与 agent 侧 `confirm::Sensitivity` 逐字一致)。
|
||||
///
|
||||
/// | 等级 | 语义 |
|
||||
/// |------|------|
|
||||
/// | `Low` | 静默生效 |
|
||||
/// | `Medium` | 静默生效,审计里能查到 |
|
||||
/// | `High` | 必须用户确认(YOLO 可跳过确认,但**仍受白名单约束**) |
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Sensitivity {
|
||||
/// 静默生效。
|
||||
#[default]
|
||||
Low,
|
||||
/// 静默生效但审计标记。
|
||||
Medium,
|
||||
/// 必须用户确认。
|
||||
High,
|
||||
}
|
||||
|
||||
impl Sensitivity {
|
||||
/// 是否会触发确认弹窗(非 YOLO 时)。
|
||||
#[must_use]
|
||||
pub fn needs_confirm(self) -> bool {
|
||||
self == Self::High
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次待确认的写操作(确认弹窗的原料;与 agent 侧 `confirm::ConfirmRequest` 对齐)。
|
||||
///
|
||||
/// 构造时**不要**放凭据:`details` 会按键名与内容双道脱敏([`crate::agent::rail`]),
|
||||
/// 但白名单之外的键本来就不该出现在这里。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ConfirmRequest {
|
||||
/// 请求 id(编排层生成,UI 回填决定时原样带回)
|
||||
pub id: String,
|
||||
/// 工具名(与 ToolSpec 注册表同名)
|
||||
pub tool: String,
|
||||
/// 一句话请求(「AI 请求把下载并发 64 改为 16」)
|
||||
pub summary: String,
|
||||
/// 结构化细节(`{"path":…, "old":…, "new":…}`),UI 画 diff 用
|
||||
pub details: serde_json::Value,
|
||||
/// 敏感等级
|
||||
pub sensitivity: Sensitivity,
|
||||
}
|
||||
|
||||
/// agent 侧事件。
|
||||
///
|
||||
/// 四类都投到 [`crate::agent::AgentRail`] 上:流式增量 → 最后一条 assistant 消息;工具调用 →
|
||||
/// 会话里可见的 tool 条目(D4);错误 → 系统条目 + 横幅;确认请求 → `rail.confirm`。
|
||||
///
|
||||
/// 注意这里**没有** `Eq`:`ConfirmRequest::details` 是 `serde_json::Value`(可含浮点)。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AgentEvent {
|
||||
/// 追加一条完整消息
|
||||
MessageAppended(ChatMessage),
|
||||
/// 给指定序号的流式消息追加增量
|
||||
MessageDelta {
|
||||
/// 目标消息在会话里的序号
|
||||
index: usize,
|
||||
/// 增量文本
|
||||
delta: String,
|
||||
},
|
||||
/// 流式收敛
|
||||
MessageFinished {
|
||||
/// 目标消息序号
|
||||
index: usize,
|
||||
},
|
||||
/// 开始一次工具调用(会话里插入工具条目)
|
||||
ToolCallStarted {
|
||||
/// 工具名
|
||||
name: String,
|
||||
/// 调用参数摘要
|
||||
summary: String,
|
||||
},
|
||||
/// 工具调用结束
|
||||
ToolCallFinished {
|
||||
/// 工具名
|
||||
name: String,
|
||||
/// 是否成功
|
||||
ok: bool,
|
||||
/// 结果摘要
|
||||
summary: String,
|
||||
},
|
||||
/// 循环级错误(provider 断流、AI 被禁用、工具内部异常)。
|
||||
///
|
||||
/// 与 `ToolCallFinished { ok: false }` 的分工:那个是**某一次工具调用**失败,对话继续;
|
||||
/// 这个是**这一轮回复**中止,rail 要收敛掉所有流式标记并立一个错误横幅。
|
||||
Failed {
|
||||
/// 人类可读原因
|
||||
message: String,
|
||||
},
|
||||
/// 需要用户确认(分级确认:写操作先问再改)。
|
||||
ConfirmRequested {
|
||||
/// 待确认请求
|
||||
request: ConfirmRequest,
|
||||
},
|
||||
/// 确认请求已收敛(重放/超时/别处已裁决):把 rail 上同 id 的待确认清掉,避免僵尸弹窗。
|
||||
ConfirmResolved {
|
||||
/// 请求 id
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
//! 内容库 DTO(M109 `CONTENTDL`):Modrinth / CurseForge 上「整合包 / Mod / 资源包 / 光影」的
|
||||
//! **检索**与**装入指定实例**。
|
||||
//!
|
||||
//! ## 为什么这些类型在 presenter 而不是 core
|
||||
//!
|
||||
//! D3 / 01c §5:UI 只经 `crate::dto` 消费后端,跨边界类型必须可序列化。而**检索本身**
|
||||
//! (HTTP + 两个站点的 JSON 方言)今天落在 `crate::host::addon`:`crates/core` 的 `addon` 模块
|
||||
//! 只有加载器安装与整合包**格式**解析,没有 CF / Modrinth 的 API 客户端(M111 才做导入管线)。
|
||||
//! 所以这是「宿主层临时落点」的契约面——长期应下沉到 `core::addon`,届时这里只留薄 DTO。
|
||||
//!
|
||||
//! 两个形状是**故意**的:
|
||||
//!
|
||||
//! - [`ContentHit::game_versions`] / [`ContentHit::loaders`] 来自检索结果的 facets ⇒ 页面在用户
|
||||
//! **点之前**就能判「装不装得上」(任务书第 3 条),不用点完才报错;
|
||||
//! - [`ContentKind::dir`] 的 `None`(整合包)是「按钮禁用」的**唯一**判据,不是一个文案开关。
|
||||
//!
|
||||
//! ## 密钥纪律(任务书第 5 条)
|
||||
//!
|
||||
//! CurseForge 的 `X-API-KEY` 从环境变量 `ULL_CF_API_KEY` 读,**不进仓库、不进 DTO、不进日志**。
|
||||
//! 这里只有 [`ContentResults::curseforge`] 一个 `bool`:源可用与否。
|
||||
//! 于是「密钥泄漏」在类型层就不可能——本 crate 没有任何字段能装下它。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::{InstanceId, LoaderKind};
|
||||
|
||||
pub use query::ContentSort;
|
||||
|
||||
mod detail;
|
||||
mod query;
|
||||
|
||||
pub use detail::{
|
||||
ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind, ContentVersionView,
|
||||
};
|
||||
|
||||
/// 内容类型(内容库页左侧那四条一级侧栏)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ContentKind {
|
||||
/// 整合包(Modpack)
|
||||
Modpack,
|
||||
/// Mod
|
||||
Mod,
|
||||
/// 资源包(Resource pack)
|
||||
Resourcepack,
|
||||
/// 光影(Shader pack)
|
||||
Shader,
|
||||
}
|
||||
|
||||
impl ContentKind {
|
||||
/// 四条侧栏的声明顺序(= 画出来的顺序)。
|
||||
pub const ALL: [ContentKind; 4] = [
|
||||
ContentKind::Modpack,
|
||||
ContentKind::Mod,
|
||||
ContentKind::Resourcepack,
|
||||
ContentKind::Shader,
|
||||
];
|
||||
|
||||
/// 稳定 slug(日志 / 文件名 / 命令行参数用,**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Modpack => "modpack",
|
||||
Self::Mod => "mod",
|
||||
Self::Resourcepack => "resourcepack",
|
||||
Self::Shader => "shader",
|
||||
}
|
||||
}
|
||||
|
||||
/// Modrinth 的 `project_type` facet 值。
|
||||
#[must_use]
|
||||
pub const fn modrinth_type(self) -> &'static str {
|
||||
match self {
|
||||
Self::Modpack => "modpack",
|
||||
Self::Mod => "mod",
|
||||
Self::Resourcepack => "resourcepack",
|
||||
Self::Shader => "shader",
|
||||
}
|
||||
}
|
||||
|
||||
/// 装入实例时的落盘目录(相对运行目录);`None` = 不是「一个文件进一个目录」这种语义。
|
||||
///
|
||||
/// 整合包没有目录:它要走 core 的导入管线(解压 + 补全 + 登记实例),那是 M111 的活,
|
||||
/// 所以这里是 `None`——**按钮禁用由这个 `None` 驱动**,不是靠一句文案。
|
||||
#[must_use]
|
||||
pub const fn dir(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Modpack => None,
|
||||
Self::Mod => Some("mods"),
|
||||
Self::Resourcepack => Some("resourcepacks"),
|
||||
Self::Shader => Some("shaderpacks"),
|
||||
}
|
||||
}
|
||||
|
||||
/// CurseForge 的 `classId`(CF API 的类目字段;数值是官方文档里的固定值)。
|
||||
#[must_use]
|
||||
pub const fn curseforge_class(self) -> u32 {
|
||||
match self {
|
||||
Self::Modpack => 4471,
|
||||
Self::Mod => 6,
|
||||
Self::Resourcepack => 12,
|
||||
Self::Shader => 6552,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 内容来源。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ContentSource {
|
||||
/// Modrinth(无需 API key,默认源)
|
||||
Modrinth,
|
||||
/// CurseForge(需要 `ULL_CF_API_KEY`)
|
||||
Curseforge,
|
||||
}
|
||||
|
||||
impl ContentSource {
|
||||
/// 两个来源的声明顺序(= 筛选条顺序)。
|
||||
pub const ALL: [ContentSource; 2] = [ContentSource::Modrinth, ContentSource::Curseforge];
|
||||
|
||||
/// 稳定 slug。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Modrinth => "modrinth",
|
||||
Self::Curseforge => "curseforge",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器 → Modrinth 的 loader facet 值;`None` = 该加载器没有对应 facet(原版等)。
|
||||
#[must_use]
|
||||
pub const fn loader_facet(loader: LoaderKind) -> Option<&'static str> {
|
||||
match loader {
|
||||
LoaderKind::Fabric => Some("fabric"),
|
||||
LoaderKind::Quilt => Some("quilt"),
|
||||
LoaderKind::Forge => Some("forge"),
|
||||
LoaderKind::NeoForge => Some("neoforge"),
|
||||
LoaderKind::Vanilla | LoaderKind::OptiFine | LoaderKind::LiteLoader => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次检索请求(**页面把它整份发出去**,不做本地过滤之外的乐观处理)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ContentQuery {
|
||||
/// 内容类型
|
||||
pub kind: ContentKind,
|
||||
/// 来源
|
||||
pub source: ContentSource,
|
||||
/// 关键词(空串 = 不带 `query` 参数,即「按热度浏览」)
|
||||
pub text: String,
|
||||
/// 游戏版本过滤(`None` = 不过滤)
|
||||
pub game_version: Option<String>,
|
||||
/// 加载器过滤(`None` = 不过滤;对资源包 / 光影也照发——静默丢掉会让筛选条撒谎)
|
||||
pub loader: Option<LoaderKind>,
|
||||
/// 排序
|
||||
pub sort: ContentSort,
|
||||
/// 偏移(分页游标)
|
||||
pub offset: u32,
|
||||
/// 每页条数
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
impl ContentQuery {
|
||||
/// 每页条数(20 = Modrinth 默认上限的一半,翻页手感与请求量折中)。
|
||||
pub const PAGE: u32 = 20;
|
||||
|
||||
/// 某个类型的首页(来源 Modrinth、无关键词、无过滤、按相关度)。
|
||||
#[must_use]
|
||||
pub fn first(kind: ContentKind) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
source: ContentSource::Modrinth,
|
||||
text: String::new(),
|
||||
game_version: None,
|
||||
loader: None,
|
||||
sort: ContentSort::Relevance,
|
||||
offset: 0,
|
||||
limit: Self::PAGE,
|
||||
}
|
||||
}
|
||||
|
||||
/// 换一批(保留全部筛选条件,只把游标往后挪一页)。
|
||||
#[must_use]
|
||||
pub fn next_page(&self) -> Self {
|
||||
Self {
|
||||
offset: self.offset.saturating_add(self.limit),
|
||||
..self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// 筛选条件是否与另一份相同(**不含游标**:判「结果是不是同一批条件拉回来的」用它)。
|
||||
#[must_use]
|
||||
pub fn same_filters(&self, other: &Self) -> bool {
|
||||
self.kind == other.kind
|
||||
&& self.source == other.source
|
||||
&& self.text == other.text
|
||||
&& self.game_version == other.game_version
|
||||
&& self.loader == other.loader
|
||||
&& self.sort == other.sort
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条检索结果(一个项目,不是文件)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ContentHit {
|
||||
/// 项目 id(Modrinth 的 base62 id / CF 的 modId;装入指令的载荷)
|
||||
pub id: String,
|
||||
/// 人读 slug(`sodium`;链接与日志里更可读)
|
||||
pub slug: String,
|
||||
/// 标题
|
||||
pub title: String,
|
||||
/// 一句话说明(站点原文,不翻译:它是用户内容,不是界面文案)
|
||||
pub summary: String,
|
||||
/// 作者(Modrinth 只有团队 id 时给空串)
|
||||
pub author: String,
|
||||
/// 下载量
|
||||
pub downloads: u64,
|
||||
/// 最近更新时间(ISO-8601 的日期部分;未知给空串)
|
||||
pub updated: String,
|
||||
/// 来源
|
||||
pub source: ContentSource,
|
||||
/// 支持的游戏版本(升序由站点给;页面只做「包不包含」判断)
|
||||
pub game_versions: Vec<String>,
|
||||
/// 支持的加载器(`fabric` / `forge` / `neoforge` / `quilt`)
|
||||
pub loaders: Vec<String>,
|
||||
/// 图标 URL(`None` = 没有;页面不拉图,只把主机名画成占位标记)
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
impl ContentHit {
|
||||
/// 这个项目支持某个游戏版本吗(空列表 = 站点没给,按「未知」处理 ⇒ 不拦)。
|
||||
#[must_use]
|
||||
pub fn supports_version(&self, version: &str) -> bool {
|
||||
self.game_versions.is_empty() || self.game_versions.iter().any(|item| item == version)
|
||||
}
|
||||
|
||||
/// 这个项目支持某个加载器吗(空列表 = 站点没给,按「未知」处理 ⇒ 不拦)。
|
||||
#[must_use]
|
||||
pub fn supports_loader(&self, loader: LoaderKind) -> bool {
|
||||
let Some(facet) = loader_facet(loader) else {
|
||||
return true;
|
||||
};
|
||||
self.loaders.is_empty() || self.loaders.iter().any(|item| item == facet)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一批检索结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ContentResults {
|
||||
/// 这份结果对应的请求(页面据此判断「这批还算不算当前条件」)
|
||||
pub query: ContentQuery,
|
||||
/// 命中项(顺序 = 站点顺序)
|
||||
pub hits: Vec<ContentHit>,
|
||||
/// 命中总数(站点给的分页总数;`0` = 站点没给)
|
||||
pub total: u32,
|
||||
/// CurseForge 源此刻是否可用(`false` = 没有 `ULL_CF_API_KEY`)
|
||||
pub curseforge: bool,
|
||||
}
|
||||
|
||||
impl ContentResults {
|
||||
/// 还有下一页吗。
|
||||
///
|
||||
/// 判据是「本页装满了」而不是 `offset + len < total`:站点给的 `total` 有时是估算值,
|
||||
/// 用「装满」判定最多多请求一次空页,不会出现「明明还有却翻不到」。
|
||||
#[must_use]
|
||||
pub fn has_more(&self) -> bool {
|
||||
self.hits.len() as u32 >= self.query.limit
|
||||
}
|
||||
}
|
||||
|
||||
/// 「装入指定实例」的请求(UI 侧只发意图;选哪个文件由 host 按版本 + 加载器选)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ContentInstallRequest {
|
||||
/// 项目 id
|
||||
pub project: String,
|
||||
/// 项目标题(进度面板上显示的那一行;**不是** i18n 文案)
|
||||
pub title: String,
|
||||
/// 内容类型(决定落盘目录与「能不能装」)
|
||||
pub kind: ContentKind,
|
||||
/// 来源
|
||||
pub source: ContentSource,
|
||||
/// 目标实例
|
||||
pub instance: InstanceId,
|
||||
/// 目标实例的游戏版本(选文件用)
|
||||
pub game_version: String,
|
||||
/// 目标实例的加载器(选文件用)
|
||||
pub loader: LoaderKind,
|
||||
/// **只重试上次失败的那些项目**(M191)。
|
||||
///
|
||||
/// `true` 时 host 不再解根项目:它拿上一次装入留下的依赖计划调
|
||||
/// `DependencyPlan::retry_request()`,只重解失败项(`present` 重新扫一遍目录),于是
|
||||
/// 「已装好的不重下、没失败的不重发」。这种模式下本结构里**只有 `instance` 会被读**——
|
||||
/// 过滤条件与落盘目录都来自那份计划。
|
||||
///
|
||||
/// 没有可重试的计划时(换进程 / 那一次是别的实例)一律回一条失败,**不**退化成「整单重装」:
|
||||
/// 那正是本字段要避免的事。
|
||||
pub retry: bool,
|
||||
}
|
||||
|
||||
/// 内容库页的状态(M109):**检索条件 + 最近一批结果 + 请求态**。
|
||||
///
|
||||
/// 条件为什么也存在这里:页面层不持有业务状态是老纪律(同 `download_query`);换页只挪游标、
|
||||
/// 条件只有一份,才不会出现「筛选条说的是 A、列表画的是 B」。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ContentState {
|
||||
/// 当前检索条件(游标也在里面:换页就是换一份 `query`)
|
||||
pub query: ContentQuery,
|
||||
/// 最近一批结果(`None` = 还没拉过)
|
||||
pub results: Option<ContentResults>,
|
||||
/// 有请求在飞(页面画加载态的依据;命中项只由事件写进来,不做乐观回显)
|
||||
pub pending: bool,
|
||||
/// 最近一次失败(`None` = 没失败)。M162 起带**大类**,「没联网」不再是普通失败。
|
||||
pub failed: Option<ContentFailure>,
|
||||
/// **最近一页**是否装满(`hits.len() >= limit`)。
|
||||
///
|
||||
/// 为什么不是「累计条数够一页」:分页是**累加**到 `results.hits` 里的,累计值在第 2 页之后
|
||||
/// 必然 ≥ limit ⇒ 用累计判会得到「has_more 恒真」:加载更多永不消失、「没有更多了」在真实
|
||||
/// 翻页路径上不可达,而且每点一次白发一次网络请求(复审 P1-2)。所以这一位单独记。
|
||||
pub last_page_filled: bool,
|
||||
/// 选中项目的详情(M162:`LoadContentDetail` 的结果;`None` = 还没拉过)
|
||||
pub detail: Option<Box<ContentDetail>>,
|
||||
/// 详情请求在飞(哪个项目:迟到结果靠它判「还算不算当前这次」)
|
||||
pub detail_pending: bool,
|
||||
/// 在飞的那次详情请求的项目 id(`None` = 没有在飞)
|
||||
pub detail_requested: Option<String>,
|
||||
/// 最近一次详情请求的失败
|
||||
pub detail_failed: Option<ContentFailure>,
|
||||
}
|
||||
|
||||
impl Default for ContentState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
query: ContentQuery::first(ContentKind::Mod),
|
||||
results: None,
|
||||
pending: false,
|
||||
failed: None,
|
||||
last_page_filled: false,
|
||||
detail: None,
|
||||
detail_pending: false,
|
||||
detail_requested: None,
|
||||
detail_failed: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContentState {
|
||||
/// 进「详情请求在飞」态并清掉上一条(换选中项时调)。
|
||||
pub fn request_detail(&mut self, project: &str) {
|
||||
if self
|
||||
.detail
|
||||
.as_ref()
|
||||
.is_some_and(|detail| detail.project != project)
|
||||
{
|
||||
self.detail = None;
|
||||
}
|
||||
self.detail_pending = true;
|
||||
self.detail_requested = Some(project.to_string());
|
||||
self.detail_failed = None;
|
||||
}
|
||||
|
||||
/// 详情落地:**只接受当前这次请求的结果**(迟到的、别的项目的一律丢掉)。
|
||||
///
|
||||
/// 场景:选中 A(请求发出)→ 立刻切到 B(又发一条)→ A 的结果后到。没有这道闸门的话,
|
||||
/// A 的版本列表会画在 B 的详情面板下,A 的失败也会被当成本次失败(r1 P2-3)。
|
||||
pub fn apply_detail(&mut self, project: &str, detail: ContentDetail) {
|
||||
if !self.accepts_detail(project) || detail.project != project {
|
||||
return;
|
||||
}
|
||||
self.detail_pending = false;
|
||||
self.detail_requested = None;
|
||||
self.detail_failed = None;
|
||||
self.detail = Some(Box::new(detail));
|
||||
}
|
||||
|
||||
/// 详情失败:同样只看当前这次请求。
|
||||
pub fn fail_detail(&mut self, project: &str, failure: ContentFailure) {
|
||||
if !self.accepts_detail(project) {
|
||||
return;
|
||||
}
|
||||
self.detail_pending = false;
|
||||
self.detail_requested = None;
|
||||
self.detail_failed = Some(failure);
|
||||
}
|
||||
|
||||
/// 这条详情结果还算当前这次吗(`false` = 迟到 / 用户已经换了选中项)。
|
||||
#[must_use]
|
||||
pub fn accepts_detail(&self, project: &str) -> bool {
|
||||
self.detail_requested.as_deref() == Some(project)
|
||||
}
|
||||
|
||||
/// 这批结果还算当前条件吗(`false` = 迟到的事件,丢掉,不许覆盖新的筛选结果)。
|
||||
#[must_use]
|
||||
pub fn accepts(&self, results: &ContentResults) -> bool {
|
||||
results.query.same_filters(&self.query)
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,175 @@
|
||||
//! 内容库的**失败分类**与**详情/版本列表** DTO(M162:在线内容源接上真数据)。
|
||||
//!
|
||||
//! 从 `dto/content.rs` 拆出(那个文件已贴 400 行硬线)。两件事放一起,因为它们都是
|
||||
//! 「检索之后」的话:一次请求失败了要能说清是哪一类,选中一个项目要能拿到版本列表。
|
||||
//!
|
||||
//! # 为什么失败要分大类
|
||||
//!
|
||||
//! 无网时画「未联网,检查网络后重试」、站点 404 时画「换个关键词」是**两种界面**。
|
||||
//! 只有字符串就只能靠 `contains("网络")` 猜文案,一改文案就失效;所以大类在 core 那一侧
|
||||
//! (`OnlineErrorKind`)就分好了,这里只做一一映射,不重新判。
|
||||
//!
|
||||
//! # 详情为什么单独一条通道
|
||||
//!
|
||||
//! 检索结果里只有「支持哪些版本/加载器」(facets),拿不到「有哪些版本可以装」。
|
||||
//! 版本列表是另一个端点(`/v2/project/{id}/version`),而且**只在选中某个项目时才需要**——
|
||||
//! 跟着检索一起拉等于每页多 N 次请求。所以详情是一条独立命令/事件,页面选中才发。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::ContentSource;
|
||||
use crate::dto::LoaderKind;
|
||||
|
||||
/// 一次检索/详情失败的**大类**(M162:把「没联网」与「查无此项」分开)。
|
||||
///
|
||||
/// 为什么不是 `Option<String>`:无网时画「未联网,检查网络后重试」、站点 404 时画「换个关键词」
|
||||
/// 是两件事;只有字符串就只能靠 `contains("网络")` 猜,一改文案就失效。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ContentFailureKind {
|
||||
/// 没联网 / 连不上站点(core 的 `OnlineErrorKind::NotConnected`)
|
||||
Offline,
|
||||
/// 站点说没有(404)
|
||||
NotFound,
|
||||
/// 站点出错(5xx / 退避后仍失败 / 限速)
|
||||
Server,
|
||||
/// 这条路走不通(缺密钥 / 没有可用下载地址 / 摘要格式不对)
|
||||
Unsupported,
|
||||
/// **站点有这个项目,但目标版本/加载器下没有条目**(M191;core 的 `FailureKind::Incompatible`)
|
||||
///
|
||||
/// 与 [`Self::NotFound`] / [`Self::Offline`] 是**两种用户动作**:这里换版本或换加载器才有救,
|
||||
/// 重试同样的条件只会得到同一条结论。以前它落在 `Unsupported`(与「缺密钥」共用一档),
|
||||
/// 于是界面没法单独说「换个版本」——M191 把这一档单独拎出来。
|
||||
Incompatible,
|
||||
/// 其它(解析失败、本地 IO……)
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ContentFailureKind {
|
||||
/// 稳定字符串 id(日志 / 用例断言用,**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Offline => "offline",
|
||||
Self::NotFound => "not_found",
|
||||
Self::Server => "server",
|
||||
Self::Unsupported => "unsupported",
|
||||
Self::Incompatible => "incompatible",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是不是「所选版本/加载器下没有条目」(换版本/加载器才有救,重试没用)。
|
||||
#[must_use]
|
||||
pub const fn is_incompatible(self) -> bool {
|
||||
matches!(self, Self::Incompatible)
|
||||
}
|
||||
|
||||
/// 是不是「没联网」(页面据此画独立的离线态,而不是通用失败态)。
|
||||
#[must_use]
|
||||
pub const fn is_offline(self) -> bool {
|
||||
matches!(self, Self::Offline)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次检索/详情失败(大类 + 一句人读原因)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ContentFailure {
|
||||
/// 大类
|
||||
pub kind: ContentFailureKind,
|
||||
/// 人读说明(原文来自 core / 站点;**不是**界面文案)
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl ContentFailure {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub fn new(kind: ContentFailureKind, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 没联网。
|
||||
#[must_use]
|
||||
pub fn offline(detail: impl Into<String>) -> Self {
|
||||
Self::new(ContentFailureKind::Offline, detail)
|
||||
}
|
||||
|
||||
/// 这条路没通。
|
||||
#[must_use]
|
||||
pub fn unsupported(detail: impl Into<String>) -> Self {
|
||||
Self::new(ContentFailureKind::Unsupported, detail)
|
||||
}
|
||||
|
||||
/// 没联网吗。
|
||||
#[must_use]
|
||||
pub const fn is_offline(&self) -> bool {
|
||||
self.kind.is_offline()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ContentFailure {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(formatter, "{}:{}", self.kind.as_str(), self.detail)
|
||||
}
|
||||
}
|
||||
|
||||
/// 「取项目详情 + 版本列表」的请求(M162)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ContentDetailRequest {
|
||||
/// 项目 id
|
||||
pub project: String,
|
||||
/// 来源(详情端点目前只接了 Modrinth;CurseForge 会明确报「未接通」)
|
||||
pub source: ContentSource,
|
||||
/// 目标游戏版本(选版本用;`None` = 不限)
|
||||
pub game_version: Option<String>,
|
||||
/// 目标加载器(选版本用;`None` = 不限)
|
||||
pub loader: Option<LoaderKind>,
|
||||
}
|
||||
|
||||
/// 一个可用版本(详情页「版本列表」的一行)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ContentVersionView {
|
||||
/// 版本 id
|
||||
pub id: String,
|
||||
/// 版本名
|
||||
pub name: String,
|
||||
/// 版本号
|
||||
pub number: String,
|
||||
/// 版本类型(`release` / `beta` / `alpha`)
|
||||
pub kind: String,
|
||||
/// 支持的游戏版本
|
||||
pub game_versions: Vec<String>,
|
||||
/// 支持的加载器
|
||||
pub loaders: Vec<String>,
|
||||
/// 发布日期(站点原文)
|
||||
pub published: String,
|
||||
/// 主文件名(`None` = 站点没给)
|
||||
pub file_name: Option<String>,
|
||||
/// 主文件字节数(`None` = 站点没给)
|
||||
pub file_size: Option<u64>,
|
||||
}
|
||||
|
||||
/// 项目详情 + 版本列表(M162)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ContentDetail {
|
||||
/// 项目 id
|
||||
pub project: String,
|
||||
/// 标题
|
||||
pub title: String,
|
||||
/// 简介
|
||||
pub description: String,
|
||||
/// 长文(markdown 原文;页面只当纯文本画前几行)
|
||||
pub body: String,
|
||||
/// 可用版本(**最新在前**,已按请求的版本/加载器过滤)
|
||||
pub versions: Vec<ContentVersionView>,
|
||||
}
|
||||
|
||||
impl ContentDetail {
|
||||
/// 版本数量(页面「N 个可用版本」直接用)。
|
||||
#[must_use]
|
||||
pub fn version_count(&self) -> usize {
|
||||
self.versions.len()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! 排序方式(M109 起在 `dto/content.rs`,M162 拆出来给内容源抽象让位)。
|
||||
//!
|
||||
//! 四种排序是**两个站点都有的公共面**,各自映射到自己的参数(Modrinth 的 `index`、
|
||||
//! CurseForge 的 `sortField`)。映射函数放在各自的来源实现里,这里只有声明与稳定 slug。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 排序方式(两个站点的官方排序项,口径对齐后再映射到各自参数)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ContentSort {
|
||||
/// 相关度(Modrinth `relevance`;CF 没有对应项,退回热度)
|
||||
Relevance,
|
||||
/// 下载量
|
||||
Downloads,
|
||||
/// 最新发布
|
||||
Newest,
|
||||
/// 最近更新
|
||||
Updated,
|
||||
}
|
||||
|
||||
impl ContentSort {
|
||||
/// 四种排序的声明顺序。
|
||||
pub const ALL: [ContentSort; 4] = [
|
||||
ContentSort::Relevance,
|
||||
ContentSort::Downloads,
|
||||
ContentSort::Newest,
|
||||
ContentSort::Updated,
|
||||
];
|
||||
|
||||
/// Modrinth `/v2/search` 的 `index` 参数值。
|
||||
#[must_use]
|
||||
pub const fn modrinth_index(self) -> &'static str {
|
||||
match self {
|
||||
Self::Relevance => "relevance",
|
||||
Self::Downloads => "downloads",
|
||||
Self::Newest => "newest",
|
||||
Self::Updated => "updated",
|
||||
}
|
||||
}
|
||||
|
||||
/// CurseForge 的 `sortField`(1 = 热度,2 = 更新,3 = 发布,4 = 名称)。
|
||||
#[must_use]
|
||||
pub const fn curseforge_field(self) -> u32 {
|
||||
match self {
|
||||
Self::Relevance | Self::Downloads => 1,
|
||||
Self::Newest => 3,
|
||||
Self::Updated => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! 内容库 DTO 的守门测试(M109)。从 `dto/content.rs` 拆出:那个文件已贴 400 行硬上限。
|
||||
|
||||
use super::*;
|
||||
|
||||
fn hit(game_versions: Vec<String>, loaders: Vec<String>) -> ContentHit {
|
||||
ContentHit {
|
||||
id: "1".to_string(),
|
||||
slug: "s".to_string(),
|
||||
title: "t".to_string(),
|
||||
summary: String::new(),
|
||||
author: String::new(),
|
||||
downloads: 0,
|
||||
updated: String::new(),
|
||||
source: ContentSource::Modrinth,
|
||||
game_versions,
|
||||
loaders,
|
||||
icon: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_file_kinds_have_install_directories() {
|
||||
// 抓得住:把整合包也塞一个目录(那等于把「解压+补全+登记实例」偷偷降级成「拷一个文件进 mods」,
|
||||
// 正是任务书第 3 条禁止的事)。
|
||||
assert_eq!(ContentKind::Modpack.dir(), None);
|
||||
assert_eq!(ContentKind::Mod.dir(), Some("mods"));
|
||||
assert_eq!(ContentKind::Resourcepack.dir(), Some("resourcepacks"));
|
||||
assert_eq!(ContentKind::Shader.dir(), Some("shaderpacks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_map_to_the_official_facets_and_class_ids() {
|
||||
assert_eq!(ContentKind::Modpack.modrinth_type(), "modpack");
|
||||
assert_eq!(ContentKind::Shader.modrinth_type(), "shader");
|
||||
// CF 的四个 classId 是官方固定值,抄错就搜不到东西(而且不会报错,只会空列表)
|
||||
assert_eq!(ContentKind::Mod.curseforge_class(), 6);
|
||||
assert_eq!(ContentKind::Resourcepack.curseforge_class(), 12);
|
||||
assert_eq!(ContentKind::Modpack.curseforge_class(), 4471);
|
||||
assert_eq!(ContentKind::Shader.curseforge_class(), 6552);
|
||||
for kind in ContentKind::ALL {
|
||||
assert!(!kind.slug().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_keeps_its_filters_and_only_moves_the_cursor() {
|
||||
let mut query = ContentQuery::first(ContentKind::Mod);
|
||||
query.game_version = Some("1.20.1".to_string());
|
||||
query.text = "sodium".to_string();
|
||||
|
||||
let next = query.next_page();
|
||||
assert_eq!(next.offset, ContentQuery::PAGE, "游标按一页走");
|
||||
assert!(next.same_filters(&query), "换页不改筛选条件");
|
||||
|
||||
let mut other = next.clone();
|
||||
other.text = "lithium".to_string();
|
||||
assert!(!other.same_filters(&query), "关键词变了就不是同一批条件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_checks_treat_missing_site_data_as_unknown_not_incompatible() {
|
||||
let empty = hit(Vec::new(), Vec::new());
|
||||
// 站点没给数据时不许拦人(否则「不知道」会被画成「不兼容」)
|
||||
assert!(empty.supports_version("1.20.1"));
|
||||
assert!(empty.supports_loader(LoaderKind::Fabric));
|
||||
|
||||
let filled = hit(vec!["1.20.1".to_string()], vec!["fabric".to_string()]);
|
||||
assert!(filled.supports_version("1.20.1"));
|
||||
assert!(!filled.supports_version("1.21.9"));
|
||||
assert!(filled.supports_loader(LoaderKind::Fabric));
|
||||
assert!(!filled.supports_loader(LoaderKind::Forge));
|
||||
// 原版没有 facet:不该被加载器过滤误伤
|
||||
assert!(filled.supports_loader(LoaderKind::Vanilla));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_more_is_driven_by_a_full_page() {
|
||||
let mut results = ContentResults {
|
||||
query: ContentQuery::first(ContentKind::Mod),
|
||||
hits: Vec::new(),
|
||||
total: 999,
|
||||
curseforge: false,
|
||||
};
|
||||
assert!(!results.has_more(), "空页 = 没有更多了");
|
||||
results.hits = vec![hit(Vec::new(), Vec::new()); ContentQuery::PAGE as usize];
|
||||
assert!(results.has_more(), "装满一页 = 还有下一页");
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
//! 事件与指令:core/agent → app 的事件队列,app → core/agent 的用户意图。
|
||||
//!
|
||||
//! 三条通道(01c §5):①不可变快照发布(`*Changed` 整体替换)②事件流(`LogAppended` /
|
||||
//! `InstallProgress` / `DeviceLoginChanged`)③注入的显示服务(先落成事件,见 `DialogRequested`)。
|
||||
//! 全部类型 `Serialize + Deserialize`:这是 IPC 契约(AGENTS §6)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dialogs::{DialogId, DialogRequest};
|
||||
use crate::dto::CreateRequest;
|
||||
use crate::dto::{
|
||||
// 主线(M109 的 Content* / M113 的 Pack*)与 M114 的 Authlib*/AccountNotice 的并集。
|
||||
AccountNotice,
|
||||
AccountView,
|
||||
AdoptCommand,
|
||||
AdoptEvent,
|
||||
AgentEvent,
|
||||
AuthlibLoginView,
|
||||
AuthlibRequest,
|
||||
ContentInstallRequest,
|
||||
ContentQuery,
|
||||
ContentResults,
|
||||
DeviceLoginView,
|
||||
DiagnosisReport,
|
||||
InstallProgressView,
|
||||
InstanceId,
|
||||
InstanceRuntime,
|
||||
InstanceSummary,
|
||||
JavaPageView,
|
||||
LaunchArgsPatch,
|
||||
LaunchFailureView,
|
||||
LoaderKind,
|
||||
LogLine,
|
||||
LogOutcome,
|
||||
LogSession,
|
||||
ModFailureView,
|
||||
ModListView,
|
||||
ModsCommand,
|
||||
NoticeView,
|
||||
PackCommand,
|
||||
PackEvent,
|
||||
SavesCommand,
|
||||
SavesFailureView,
|
||||
SavesListView,
|
||||
SessionId,
|
||||
SettingGroupView,
|
||||
// M159: 自更新 —— 一条快照整体替换
|
||||
UpdateView,
|
||||
VersionCategory,
|
||||
VersionSummary,
|
||||
};
|
||||
use crate::toast::Toast;
|
||||
|
||||
// M224:`AppCommand` 的构造器(从本文件搬出去守 400 行硬线,纯搬移)
|
||||
mod commands;
|
||||
|
||||
/// 启动会话的一段实时日志(host 尾随 JSONL 后按批次发)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LiveLogBatch {
|
||||
/// 会话 id
|
||||
pub session: SessionId,
|
||||
/// 新增行
|
||||
pub lines: Vec<LogLine>,
|
||||
/// 会话是否还在跑(false = 收尾,可以触发诊断/刷新列表)
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
/// 一次启动收尾(磁贴状态回写 + 日志页会话列表刷新)。
|
||||
///
|
||||
/// **不带人读散文**(M161):早先这里有一个 `summary: String`,内容是 presenter 拼的中文
|
||||
/// (`正常退出` / `崩溃(exit 1)` / `启动失败:找不到 Java`)。于是英文界面上弹出的是中文 Toast,
|
||||
/// 磁贴那行还叠成「Launch failed · 启动失败:…」。现在只给**结构**,文案由 app 层按 key 现求
|
||||
/// (与 `InstallCancelled` 同一条纪律:presenter 不依赖 `unlucky-i18n`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LaunchFinished {
|
||||
/// 实例 id
|
||||
pub id: InstanceId,
|
||||
/// 会话 id
|
||||
pub session: SessionId,
|
||||
/// 结局
|
||||
pub outcome: LogOutcome,
|
||||
/// 进程退出码(`None` = 进程没起来 / 平台没给)
|
||||
pub exit_code: Option<i32>,
|
||||
/// 从 spawn 到收尾的毫秒数(没起来时是 0)
|
||||
pub uptime_ms: u64,
|
||||
/// 失败发生在哪一步(`None` = 这一场没有装配/启动阶段的失败;
|
||||
/// 「跑起来又崩了」看 `outcome`)
|
||||
pub failure: Option<LaunchFailureView>,
|
||||
}
|
||||
|
||||
/// core 侧事件(不可变快照发布 + 事件流,01c §5 的三条通道之二)。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum CoreEvent {
|
||||
/// 实例列表整体刷新
|
||||
InstancesChanged(Vec<InstanceSummary>),
|
||||
/// 单个实例运行时状态变化(启动进度/运行中/失败)
|
||||
InstanceRuntimeChanged {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// 新状态
|
||||
runtime: InstanceRuntime,
|
||||
},
|
||||
/// 下载页版本列表刷新
|
||||
VersionListChanged(Vec<VersionSummary>),
|
||||
/// 日志会话列表刷新
|
||||
LogSessionsChanged(Vec<LogSession>),
|
||||
/// 当前会话日志行刷新(整批替换)
|
||||
LogLinesChanged(Vec<LogLine>),
|
||||
/// 实时日志增量(启动中尾随 JSONL;`running=false` 表示会话收尾)
|
||||
LogAppended(LiveLogBatch),
|
||||
/// 启动收尾(磁贴状态回写 + toast)
|
||||
LaunchFinished(LaunchFinished),
|
||||
/// 诊断结论产出
|
||||
DiagnosisReady(DiagnosisReport),
|
||||
/// 账户列表刷新
|
||||
AccountsChanged(Vec<AccountView>),
|
||||
/// 微软登录状态刷新
|
||||
DeviceLoginChanged(DeviceLoginView),
|
||||
/// 第三方轨(authlib-injector)登录/校验状态刷新
|
||||
AuthlibLoginChanged(AuthlibLoginView),
|
||||
/// 账户页横幅(添加/删除/切换的结果;`None` = 清掉横幅)
|
||||
AccountNoticeChanged(Option<AccountNotice>),
|
||||
/// Java 页快照(发行版/回退链/运行时)
|
||||
JavaChanged(JavaPageView),
|
||||
/// 安装进度(`None` = 清掉进度面板)
|
||||
InstallProgress(Option<InstallProgressView>),
|
||||
/// 用户取消了安装(**不带人读文案**)
|
||||
///
|
||||
/// 为什么只有信号没有文本:`presenter` 不依赖 `unlucky-i18n`,任何写死的中文都会和
|
||||
/// 页面语言打架(下载页目前渲染英文,一条中文 Toast 会**同屏一中一英**,复审 P2-2)。
|
||||
/// 文案由 app 层按 `Key` 现求:见 `pages::download::cancelled_toast`。
|
||||
InstallCancelled,
|
||||
/// 某 (加载器, 原版版本) 可用的加载器版本列表(下载页「安装选项」的版本下拉)。
|
||||
///
|
||||
/// `result` 用 `Result` 而不是空列表:**取不到**与**确实没有**是两件事,
|
||||
/// 前者要给具名错误(不许让下拉显示成空还以为能装),后者是「这个组合官方就没有版本」。
|
||||
LoaderVersions {
|
||||
/// 加载器种类
|
||||
kind: LoaderKind,
|
||||
/// 目标原版版本
|
||||
mc_version: String,
|
||||
/// `Ok(最新在前)` / `Err(人读原因)`
|
||||
result: Result<Vec<String>, String>,
|
||||
},
|
||||
/// 设置分组刷新(设置页右栏)
|
||||
SettingsChanged(Vec<SettingGroupView>),
|
||||
/// 横幅通知刷新(隔离切换提示/启动失败等)
|
||||
NoticesChanged(Vec<NoticeView>),
|
||||
/// 内容库检索结果(M109):**整批替换**(同一份 `query` 的命中项,含分页游标)
|
||||
ContentResults(Box<ContentResults>),
|
||||
/// 内容库检索失败(M109;M162 起带大类):没网 / 站点 5xx / 缺密钥要能分开画
|
||||
ContentFailed(Box<crate::dto::ContentFailure>),
|
||||
/// 内容库项目详情 + 版本列表(M162):`project` 是**请求时**选中的 id,迟到结果靠它判。
|
||||
ContentDetail {
|
||||
/// 请求时选中的项目 id
|
||||
project: String,
|
||||
/// `Ok` = 详情;`Err` = 失败大类 + 原因
|
||||
result: Box<Result<crate::dto::ContentDetail, crate::dto::ContentFailure>>,
|
||||
},
|
||||
// M113: import/export
|
||||
/// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`])
|
||||
Pack(Box<PackEvent>),
|
||||
/// 导入已有版本(M223):扫描 / 预检 / 完成 / 失败(子枚举见 [`AdoptEvent`])
|
||||
///
|
||||
/// 与 [`Self::Pack`] 分开是**语义**上必须的:整合包导入会把内容真搬进实例目录,
|
||||
/// 而这里只登记盘上已有的版本、写两份配置(`versions/` 一个字节都不动)。
|
||||
Adopt(Box<AdoptEvent>),
|
||||
/// 自更新(M159):整份状态快照(检查结论 + 就位状态 + 用户是否已拒绝)。
|
||||
///
|
||||
/// 与 `SettingsChanged` 同一条形态:快照整体替换,UI 不做增量合并。
|
||||
Update(Box<UpdateView>),
|
||||
/// 本地 Mod 列表 / 启停(M224):`Ok` = 整份快照;`Err` = 具名原因 + 原文(文案由 app 现求)。
|
||||
Mods(Box<Result<ModListView, ModFailureView>>),
|
||||
/// 实例存档 / 截图的只读列表(M235):`Ok` = 整份快照;`Err` = 具名原因 + 原文。
|
||||
///
|
||||
/// 与 [`Self::Mods`] 分开:这一族**没有任何写路径**(U3 只做只读列表 + 打开文件夹)。
|
||||
Saves(Box<Result<SavesListView, SavesFailureView>>),
|
||||
}
|
||||
|
||||
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
|
||||
/// 这样 [`crate::state::AppState::apply`] 是唯一的状态变更入口。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AppEvent {
|
||||
/// 来自 core
|
||||
Core(CoreEvent),
|
||||
/// 来自 agent
|
||||
Agent(AgentEvent),
|
||||
/// core/agent 请求弹对话框(会进队列,一次一条)
|
||||
DialogRequested(DialogRequest),
|
||||
/// 关闭指定对话框(任务完成收起进度条)
|
||||
DialogClosed(DialogId),
|
||||
/// 右下角 Toast
|
||||
Toast(Toast),
|
||||
}
|
||||
|
||||
/// app → core/agent 的用户意图。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AppCommand {
|
||||
/// 重新扫描实例目录 + 全部页面快照(启动时与手改文件后)
|
||||
RefreshAll,
|
||||
/// 启动实例
|
||||
LaunchInstance(InstanceId),
|
||||
/// 停止实例(含取消启动中的管线)
|
||||
StopInstance(InstanceId),
|
||||
/// 拉取某分类的版本列表
|
||||
ListVersions(VersionCategory),
|
||||
/// 安装指定版本(下载页行内「安装」按钮;原版)
|
||||
InstallVersion(String),
|
||||
/// 取消安装(阶段边界生效:当前阶段跑完就停,不再进下一阶段)
|
||||
CancelInstall,
|
||||
/// 新建实例(向导提交:版本 + 加载器 + 名字)
|
||||
CreateInstance(CreateRequest),
|
||||
/// 拉取某原版版本可用的加载器版本(下载页「安装选项」的版本下拉)
|
||||
ListLoaderVersions {
|
||||
/// 加载器种类
|
||||
kind: LoaderKind,
|
||||
/// 目标原版版本(`1.20.1`)
|
||||
mc_version: String,
|
||||
},
|
||||
/// 修改实例隔离档位(写实例设置文件)
|
||||
SetIsolation {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// `isolated` / `shared`
|
||||
mode: String,
|
||||
},
|
||||
/// 写实例启动参数(M157 的「启动参数」区:JVM/游戏参数、内存、Java、图形后端)
|
||||
///
|
||||
/// 补丁语义:`None` = 这一项不改;`Some("")` = 清空回「跟随全局」。
|
||||
SetInstanceLaunchArgs {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// 五项补丁
|
||||
patch: LaunchArgsPatch,
|
||||
},
|
||||
/// 刷新日志会话列表
|
||||
RefreshLogSessions,
|
||||
/// 打开某个会话(拉取日志行 + 触发诊断)
|
||||
OpenLogSession(SessionId),
|
||||
/// 导入崩溃日志文件做规则分析(`hs_err_pid*.log` / `latest.log`)
|
||||
ImportCrashLog(String),
|
||||
/// 加一个离线账户
|
||||
AddOfflineAccount(String),
|
||||
/// 删除账户(按 id 或名字)
|
||||
RemoveAccount(String),
|
||||
/// 切换当前启动账户
|
||||
SwitchAccount(String),
|
||||
/// 开始微软设备码登录(CLI/无浏览器场景、备用通道)
|
||||
StartDeviceLogin,
|
||||
/// 开始微软授权码登录(拉起系统默认浏览器 + 本地回调,GUI 的默认入口)
|
||||
StartBrowserLogin,
|
||||
/// 取消/关掉微软登录面板(已发出的请求会在收尾时丢弃结果)
|
||||
CancelDeviceLogin,
|
||||
/// 加一个第三方皮肤站(authlib-injector)账户:校验服务器 + 密码登录
|
||||
///
|
||||
/// **凭据红线**:`request.password` 只是这一次的请求值,host 用完即弃,绝不进回程 DTO。
|
||||
AddAuthlibAccount(AuthlibRequest),
|
||||
/// 只校验第三方皮肤站地址(服务发现):结果走 `AuthlibLoginChanged`
|
||||
ProbeAuthlibServer(String),
|
||||
/// 关掉账户页横幅(页面上的 × / Dismiss)
|
||||
DismissAccountNotice,
|
||||
/// 安装指定发行版的托管 JDK(`temurin` / `oracle` / `microsoft` / `zulu`)
|
||||
InstallJava(String),
|
||||
/// 写一条设置(`key` 必须是 core schema 里的键)
|
||||
SetSetting {
|
||||
/// schema 键
|
||||
key: String,
|
||||
/// 新值(字符串形态;host 按 schema 类型转换)
|
||||
value: String,
|
||||
},
|
||||
/// 关掉一条横幅通知
|
||||
DismissNotice(String),
|
||||
/// 向 AI 提问(会话状态在 app 侧,agent 只回事件)
|
||||
AskAgent(String),
|
||||
/// 中断当前 AI 流
|
||||
StopAgent,
|
||||
/// YOLO 开关(跳过确认,审计照记)
|
||||
SetYolo(bool),
|
||||
/// 检索内容库(M109;结果经 [`CoreEvent::ContentResults`] 回来)
|
||||
SearchContent(ContentQuery),
|
||||
/// 把某个内容项目装进指定实例(M109;整合包不支持,host 会拒绝并给原因)
|
||||
InstallContent(Box<ContentInstallRequest>),
|
||||
/// 取某个内容项目的详情与版本列表(M162;结果经 [`CoreEvent::ContentDetail`] 回来)
|
||||
LoadContentDetail(Box<crate::dto::ContentDetailRequest>),
|
||||
// M113: import/export
|
||||
/// 整合包导入/导出(M113):导入 / 预检 / 确认写盘 / 关面板(子枚举见 [`PackCommand`])
|
||||
Pack(Box<PackCommand>),
|
||||
/// 导入已有版本(M223):扫描 / 预检 / 确认登记 / 关面板(子枚举见 [`AdoptCommand`])
|
||||
Adopt(Box<AdoptCommand>),
|
||||
// M159: 自更新
|
||||
/// 查一次更新(设置页「立即检查」;会清掉「忽略这个版本」的标记并重新请求网络)
|
||||
CheckUpdate,
|
||||
/// 把当前候选下载下来、校验、替换本程序(结果经 [`CoreEvent::Update`] 回来)
|
||||
ApplyUpdate,
|
||||
/// 忽略**这一个候选版本**(记的是版本号,不是「别再问我」)。
|
||||
///
|
||||
/// 语义三条(`UpdateView::skipped` 的契约,`dto::update` 的模块文档与用例守着):
|
||||
/// 同一次运行里同一个版本不再弹提示条;**更高的版本照弹**(0.2.3 被忽略不影响 0.2.4);
|
||||
/// 设置页的「立即检查」会清掉它。候选本身仍留在快照里,设置页可手动装。
|
||||
DismissUpdate,
|
||||
/// 开关「启动时静默检查」。
|
||||
///
|
||||
/// 落点是主配置 `config/launcher.json` 的 `update.auto_check`(M161 从更新域自己的
|
||||
/// `cache/update/prefs.json` 迁过来的 schema 键)——`ull config get update.auto_check`
|
||||
/// 读到的是同一个值。
|
||||
SetUpdateAutoCheck(bool),
|
||||
/// 本地 Mod 列表 / 启停(M224;子枚举与语义见 [`ModsCommand`] 的文档)。
|
||||
Mods(Box<ModsCommand>),
|
||||
/// 实例存档 / 截图的只读列表(M235;子枚举与语义见 [`SavesCommand`] 的文档)。
|
||||
Saves(Box<SavesCommand>),
|
||||
}
|
||||
|
||||
/// 后端通道:core/agent host 实现它;[`crate::mock::MockBackend`] 验证通道语义。
|
||||
///
|
||||
/// 轮询模型(而非回调)是为了让 Slint 的定时器成为唯一调度者,
|
||||
/// 避免跨线程回调里碰 Slint 模型。
|
||||
pub trait Backend {
|
||||
/// 取走自上次调用以来积压的事件(FIFO,不可丢序号)。
|
||||
fn drain_events(&mut self) -> Vec<AppEvent>;
|
||||
|
||||
/// 下发一条用户意图。
|
||||
fn dispatch(&mut self, command: AppCommand);
|
||||
|
||||
/// 时间推进(真实后端在这里收后台线程的结果并尾随日志)。
|
||||
fn tick(&mut self, elapsed: std::time::Duration) {
|
||||
let _ = elapsed;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,26 @@
|
||||
//! `AppCommand` 的**构造器**(从 `dto/events.rs` 搬出:那边 406 行越了 400 行硬线)。
|
||||
//!
|
||||
//! **纯搬移**:函数体、文档、可见性一字未改,只是换了文件;`impl AppCommand` 的另一个块,
|
||||
//! 于是调用点 `AppCommand::set_isolation(..)` / `AppCommand::set_launch_args(..)` 一个字都不用改。
|
||||
//! 这样 `events.rs` 只留两个枚举本体(`AppCommand` / `CoreEvent`),M224 才能在里面加
|
||||
//! `Mods` 变体而不越线。
|
||||
|
||||
use crate::dto::{AppCommand, LaunchArgsPatch};
|
||||
|
||||
impl AppCommand {
|
||||
/// 构造「切换隔离档位」指令(磁贴/详情页共用)。
|
||||
pub fn set_isolation(id: &str, mode: &str) -> Self {
|
||||
Self::SetIsolation {
|
||||
id: id.to_string(),
|
||||
mode: mode.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造「写实例启动参数」指令(详情页「启动参数」区)。
|
||||
pub fn set_launch_args(id: &str, patch: LaunchArgsPatch) -> Self {
|
||||
Self::SetInstanceLaunchArgs {
|
||||
id: id.to_string(),
|
||||
patch,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! `dto::events` 的 IPC 契约用例(M223 从 `events.rs` 尾部**纯搬移**,一行未改)。
|
||||
//!
|
||||
//! 为什么搬:M223 要给 `AppCommand` / `CoreEvent` 各加一条变体(「导入已有版本」的指令与事件),
|
||||
//! 而 `events.rs` 当时正好 399 行——加两行变体 + 两行 import 就顶破 400 行硬线(AGENTS §3.1)。
|
||||
//! 拆分口径与仓库其它拆分一致:**纯搬移、一行不改**,`mod tests;` 只是把位置挪到
|
||||
//! `dto/events/tests.rs`(`events.rs` 是非 `mod.rs` 文件,子模块目录就是 `events/`)。
|
||||
//!
|
||||
//! 搬移后 `events.rs` 只剩契约本体(枚举 + 构造器 + `Backend` trait),本文件的断言一条没动,
|
||||
//! 所以「搬移零行为变化」可以用逐行 diff 对。
|
||||
|
||||
use super::*;
|
||||
use crate::dto::{AccountKind, DeviceLoginPhase, InstallStage};
|
||||
|
||||
#[test]
|
||||
fn events_are_serializable_ipc_contracts() {
|
||||
let event = AppEvent::Core(CoreEvent::InstallProgress(Some(InstallProgressView {
|
||||
stage: InstallStage::Downloading,
|
||||
files_done: 3,
|
||||
files_total: 9,
|
||||
..InstallProgressView::new()
|
||||
})));
|
||||
let text = serde_json::to_string(&event).expect("序列化");
|
||||
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, event);
|
||||
|
||||
let account = AppEvent::Core(CoreEvent::AccountsChanged(vec![AccountView {
|
||||
id: "account:1".to_string(),
|
||||
name: "Steve".to_string(),
|
||||
kind: AccountKind::Offline,
|
||||
profile_id: "0f8f".to_string(),
|
||||
login_name: String::new(),
|
||||
created: "2026-10-31 20:12".to_string(),
|
||||
active: true,
|
||||
server_url: String::new(),
|
||||
skin: None,
|
||||
}]));
|
||||
let text = serde_json::to_string(&account).expect("序列化");
|
||||
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, account);
|
||||
// 凭据永不跨界:序列化文本里没有 token 类字段
|
||||
assert!(!text.to_lowercase().contains("token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_round_trip() {
|
||||
let command = AppCommand::CreateInstance(CreateRequest {
|
||||
version: "1.20.4".to_string(),
|
||||
loader: crate::dto::LoaderKind::Fabric,
|
||||
loader_version: "0.16.5".to_string(),
|
||||
name: "生存".to_string(),
|
||||
});
|
||||
let text = serde_json::to_string(&command).expect("序列化");
|
||||
let back: AppCommand = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, command);
|
||||
|
||||
let login = AppCommand::StartDeviceLogin;
|
||||
assert_eq!(
|
||||
serde_json::from_str::<AppCommand>(&serde_json::to_string(&login).expect("序列化"))
|
||||
.expect("反序列化"),
|
||||
login
|
||||
);
|
||||
assert!(DeviceLoginPhase::Waiting.busy());
|
||||
|
||||
// 第三方轨(M114):请求进、状态出,两边都不带 token 字段
|
||||
let authlib = AppCommand::AddAuthlibAccount(AuthlibRequest::new(
|
||||
"https://littleskin.cn/api/yggdrasil/",
|
||||
"me@example.com",
|
||||
"hunter2",
|
||||
));
|
||||
let text = serde_json::to_string(&authlib).expect("序列化");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<AppCommand>(&text).expect("反序列化"),
|
||||
authlib
|
||||
);
|
||||
|
||||
let view = AppEvent::Core(CoreEvent::AuthlibLoginChanged(AuthlibLoginView::reachable(
|
||||
"https://littleskin.cn/api/yggdrasil/",
|
||||
"LittleSkin",
|
||||
)));
|
||||
let text = serde_json::to_string(&view).expect("序列化");
|
||||
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, view);
|
||||
assert!(
|
||||
!text.to_lowercase().contains("token"),
|
||||
"回程视图里不该有 token"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
//! 安装进度 DTO:下载页与新建向导共用的「一个安装任务」视图。
|
||||
//!
|
||||
//! 只放聚合量(文件计数 / 字节 / 速度 / 剩余时间)+ 一行状态文案:
|
||||
//! 逐文件的明细留在 core 的 `InstallReport`,UI 不需要也不该持有。
|
||||
//! 速度与 ETA 由 host 依据相邻两次字节数差分算(core 的事件只给绝对值)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::ContentFailureKind;
|
||||
|
||||
/// 安装阶段。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum InstallStage {
|
||||
/// 解析版本清单 / 规划文件(联网取 metadata)
|
||||
Planning,
|
||||
/// 下载并校验文件
|
||||
Downloading,
|
||||
/// 落版本 JSON / 跑安装器
|
||||
Installing,
|
||||
/// 登记实例
|
||||
Registering,
|
||||
/// 完成
|
||||
Done,
|
||||
/// 失败
|
||||
Failed,
|
||||
/// 用户取消
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl InstallStage {
|
||||
/// 阶段文案。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Planning => "解析版本",
|
||||
Self::Downloading => "下载文件",
|
||||
Self::Installing => "生成版本",
|
||||
Self::Registering => "登记实例",
|
||||
Self::Done => "已完成",
|
||||
Self::Failed => "失败",
|
||||
Self::Cancelled => "已取消",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否已经收尾(Done/Failed/Cancelled)。
|
||||
pub fn is_terminal(self) -> bool {
|
||||
matches!(self, Self::Done | Self::Failed | Self::Cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条可只重试的失败的**档位**(M191;与 core 的 `FailureKind` 一一对应)。
|
||||
///
|
||||
/// ## 为什么不是一个 `bool` / 一句话
|
||||
///
|
||||
/// 「不兼容所选版本/加载器」与「取不到」要用户做的事不同:前者换版本或换加载器,后者检查网络
|
||||
/// 再试。**两档在类型上分开**,界面才能分组画两段、给两条不同的话;合并成一档之后
|
||||
/// 「重试」按钮会把一条重试必然失败的项也算进去(M191 的变异守门就钉这一条)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum InstallFailureKind {
|
||||
/// 站点有这个项目,但目标版本/加载器下没有条目(换版本/加载器才有救)
|
||||
Incompatible,
|
||||
/// 取不到(没联网 / 项目不存在 / 站点出错 / 下载失败)
|
||||
Unreachable,
|
||||
}
|
||||
|
||||
impl InstallFailureKind {
|
||||
/// 稳定字符串 id(日志 / 用例断言用,**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Incompatible => "incompatible",
|
||||
Self::Unreachable => "unreachable",
|
||||
}
|
||||
}
|
||||
|
||||
/// 两档的固定顺序(界面分组按它排:先「换版本能救」,再「检查网络」)。
|
||||
pub const ALL: [Self; 2] = [Self::Incompatible, Self::Unreachable];
|
||||
}
|
||||
|
||||
/// 一条「可只重试」的失败项(M191):项目键 + 可读标签 + 档位 + core 给的原话。
|
||||
///
|
||||
/// 这是界面画「可只重试这 N 个」和分组所需的**全部**信息:项目键就是重试时发出去的那个键
|
||||
/// (`DependencyPlan::retry_request()` 用的同一份)。
|
||||
///
|
||||
/// ## 为什么还要 `label`(M191 r1 复审 P2-1)
|
||||
///
|
||||
/// `project` 是站点 id(`P7dR8mSH`),直接画在界面上等于给用户一串哈希。可读名其实都在手边,
|
||||
/// 只是没进 DTO:根项目的名字就在请求的 `title` 里;失败文件的名字在 `PlannedNode.file.filename`
|
||||
/// 里(下载失败那种)。`label` 收的正是这两个**语言中立**的字符串(站点标题 / 文件名),
|
||||
/// **不是** `detail` 那种 core 中文散文——那会踩 i18n 的 R2(core 中文不进界面)。
|
||||
///
|
||||
/// `label` 为空是合法且**必须**保留的一档:项目不存在(404)时确实没有名字可显示,
|
||||
/// 那时界面只画 id(评审认这条兜底是合理的)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InstallFailureView {
|
||||
/// 请求它用的项目键(slug 或 id;重试也用它)
|
||||
pub project: String,
|
||||
/// 语言中立的可读标签(站点给的项目标题 / 文件名;空 = 拿不到名字)
|
||||
pub label: String,
|
||||
/// 哪一档
|
||||
pub kind: InstallFailureKind,
|
||||
/// core 给的人读说明(引擎原话 + 上下文,**不是**界面文案;**不要**画到界面上)
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl InstallFailureView {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
project: impl Into<String>,
|
||||
label: impl Into<String>,
|
||||
kind: InstallFailureKind,
|
||||
detail: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
project: project.into(),
|
||||
label: label.into(),
|
||||
kind,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 界面该画的那一行:有名字就「名字 (id)」,没有就只画 id。
|
||||
///
|
||||
/// 界面上**名字优先、id 次要**:名字让人认出是哪个项目,括号里的 id 是给「名字对不上时」
|
||||
/// 或排查用的。定在这个方法里而不是视图里,是因为「有名字用名字」这条要有用例钉住。
|
||||
#[must_use]
|
||||
pub fn line(&self) -> String {
|
||||
let label = self.label.trim();
|
||||
if label.is_empty() || label == self.project {
|
||||
self.project.clone()
|
||||
} else {
|
||||
format!("{label} ({})", self.project)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次安装的进度快照。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InstallProgressView {
|
||||
/// 目标版本 id
|
||||
pub version: String,
|
||||
/// 实例名(默认 = 版本 id)
|
||||
pub instance: String,
|
||||
/// 加载器徽标文字(`原版` / `Fabric 0.16.5`)
|
||||
pub loader: String,
|
||||
/// 阶段
|
||||
pub stage: InstallStage,
|
||||
/// 已完成文件数
|
||||
pub files_done: u64,
|
||||
/// 文件总数
|
||||
pub files_total: u64,
|
||||
/// 已下载字节
|
||||
pub bytes_done: u64,
|
||||
/// 总字节(0 = 未知)
|
||||
pub bytes_total: u64,
|
||||
/// 当前速度(字节/秒;0 = 未知)
|
||||
pub speed_bps: u64,
|
||||
/// 剩余秒数(0 = 未知)
|
||||
pub eta_secs: u64,
|
||||
/// 状态/错误文案
|
||||
pub message: String,
|
||||
/// 失败大类(M162 r1):`None` = 没失败,或失败来自不产生分类的路径。
|
||||
///
|
||||
/// 放在进度快照里而不是只塞进文案:`message` 是给用户看的一句话,"没联网" 与
|
||||
/// "这个项目不存在" 该画两种提示,判据不能靠那句话里有没有"网络"两个字。
|
||||
pub failure_kind: Option<ContentFailureKind>,
|
||||
/// 可只重试的失败项(M191):空 = 没有失败项 ⇒ 界面**不渲染**那条入口。
|
||||
///
|
||||
/// 每条自带档位([`InstallFailureKind`]),界面按档分组;`project` 就是
|
||||
/// `DependencyPlan::retry_request()` 会发出去的那几个项目键——名单由 plan 产出,
|
||||
/// 界面不自己拼(拼一份就会与重试实际请求的那几个不一致)。
|
||||
pub retry: Vec<InstallFailureView>,
|
||||
}
|
||||
|
||||
impl Default for InstallProgressView {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl InstallProgressView {
|
||||
/// 规划中的空快照。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
version: String::new(),
|
||||
instance: String::new(),
|
||||
loader: String::new(),
|
||||
stage: InstallStage::Planning,
|
||||
files_done: 0,
|
||||
files_total: 0,
|
||||
bytes_done: 0,
|
||||
bytes_total: 0,
|
||||
speed_bps: 0,
|
||||
eta_secs: 0,
|
||||
message: String::new(),
|
||||
failure_kind: None,
|
||||
retry: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 起一个任务的快照。
|
||||
pub fn start(version: &str, instance: &str, loader: &str) -> Self {
|
||||
Self {
|
||||
version: version.to_string(),
|
||||
instance: instance.to_string(),
|
||||
loader: loader.to_string(),
|
||||
stage: InstallStage::Planning,
|
||||
message: "正在解析版本清单…".to_string(),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 整体进度 0.0~1.0(优先文件计数,其次字节;都没有时 0)。
|
||||
pub fn ratio(&self) -> f32 {
|
||||
if self.files_total > 0 {
|
||||
(self.files_done as f32 / self.files_total as f32).clamp(0.0, 1.0)
|
||||
} else if self.bytes_total > 0 {
|
||||
(self.bytes_done as f32 / self.bytes_total as f32).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// 进度是否已知(未知时进度条转不确定态)。
|
||||
pub fn is_known(&self) -> bool {
|
||||
self.files_total > 0 || self.bytes_total > 0
|
||||
}
|
||||
|
||||
/// 速度文案(`1.2 MB/s`;未知给空串)。
|
||||
pub fn speed_label(&self) -> String {
|
||||
if self.speed_bps == 0 {
|
||||
return String::new();
|
||||
}
|
||||
format!("{}/s", human_bytes(self.speed_bps))
|
||||
}
|
||||
|
||||
/// 剩余时间文案(`剩 12s`;未知给空串)。
|
||||
pub fn eta_label(&self) -> String {
|
||||
if self.eta_secs == 0 {
|
||||
return String::new();
|
||||
}
|
||||
if self.eta_secs >= 60 {
|
||||
format!("剩 {}m{:02}s", self.eta_secs / 60, self.eta_secs % 60)
|
||||
} else {
|
||||
format!("剩 {}s", self.eta_secs)
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件计数文案(`128/512 文件`)。
|
||||
pub fn files_label(&self) -> String {
|
||||
if self.files_total == 0 {
|
||||
return String::new();
|
||||
}
|
||||
format!("{}/{} 文件", self.files_done, self.files_total)
|
||||
}
|
||||
|
||||
/// 有可只重试的失败项吗(`false` ⇒ 界面**不渲染**那条入口,不是置灰)。
|
||||
#[must_use]
|
||||
pub fn has_retry(&self) -> bool {
|
||||
!self.retry.is_empty()
|
||||
}
|
||||
|
||||
/// 某一档的可重试项(两档分开画时用;顺序沿用计划里的顺序)。
|
||||
pub fn retry_of(&self, kind: InstallFailureKind) -> impl Iterator<Item = &InstallFailureView> {
|
||||
self.retry.iter().filter(move |item| item.kind == kind)
|
||||
}
|
||||
|
||||
/// 某一档有几项。
|
||||
#[must_use]
|
||||
pub fn retry_count(&self, kind: InstallFailureKind) -> usize {
|
||||
self.retry_of(kind).count()
|
||||
}
|
||||
}
|
||||
|
||||
/// 字节数 → 人读(`1.2 MB`)。
|
||||
pub fn human_bytes(bytes: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||
let mut value = bytes as f64;
|
||||
let mut unit = 0;
|
||||
while value >= 1024.0 && unit + 1 < UNITS.len() {
|
||||
value /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
if unit == 0 {
|
||||
format!("{bytes} B")
|
||||
} else {
|
||||
format!("{value:.1} {}", UNITS[unit])
|
||||
}
|
||||
}
|
||||
|
||||
/// 用相邻两次采样算速度与 ETA(`None` = 本次无法计算,保持上次值)。
|
||||
pub fn derive_speed(
|
||||
previous: (u64, std::time::Instant),
|
||||
current: (u64, std::time::Instant),
|
||||
bytes_total: u64,
|
||||
) -> Option<(u64, u64)> {
|
||||
let elapsed = current.1.saturating_duration_since(previous.1);
|
||||
if elapsed.as_millis() < 50 {
|
||||
return None;
|
||||
}
|
||||
let delta = current.0.saturating_sub(previous.0);
|
||||
if delta == 0 {
|
||||
return None;
|
||||
}
|
||||
let elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
|
||||
let speed = delta.saturating_mul(1000) / elapsed_ms.max(1);
|
||||
let eta = if bytes_total > current.0 && speed > 0 {
|
||||
(bytes_total - current.0) / speed
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Some((speed, eta))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn ratio_prefers_file_counts() {
|
||||
let mut view = InstallProgressView::start("1.20.4", "1.20.4", "原版");
|
||||
assert_eq!(view.ratio(), 0.0);
|
||||
assert!(!view.is_known());
|
||||
|
||||
view.files_total = 4;
|
||||
view.files_done = 1;
|
||||
view.bytes_total = 1000;
|
||||
view.bytes_done = 900;
|
||||
assert!((view.ratio() - 0.25).abs() < f32::EPSILON);
|
||||
assert!(view.is_known());
|
||||
assert_eq!(view.files_label(), "1/4 文件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_and_eta_labels_are_human_readable() {
|
||||
let view = InstallProgressView {
|
||||
speed_bps: 1_258_291,
|
||||
eta_secs: 75,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
assert_eq!(view.speed_label(), "1.2 MB/s");
|
||||
assert_eq!(view.eta_label(), "剩 1m15s");
|
||||
assert_eq!(InstallProgressView::new().speed_label(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_speed_needs_a_measurable_delta() {
|
||||
let start = Instant::now();
|
||||
let previous = (0, start);
|
||||
// 间隔太短 → 不算
|
||||
assert!(derive_speed(previous, (500, start + Duration::from_millis(10)), 1000).is_none());
|
||||
// 正常差分:1s 下 500 字节 → 500 B/s,剩 500 字节 → 1s
|
||||
let (speed, eta) =
|
||||
derive_speed(previous, (500, start + Duration::from_secs(1)), 1000).expect("应能算");
|
||||
assert_eq!(speed, 500);
|
||||
assert_eq!(eta, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_stages() {
|
||||
assert!(InstallStage::Done.is_terminal());
|
||||
assert!(InstallStage::Failed.is_terminal());
|
||||
assert!(!InstallStage::Downloading.is_terminal());
|
||||
assert_eq!(human_bytes(512), "512 B");
|
||||
assert_eq!(human_bytes(2048), "2.0 KB");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! 实例相关 DTO:磁贴与列表视图的唯一数据源。
|
||||
//!
|
||||
//! 按关注点拆成四个子模块(M222 为了给 LiteLoader 加入口而拆,纯搬移 + 新增一支):
|
||||
//!
|
||||
//! | 子模块 | 内容 |
|
||||
//! |---|---|
|
||||
//! | [`loader`] | 加载器词表(磁贴徽标 + 向导可选)、[`LoaderInfo`] |
|
||||
//! | [`runtime`] | [`Progress`] / [`LaunchStage`] / [`InstanceRuntime`] / [`InstanceSummary`] |
|
||||
//! | [`create`] | 新建实例向导的提交请求 [`CreateRequest`] |
|
||||
//!
|
||||
//! 路径对外**一个字没变**(`unlucky_presenter::dto::InstanceSummary` 等仍从这里取),
|
||||
//! 拆的是文件不是 API。
|
||||
|
||||
mod create;
|
||||
mod loader;
|
||||
mod runtime;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use create::CreateRequest;
|
||||
pub use loader::{LoaderInfo, LoaderKind};
|
||||
pub use runtime::{InstanceRuntime, InstanceSummary, LaunchStage, Progress};
|
||||
|
||||
pub use super::launch_failure::{LaunchFailureCause, LaunchFailureStage, LaunchFailureView};
|
||||
|
||||
/// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。
|
||||
pub type InstanceId = String;
|
||||
@@ -0,0 +1,34 @@
|
||||
//! 新建实例向导提交给 host 的请求。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::LoaderKind;
|
||||
|
||||
/// 新建实例向导提交给 host 的请求(已通过向导校验)。
|
||||
///
|
||||
/// ⚠️ **没有「父版本」槽位**:向导/下载页只装「叠在原版上」的那一层。要把 LiteLoader 叠在
|
||||
/// Forge 上,core 侧已有真实装配路径(`LoaderSource::resolve_on` 的 `parent` 参数 +
|
||||
/// `LoaderPlanInput::parent_installed`),但把父版本从 UI 传下来需要同时扩 `CreateRequest`、
|
||||
/// 下载页草稿与向导校验三处,本轮只做原版父链(见 M222 报告「未做/待排」)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreateRequest {
|
||||
/// 目标原版版本 id(`1.20.4`)
|
||||
pub version: String,
|
||||
/// 加载器
|
||||
pub loader: LoaderKind,
|
||||
/// 加载器版本(空 = 让 core 取最新)
|
||||
pub loader_version: String,
|
||||
/// 实例名(空 = 用版本 id / 加载器版本 id)
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl CreateRequest {
|
||||
/// 实际使用的实例名。
|
||||
pub fn instance_name(&self) -> String {
|
||||
if self.name.trim().is_empty() {
|
||||
self.version.clone()
|
||||
} else {
|
||||
self.name.trim().to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! 加载器词表(磁贴徽标 + 向导可选)与 [`LoaderInfo`]。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use unlucky_core::game::LoaderKind as CoreLoaderKind;
|
||||
|
||||
/// 加载器种类(磁贴徽标 + 详情页版本链)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LoaderKind {
|
||||
/// 原版(无加载器)
|
||||
Vanilla,
|
||||
/// Fabric
|
||||
Fabric,
|
||||
/// Quilt
|
||||
Quilt,
|
||||
/// Forge
|
||||
Forge,
|
||||
/// NeoForge
|
||||
NeoForge,
|
||||
/// OptiFine
|
||||
OptiFine,
|
||||
/// LiteLoader
|
||||
LiteLoader,
|
||||
}
|
||||
|
||||
impl LoaderKind {
|
||||
/// 徽标显示文字。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Vanilla => "原版",
|
||||
Self::Fabric => "Fabric",
|
||||
Self::Quilt => "Quilt",
|
||||
Self::Forge => "Forge",
|
||||
Self::NeoForge => "NeoForge",
|
||||
Self::OptiFine => "OptiFine",
|
||||
Self::LiteLoader => "LiteLoader",
|
||||
}
|
||||
}
|
||||
|
||||
/// 本 DTO 种类 → core 的宽种类。
|
||||
///
|
||||
/// **结构映射,一个 id 字面量都没有**:词表只有一个真相源
|
||||
/// ([`CoreLoaderKind::as_str`]),本 crate 写不进第二份。加一支 DTO 种类这里编不过。
|
||||
const fn to_core(self) -> CoreLoaderKind {
|
||||
match self {
|
||||
Self::Vanilla => CoreLoaderKind::Vanilla,
|
||||
Self::Fabric => CoreLoaderKind::Fabric,
|
||||
Self::Quilt => CoreLoaderKind::Quilt,
|
||||
Self::Forge => CoreLoaderKind::Forge,
|
||||
Self::NeoForge => CoreLoaderKind::NeoForge,
|
||||
Self::OptiFine => CoreLoaderKind::OptiFine,
|
||||
Self::LiteLoader => CoreLoaderKind::LiteLoader,
|
||||
}
|
||||
}
|
||||
|
||||
/// core 的宽种类 → 本 DTO 种类([`Self::to_core`] 的反向,同样不含字面量)。
|
||||
///
|
||||
/// core 给 `game::LoaderKind` 加一支、而 DTO 还没跟上时,这里编不过——这正是要的:
|
||||
/// UI 不许「新加载器默默显示成原版」。
|
||||
const fn from_core(kind: CoreLoaderKind) -> Self {
|
||||
match kind {
|
||||
CoreLoaderKind::Vanilla => Self::Vanilla,
|
||||
CoreLoaderKind::Fabric => Self::Fabric,
|
||||
CoreLoaderKind::Quilt => Self::Quilt,
|
||||
CoreLoaderKind::Forge => Self::Forge,
|
||||
CoreLoaderKind::NeoForge => Self::NeoForge,
|
||||
CoreLoaderKind::OptiFine => Self::OptiFine,
|
||||
CoreLoaderKind::LiteLoader => Self::LiteLoader,
|
||||
}
|
||||
}
|
||||
|
||||
/// 稳定 id(`vanilla` / `fabric` / …;GUI 与 `--json` 同词表)。
|
||||
///
|
||||
/// **委派**给 [`CoreLoaderKind::as_str`](M126):词表的源头表在 core(0.2.0 已发布契约;
|
||||
/// 另一处逐字副本是 `ull help instances` 的词表行),这里只剩一次结构映射。
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
self.to_core().as_str()
|
||||
}
|
||||
|
||||
/// 从稳定 id 解析(未知给 `None`,由调用方决定报错文案)。
|
||||
///
|
||||
/// 七个 id 的匹配委派给 [`CoreLoaderKind::parse`];本方法只额外收 `none` / 空串这两个
|
||||
/// **GUI 输入别名**(表单里「不装加载器」的写法)——它们不是对外输出的 id,也绝不参与
|
||||
/// [`Self::as_str`]。
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
let wanted = raw.trim();
|
||||
if wanted.is_empty() || wanted.eq_ignore_ascii_case("none") {
|
||||
return Some(Self::Vanilla);
|
||||
}
|
||||
Some(Self::from_core(CoreLoaderKind::parse(wanted)?))
|
||||
}
|
||||
|
||||
/// 向导里的可选加载器(顺序即展示顺序)。
|
||||
///
|
||||
/// 六选 = 原版 + 五家能装的。OptiFine 仍**不在这里**:它要跑外部 `optifine.Patcher` 并原地
|
||||
/// 改 zip,core 侧至今没有安装路线(见 `DevDocs/artifacts/m218/SURVEY-p1.md` §3),
|
||||
/// 放进向导只会让用户选完才失败。
|
||||
pub fn wizard_choices() -> [Self; 6] {
|
||||
[
|
||||
Self::Vanilla,
|
||||
Self::Fabric,
|
||||
Self::Quilt,
|
||||
Self::Forge,
|
||||
Self::NeoForge,
|
||||
Self::LiteLoader,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器 + 版本(例如 Fabric 0.16.5)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LoaderInfo {
|
||||
/// 加载器种类
|
||||
pub kind: LoaderKind,
|
||||
/// 加载器版本号;原版为空串
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl Default for LoaderInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kind: LoaderKind::Vanilla,
|
||||
version: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LoaderInfo {
|
||||
/// 徽标文字:原版只写「原版」,装加载器时写「Fabric 0.16.5」。
|
||||
pub fn badge(&self) -> String {
|
||||
if self.kind == LoaderKind::Vanilla || self.version.is_empty() {
|
||||
self.kind.label().to_string()
|
||||
} else {
|
||||
format!("{} {}", self.kind.label(), self.version)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! 进度 / 阶段 / 运行时状态 / 实例摘要(磁贴与列表的唯一数据源)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::super::LaunchArgsView;
|
||||
use super::{InstanceId, LoaderInfo};
|
||||
|
||||
/// 字节进度;`total == 0` 表示总量未知(UI 转不确定态)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct Progress {
|
||||
/// 已完成量
|
||||
pub done: u64,
|
||||
/// 总量;0 = 未知
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
/// 0.0~1.0;未知总量返回 0.0。
|
||||
pub fn ratio(self) -> f32 {
|
||||
if self.total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.done as f32 / self.total as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 总量是否已知。
|
||||
pub fn is_known(self) -> bool {
|
||||
self.total > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动管线阶段(磁贴进度环的文案来源;D8 的 10 阶段在 P1 细化)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LaunchStage {
|
||||
/// 下载资源
|
||||
Download,
|
||||
/// 校验文件
|
||||
Verify,
|
||||
/// 组装命令行并拉起进程
|
||||
Launch,
|
||||
/// 进程已起、等待游戏窗口(M28 的窗口检测阶段)
|
||||
Window,
|
||||
}
|
||||
|
||||
impl LaunchStage {
|
||||
/// 磁贴上的阶段文案。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Download => "下载资源",
|
||||
Self::Verify => "校验文件",
|
||||
Self::Launch => "组装命令行",
|
||||
Self::Window => "等待窗口",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 实例运行时状态(磁贴的四种外观)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum InstanceRuntime {
|
||||
/// 空闲
|
||||
#[default]
|
||||
Idle,
|
||||
/// 启动管线进行中
|
||||
Busy {
|
||||
/// 当前阶段
|
||||
stage: LaunchStage,
|
||||
/// 该阶段进度
|
||||
progress: Progress,
|
||||
},
|
||||
/// 游戏进程存活
|
||||
Running {
|
||||
/// 进程号(停止/诊断用)
|
||||
pid: u32,
|
||||
},
|
||||
/// 上次启动失败(磁贴显示「重试」)
|
||||
///
|
||||
/// M161 之前这里只有一个 `reason: String`,而且里面已经写着「启动失败:…」——
|
||||
/// 界面再包一层就成了「英文前缀 + 中文句子」,中文界面则是「启动失败 · 启动失败:…」。
|
||||
/// 现在给的是结构化原因(见 [`crate::dto::LaunchFailureCause`]):**没起来**与**起来又崩**是两种形状,
|
||||
/// 界面各说各的话(复审 P1-1:早先崩溃被说成「启动游戏进程失败」)。
|
||||
Failed {
|
||||
/// 这一场为什么是失败态
|
||||
cause: crate::dto::LaunchFailureCause,
|
||||
},
|
||||
}
|
||||
|
||||
/// 实例摘要:磁贴/列表视图唯一数据源。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InstanceSummary {
|
||||
/// 稳定 id
|
||||
pub id: InstanceId,
|
||||
/// 显示名
|
||||
pub name: String,
|
||||
/// 游戏版本(如 1.20.4)
|
||||
pub mc_version: String,
|
||||
/// 加载器信息
|
||||
pub loader: LoaderInfo,
|
||||
/// ISO-8601 UTC 最后游玩时间;None = 从未启动
|
||||
pub last_played: Option<String>,
|
||||
/// 运行时状态
|
||||
pub runtime: InstanceRuntime,
|
||||
/// 实例图标路径(P0 直接给路径;P1 换成 core 提供的图像句柄)
|
||||
pub icon: Option<PathBuf>,
|
||||
/// 隔离档位(`isolated` / `shared` / `custom`;未知为空)
|
||||
pub isolation: String,
|
||||
/// 该实例绑定的版本 id(详情页/启动用)
|
||||
pub version_id: String,
|
||||
/// 版本本体是否在盘上(缺载荷时磁贴亮黄条)
|
||||
pub version_present: bool,
|
||||
/// 启动参数(M157:实例配置页「启动参数」区的数据;含三级回落后的生效值与版本门槛)
|
||||
pub launch: LaunchArgsView,
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! 实例 DTO 的单测(词表契约 / 徽标 / 序列化往返)。
|
||||
|
||||
use super::*;
|
||||
use unlucky_core::game::LoaderKind as CoreLoaderKind;
|
||||
|
||||
use crate::dto::LaunchArgsView;
|
||||
|
||||
#[test]
|
||||
fn loader_badges_and_ids() {
|
||||
let vanilla = LoaderInfo::default();
|
||||
assert_eq!(vanilla.badge(), "原版");
|
||||
let fabric = LoaderInfo {
|
||||
kind: LoaderKind::Fabric,
|
||||
version: "0.16.5".to_string(),
|
||||
};
|
||||
assert_eq!(fabric.badge(), "Fabric 0.16.5");
|
||||
let liteloader = LoaderInfo {
|
||||
kind: LoaderKind::LiteLoader,
|
||||
version: "1.12.2-SNAPSHOT".to_string(),
|
||||
};
|
||||
assert_eq!(liteloader.badge(), "LiteLoader 1.12.2-SNAPSHOT");
|
||||
for kind in LoaderKind::wizard_choices() {
|
||||
assert_eq!(LoaderKind::parse(kind.as_str()), Some(kind));
|
||||
}
|
||||
assert_eq!(LoaderKind::parse("fabric"), Some(LoaderKind::Fabric));
|
||||
assert_eq!(LoaderKind::parse("unknown"), None);
|
||||
}
|
||||
|
||||
/// 向导可选集:**每一支都能被 core 的安装侧接受**(OptiFine 不在其中,因为它装不了)。
|
||||
///
|
||||
/// 抓得住:把 OptiFine 塞进向导(选完才失败)、漏掉 LiteLoader(GUI 走不到真实路线)。
|
||||
#[test]
|
||||
fn wizard_choices_are_all_installable() {
|
||||
let installable: Vec<&str> = LoaderKind::wizard_choices()
|
||||
.iter()
|
||||
.filter(|kind| **kind != LoaderKind::Vanilla)
|
||||
.map(|kind| kind.as_str())
|
||||
.collect();
|
||||
for id in &installable {
|
||||
assert!(
|
||||
unlucky_core::addon::loader::LoaderKind::parse(id).is_some(),
|
||||
"`{id}` 在向导里但安装侧不认"
|
||||
);
|
||||
}
|
||||
assert_eq!(installable.len(), 5, "原版 + 五家能装的:{installable:?}");
|
||||
assert!(!installable.contains(&"optifine"));
|
||||
}
|
||||
|
||||
/// 对外契约:七个 id 在测试里**独立抄一遍**(0.2.0 已发布形状),并与 core 的 `ALL` 做
|
||||
/// **集合比较**——这是对外词表的守卫(与 CLI 的 help 守卫互补)。显示路径另有守卫:
|
||||
/// `presenter::host::game::every_core_loader_kind_reaches_the_tile_as_itself`(按 `ALL` 遍历)。
|
||||
///
|
||||
/// 为什么不是编译器管:编译期只拦得到「漏 match」,而新种类在还有 match 的消费方用最省事的
|
||||
/// `=> Vanilla` 糊过去**编得过**,那时新加载器会静默显示成原版(`game::loader` 模块文档
|
||||
/// 第 ③ 步)。对外词表由下面第 ② 段的集合断言守,显示路径由上面那条测试守。
|
||||
///
|
||||
/// 抓得住:改词表(`neoforge` → `neo_forge`);**新增一家加载器而没主动更新这份契约**
|
||||
/// (core 的 `ALL` 多一支 ⇒ 集合不等);DTO 委派接错支(`OptiFine` 映到 `LiteLoader`);
|
||||
/// `parse` 收进不该有的别名。
|
||||
/// 抓不住:core 的解析规则(那是 `game::loader` 的
|
||||
/// `every_stable_id_parses_back_to_its_kind` 等测试的职责),以及真二进制输出面的 JSON
|
||||
/// (CLI 的 `json_rows_expose_loader_kind_and_version`)。
|
||||
#[test]
|
||||
fn loader_kind_ids_are_the_released_contract() {
|
||||
const CONTRACT: [(LoaderKind, &str); 7] = [
|
||||
(LoaderKind::Vanilla, "vanilla"),
|
||||
(LoaderKind::Fabric, "fabric"),
|
||||
(LoaderKind::Quilt, "quilt"),
|
||||
(LoaderKind::Forge, "forge"),
|
||||
(LoaderKind::NeoForge, "neoforge"),
|
||||
(LoaderKind::OptiFine, "optifine"),
|
||||
(LoaderKind::LiteLoader, "liteloader"),
|
||||
];
|
||||
|
||||
// ① DTO 这一层:每支的 id、反查、大小写。
|
||||
for (kind, id) in CONTRACT {
|
||||
assert_eq!(kind.as_str(), id, "{kind:?} 的稳定 id 变了");
|
||||
assert_eq!(LoaderKind::parse(id), Some(kind), "`{id}` 认不回来");
|
||||
let upper = id.to_ascii_uppercase();
|
||||
assert_eq!(LoaderKind::parse(&upper), Some(kind), "大写 `{upper}`");
|
||||
}
|
||||
|
||||
// ② core 的词表 ↔ 这份契约:**集合相等**才过(多一支、少一支、改一个字都红)。
|
||||
let mut actual: Vec<&str> = CoreLoaderKind::ALL
|
||||
.iter()
|
||||
.map(|kind| kind.as_str())
|
||||
.collect();
|
||||
actual.sort_unstable();
|
||||
let mut expected: Vec<&str> = CONTRACT.iter().map(|(_, id)| *id).collect();
|
||||
expected.sort_unstable();
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"core 的 `LoaderKind::ALL` 必须逐字等于对外契约:加一家加载器时先改这里 + help"
|
||||
);
|
||||
|
||||
// GUI 输入别名(不是对外 id):不装加载器有三种写法;别名与拼法都不许混进词表。
|
||||
for alias in ["none", "", " "] {
|
||||
assert_eq!(
|
||||
LoaderKind::parse(alias),
|
||||
Some(LoaderKind::Vanilla),
|
||||
"`{alias}`"
|
||||
);
|
||||
}
|
||||
assert_eq!(LoaderKind::parse("neo-forge"), None, "别名只归安装侧");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_ratio_handles_unknown_total() {
|
||||
assert_eq!(Progress { done: 1, total: 0 }.ratio(), 0.0);
|
||||
assert!((Progress { done: 1, total: 4 }.ratio() - 0.25).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_request_defaults_name_to_version() {
|
||||
let request = CreateRequest {
|
||||
version: "1.20.4".to_string(),
|
||||
loader: LoaderKind::Vanilla,
|
||||
loader_version: String::new(),
|
||||
name: " ".to_string(),
|
||||
};
|
||||
assert_eq!(request.instance_name(), "1.20.4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_summary_is_serde_round_trip_safe() {
|
||||
let instance = InstanceSummary {
|
||||
id: "inst-1".to_string(),
|
||||
name: "生存".to_string(),
|
||||
mc_version: "1.20.4".to_string(),
|
||||
loader: LoaderInfo {
|
||||
kind: LoaderKind::Fabric,
|
||||
version: "0.16.5".to_string(),
|
||||
},
|
||||
last_played: Some("2026-10-31T12:00:00.000Z".to_string()),
|
||||
runtime: InstanceRuntime::Busy {
|
||||
stage: LaunchStage::Window,
|
||||
progress: Progress { done: 1, total: 2 },
|
||||
},
|
||||
icon: None,
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.4".to_string(),
|
||||
version_present: true,
|
||||
launch: LaunchArgsView::default(),
|
||||
};
|
||||
let text = serde_json::to_string(&instance).expect("序列化");
|
||||
let back: InstanceSummary = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, instance);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Java 页 DTO:四家发行版、回退链可视化与本地运行时清单(D23/D13)。
|
||||
//!
|
||||
//! 回退链是「可观测性」的载体:安装失败时用户要能看见「Temurin 跳过 → Oracle 失败 →
|
||||
//! Microsoft 命中」这条链,而不是只拿到一句「下载失败」。链的每一步由 host 从
|
||||
//! `core::game::java::source::ChainLog` 翻译过来。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 一家发行版的展示行。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JavaVendorView {
|
||||
/// 稳定 id(`temurin` / `oracle` / `microsoft` / `zulu`)
|
||||
pub id: String,
|
||||
/// 展示名
|
||||
pub display: String,
|
||||
/// 优先级序号(1 起)
|
||||
pub rank: u32,
|
||||
/// 支持的 Java 大版本摘要(`21/17/11`)
|
||||
pub majors: String,
|
||||
/// 可用源(镜像 + 吞吐备注)
|
||||
pub sources: Vec<String>,
|
||||
/// 是否已装过这家(托管目录里有现成的)
|
||||
pub installed: bool,
|
||||
}
|
||||
|
||||
/// 回退链一步的结局。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum JavaChainOutcome {
|
||||
/// 还没尝试(安装前的计划链)
|
||||
Pending,
|
||||
/// 跳过(不支持目标大版本)
|
||||
Skip,
|
||||
/// 尝试过但失败
|
||||
Failed,
|
||||
/// 命中(下载/安装成功)
|
||||
Hit,
|
||||
}
|
||||
|
||||
impl JavaChainOutcome {
|
||||
/// 徽标文字。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "待尝试",
|
||||
Self::Skip => "跳过",
|
||||
Self::Failed => "失败",
|
||||
Self::Hit => "命中",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 core 的 `VendorAttempt` 结局字符串解析。
|
||||
pub fn parse(raw: &str) -> Self {
|
||||
match raw {
|
||||
"hit" => Self::Hit,
|
||||
"failed" => Self::Failed,
|
||||
"skip" | "skipped" => Self::Skip,
|
||||
_ => Self::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 回退链一步。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JavaChainStep {
|
||||
/// 发行版 id
|
||||
pub vendor: String,
|
||||
/// 结局
|
||||
pub outcome: JavaChainOutcome,
|
||||
/// 细节(跳过原因 / 失败摘要 / 命中的下载源)
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 本地已发现的 Java 运行时。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JavaRuntimeView {
|
||||
/// 可执行文件路径
|
||||
pub binary: String,
|
||||
/// 版本串(`17.0.9`)
|
||||
pub version: String,
|
||||
/// 大版本
|
||||
pub major: u32,
|
||||
/// 发行方
|
||||
pub vendor: String,
|
||||
/// 来源分类(system / managed / instance)
|
||||
pub origin: String,
|
||||
}
|
||||
|
||||
/// Java 页快照(一次事件整体替换)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JavaPageView {
|
||||
/// 地区判定(`domestic` / `global`)
|
||||
pub region: String,
|
||||
/// 优先级的来源(`region_default` / `java.vendorPriority`)
|
||||
pub priority_source: String,
|
||||
/// 生效顺序(`temurin → oracle → microsoft → zulu`)
|
||||
pub order_text: String,
|
||||
/// 四家展示行(按生效顺序)
|
||||
pub vendors: Vec<JavaVendorView>,
|
||||
/// 回退链(计划链或上一次安装的真实链)
|
||||
pub chain: Vec<JavaChainStep>,
|
||||
/// 本地运行时
|
||||
pub runtimes: Vec<JavaRuntimeView>,
|
||||
/// 托管目录
|
||||
pub managed_roots: Vec<String>,
|
||||
/// 是否有 Java 安装在跑
|
||||
pub busy: bool,
|
||||
/// 状态/错误文案
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Default for JavaPageView {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl JavaPageView {
|
||||
/// 空态(托管目录还没扫到东西)。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
region: "global".to_string(),
|
||||
priority_source: "region_default".to_string(),
|
||||
order_text: String::new(),
|
||||
vendors: Vec::new(),
|
||||
chain: Vec::new(),
|
||||
runtimes: Vec::new(),
|
||||
managed_roots: Vec::new(),
|
||||
busy: false,
|
||||
message: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 计划链:按生效顺序铺一排「待尝试」(安装前的可视化)。
|
||||
pub fn planned_chain(vendors: &[JavaVendorView]) -> Vec<JavaChainStep> {
|
||||
vendors
|
||||
.iter()
|
||||
.map(|vendor| JavaChainStep {
|
||||
vendor: vendor.id.clone(),
|
||||
outcome: JavaChainOutcome::Pending,
|
||||
detail: String::new(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 命中的那一家的展示名(`None` = 链上还没命中)。
|
||||
pub fn hit_vendor(&self) -> Option<&str> {
|
||||
self.chain
|
||||
.iter()
|
||||
.find(|step| step.outcome == JavaChainOutcome::Hit)
|
||||
.map(|step| step.vendor.as_str())
|
||||
}
|
||||
|
||||
/// 已安装的大版本列表(去重升序)。
|
||||
pub fn majors(&self) -> Vec<u32> {
|
||||
let mut majors: Vec<u32> = self.runtimes.iter().map(|runtime| runtime.major).collect();
|
||||
majors.sort_unstable();
|
||||
majors.dedup();
|
||||
majors
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn vendor(id: &str, rank: u32) -> JavaVendorView {
|
||||
JavaVendorView {
|
||||
id: id.to_string(),
|
||||
display: id.to_uppercase(),
|
||||
rank,
|
||||
majors: "21/17".to_string(),
|
||||
sources: Vec::new(),
|
||||
installed: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_chain_is_all_pending_in_rank_order() {
|
||||
let vendors = vec![vendor("temurin", 1), vendor("oracle", 2)];
|
||||
let chain = JavaPageView::planned_chain(&vendors);
|
||||
assert_eq!(chain.len(), 2);
|
||||
assert!(chain
|
||||
.iter()
|
||||
.all(|step| step.outcome == JavaChainOutcome::Pending));
|
||||
assert_eq!(chain[0].vendor, "temurin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_vendor_finds_the_hit_step() {
|
||||
let mut page = JavaPageView::new();
|
||||
page.chain = vec![
|
||||
JavaChainStep {
|
||||
vendor: "temurin".to_string(),
|
||||
outcome: JavaChainOutcome::Skip,
|
||||
detail: "不支持 8".to_string(),
|
||||
},
|
||||
JavaChainStep {
|
||||
vendor: "oracle".to_string(),
|
||||
outcome: JavaChainOutcome::Hit,
|
||||
detail: "BMCLAPI".to_string(),
|
||||
},
|
||||
];
|
||||
assert_eq!(page.hit_vendor(), Some("oracle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_parses_core_words() {
|
||||
assert_eq!(JavaChainOutcome::parse("hit"), JavaChainOutcome::Hit);
|
||||
assert_eq!(JavaChainOutcome::parse("failed"), JavaChainOutcome::Failed);
|
||||
assert_eq!(JavaChainOutcome::parse("skip"), JavaChainOutcome::Skip);
|
||||
assert_eq!(JavaChainOutcome::parse("?"), JavaChainOutcome::Pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn majors_are_deduped_and_sorted() {
|
||||
let mut page = JavaPageView::new();
|
||||
page.runtimes = vec![
|
||||
JavaRuntimeView {
|
||||
binary: "a".to_string(),
|
||||
version: "17".to_string(),
|
||||
major: 17,
|
||||
vendor: "Temurin".to_string(),
|
||||
origin: "managed".to_string(),
|
||||
},
|
||||
JavaRuntimeView {
|
||||
binary: "b".to_string(),
|
||||
version: "17".to_string(),
|
||||
major: 17,
|
||||
vendor: "Oracle".to_string(),
|
||||
origin: "system".to_string(),
|
||||
},
|
||||
];
|
||||
assert_eq!(page.majors(), vec![17]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! 实例启动参数 DTO(M157):实例配置页「启动参数」区的唯一数据源 + 写回补丁。
|
||||
//!
|
||||
//! 口径三条:
|
||||
//! 1. **实例层与生效值分开给**:`jvm_args` 等是实例文件里的原文(空 = 跟随全局),
|
||||
//! `*_effective` 是三级回落(实例 → 全局 → 默认)之后真正会下发的值。UI 要把两件事同时
|
||||
//! 画出来(「留空 = 跟随全局 {global}」这种提示与「生效值」是两回事);
|
||||
//! 2. **图形后端的可选值与门槛由 host 算好**:`graphics_backend_options` 来自 core 的词表
|
||||
//! ([`LaunchArgsView::graphics_backend_options`]),`graphics_backend_allowed` 来自 core 的
|
||||
//! 版本门槛判定——UI 不复制任何一条判据(任务书:判定用 core capability);
|
||||
//! 3. **本模块不含 core 类型**(D42:dto 只放扁平 IPC 类型)。
|
||||
//!
|
||||
//! 补丁的「不改 / 清空」二分:`None` = 这一项不动;`Some("")` = 清空(回到跟随)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 实例启动参数的**读取**视图(详情页「启动参数」区)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct LaunchArgsView {
|
||||
/// 实例 id。
|
||||
pub instance_id: String,
|
||||
/// 实例层 JVM 参数原文(空 = 只用全局 `java.extra_args`)。
|
||||
pub jvm_args: String,
|
||||
/// 实例层游戏参数原文(空 = 不追加)。
|
||||
pub game_args: String,
|
||||
/// 实例层最大堆(`None` = 跟随全局)。
|
||||
pub memory_mb: Option<u32>,
|
||||
/// 生效最大堆(三级回落 + 出厂默认,恒有值)。
|
||||
pub memory_effective_mb: u32,
|
||||
/// 实例层 Java 可执行文件原文(空 = 跟随全局 / 自动挑)。
|
||||
pub java_path: String,
|
||||
/// 生效 Java 可执行文件(空 = 自动挑;展示用,不代表已探测)。
|
||||
pub java_effective: String,
|
||||
/// 实例层图形后端(`default` / `opengl` / `vulkan`)。
|
||||
pub graphics_backend: String,
|
||||
/// 生效图形后端(三级回落之后)。
|
||||
pub graphics_backend_effective: String,
|
||||
/// 该实例版本能不能用 `--graphicsBackend`(core 的版本门槛判定;`false` ⇒ UI 灰化 + 提示)。
|
||||
pub graphics_backend_allowed: bool,
|
||||
/// 判定所依据的游戏版本(提示文案「需 MC 26.2+」旁边给出实际版本)。
|
||||
pub mc_version: String,
|
||||
/// 门槛的最低版本(core 的 `GRAPHICS_BACKEND_MIN_VERSION`;文案与测试共用一份来源)。
|
||||
pub graphics_backend_min_version: String,
|
||||
/// 图形后端三态的词表(顺序即下拉顺序;来自 core,不让 UI 手抄)。
|
||||
pub graphics_backend_options: Vec<String>,
|
||||
}
|
||||
|
||||
impl LaunchArgsView {
|
||||
/// 三态下拉的 id → 是不是当前生效值(UI 高亮用;未知值原样比较)。
|
||||
#[must_use]
|
||||
pub fn is_effective_backend(&self, id: &str) -> bool {
|
||||
self.graphics_backend_effective == id
|
||||
}
|
||||
}
|
||||
|
||||
/// 实例启动参数的**写回**补丁(GUI 表单与 CLI 共用)。
|
||||
///
|
||||
/// 每个字段:`None` = 不改;`Some("")` = 清空(回到跟随下一层)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct LaunchArgsPatch {
|
||||
/// 实例层 JVM 参数(文本,空白分隔)。
|
||||
pub jvm_args: Option<String>,
|
||||
/// 实例层游戏参数(文本)。
|
||||
pub game_args: Option<String>,
|
||||
/// 实例层最大堆(文本形态,host 解析;空 = 跟随全局)。
|
||||
pub memory_mb: Option<String>,
|
||||
/// 实例层 Java 可执行文件。
|
||||
pub java_path: Option<String>,
|
||||
/// 实例层图形后端(`default` / `opengl` / `vulkan`)。
|
||||
pub graphics_backend: Option<String>,
|
||||
}
|
||||
|
||||
impl LaunchArgsPatch {
|
||||
/// 是否什么都不改(UI 没动过表单时的短路)。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.jvm_args.is_none()
|
||||
&& self.game_args.is_none()
|
||||
&& self.memory_mb.is_none()
|
||||
&& self.java_path.is_none()
|
||||
&& self.graphics_backend.is_none()
|
||||
}
|
||||
|
||||
/// 只改图形后端(下拉切换的最小补丁)。
|
||||
#[must_use]
|
||||
pub fn backend(id: &str) -> Self {
|
||||
Self {
|
||||
graphics_backend: Some(id.to_string()),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_patch_touches_nothing() {
|
||||
assert!(LaunchArgsPatch::default().is_empty());
|
||||
assert!(!LaunchArgsPatch::backend("vulkan").is_empty());
|
||||
// `Some("")` 是「清空」而不是「不改」——两者不能折叠成一个判据。
|
||||
let clear = LaunchArgsPatch {
|
||||
jvm_args: Some(String::new()),
|
||||
..LaunchArgsPatch::default()
|
||||
};
|
||||
assert!(!clear.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_round_trips_as_ipc() {
|
||||
let view = LaunchArgsView {
|
||||
instance_id: "inst-1".to_string(),
|
||||
jvm_args: "-XX:+UseG1GC".to_string(),
|
||||
game_args: String::new(),
|
||||
memory_mb: Some(4096),
|
||||
memory_effective_mb: 4096,
|
||||
java_path: String::new(),
|
||||
java_effective: "C:/jdk21/bin/java.exe".to_string(),
|
||||
graphics_backend: "vulkan".to_string(),
|
||||
graphics_backend_effective: "vulkan".to_string(),
|
||||
graphics_backend_allowed: true,
|
||||
mc_version: "26.2-snapshot-2".to_string(),
|
||||
graphics_backend_min_version: "26.2-snapshot-2".to_string(),
|
||||
graphics_backend_options: vec![
|
||||
"default".to_string(),
|
||||
"opengl".to_string(),
|
||||
"vulkan".to_string(),
|
||||
],
|
||||
};
|
||||
let text = serde_json::to_string(&view).expect("序列化");
|
||||
let back: LaunchArgsView = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, view);
|
||||
assert!(back.is_effective_backend("vulkan"));
|
||||
assert!(!back.is_effective_backend("opengl"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! 启动失败的**结构**(M161:presenter 只给阶段 + 原文,文案由界面按 i18n key 现求)。
|
||||
//!
|
||||
//! 从 `dto/instances.rs` 拆出:那边贴着 400 行硬线,而这两个类型是「启动」这条线上的
|
||||
//! 独立一块(磁贴状态行、收尾事件、Toast 三处都读它)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 启动失败发生在**哪一步**(M161:界面文案按它选 i18n key,presenter 不写散文)。
|
||||
///
|
||||
/// 分档只在「用户该去做什么」不同的地方切,不按代码结构切:
|
||||
///
|
||||
/// | 档 | 意思 | 用户下一步 |
|
||||
/// |---|---|---|
|
||||
/// | [`Self::VersionManifest`] | `versions/<id>.json` 读不出来 / 解析不了 | 去版本管理重装这个版本 |
|
||||
/// | [`Self::Inheritance`] | 继承链断了(父版本缺失) | 同上(父版本缺了) |
|
||||
/// | [`Self::InstanceConfig`] | 实例设置文件读不出来 | 去实例页看一眼那个实例 |
|
||||
/// | [`Self::Isolation`] | 隔离目录派生失败(路径/权限) | 换隔离档位或修目录权限 |
|
||||
/// | [`Self::Identity`] | 账户装配失败(找不到账户 / 凭据过期) | 去账户页重新登录 |
|
||||
/// | [`Self::Spawn`] | 命令行装配完了但进程起不来 | 看 Java 与日志 |
|
||||
///
|
||||
/// 「跑起来了但崩了」不在这张表里:那是**结局**,由 `LogOutcome` 表达(三个变体各有 i18n key)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LaunchFailureStage {
|
||||
/// 版本 JSON 读不出来 / 解析不了
|
||||
VersionManifest,
|
||||
/// 继承链断裂
|
||||
Inheritance,
|
||||
/// 实例设置文件读不出来
|
||||
InstanceConfig,
|
||||
/// 隔离目录派生失败
|
||||
Isolation,
|
||||
/// 账户装配失败
|
||||
Identity,
|
||||
/// 进程起不来
|
||||
Spawn,
|
||||
}
|
||||
|
||||
impl LaunchFailureStage {
|
||||
/// 稳定 slug(日志 / 事件流 / 测试用;**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::VersionManifest => "version_manifest",
|
||||
Self::Inheritance => "inheritance",
|
||||
Self::InstanceConfig => "instance_config",
|
||||
Self::Isolation => "isolation",
|
||||
Self::Identity => "identity",
|
||||
Self::Spawn => "spawn",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次启动失败:**哪一步** + 那一步的原文。
|
||||
///
|
||||
/// `detail` 是当场的原始错误文本(core / io / 账户层给的),**故意不翻译**:里面有路径、
|
||||
/// 版本 id、HTTP 状态这类机器细节,翻成人话就丢了取证价值。要本地化的是「哪一步」
|
||||
/// ([`LaunchFailureStage`]),界面按它选词。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LaunchFailureView {
|
||||
/// 失败阶段
|
||||
pub stage: LaunchFailureStage,
|
||||
/// 原始错误文本(不翻译)
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl LaunchFailureView {
|
||||
/// 建一条。
|
||||
pub fn new(stage: LaunchFailureStage, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
stage,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 磁贴/详情上「这一场为什么是失败态」的**完整原因**。
|
||||
///
|
||||
/// 两种**互斥**的形状,混成一种就会说假话(M161 复审 P1-1 抓的就是这个):
|
||||
///
|
||||
/// | 形状 | 意思 | 界面该说什么 |
|
||||
/// |---|---|---|
|
||||
/// | [`Self::Stage`] | 进程**没起来**(装配/启动阶段失败) | 「启动失败 · <哪一步>:<原文>」 |
|
||||
/// | [`Self::Crashed`] | 进程起来过,**崩了** | 「启动失败 · 游戏崩溃(退出码 N)」 |
|
||||
/// | [`Self::SilentFailure`] | 进程起来过,但 exit 0 却没真正跑起来 | 「启动失败 · 退出码 0,但游戏没起来」 |
|
||||
///
|
||||
/// 为什么把结局也放在这里:磁贴的状态行与 Toast 必须说同一件事。早先「起来了又崩」被
|
||||
/// 硬塞进 [`LaunchFailureStage::Spawn`](因为 `LaunchFinished.failure` 在这次是 `None`),
|
||||
/// 于是磁贴写「启动游戏进程失败」而 Toast 写「游戏崩溃」——同一时刻两句话打架,而且
|
||||
/// `detail` 还漏出了 `exit Some(1)` 这种 Rust Debug 文本。
|
||||
///
|
||||
/// `detail` 只在 [`Self::Stage`] 里有:那是 core / io 给的原文(路径、版本 id、os error),
|
||||
/// **故意不翻译**。结局类没有什么「原文」可给,退出码是数字,措辞由界面按语言出。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LaunchFailureCause {
|
||||
/// 装配/启动阶段失败:进程**没起来**
|
||||
Stage {
|
||||
/// 失败在上面的哪一步
|
||||
stage: LaunchFailureStage,
|
||||
/// 那一步的原始错误文本(不翻译)
|
||||
detail: String,
|
||||
},
|
||||
/// 进程起来过,结局是崩溃
|
||||
Crashed {
|
||||
/// 退出码(`None` = 平台没给)
|
||||
exit_code: Option<i32>,
|
||||
},
|
||||
/// 进程起来过,但疑似静默失败(exit 0 却没真正跑起来)
|
||||
SilentFailure {
|
||||
/// 退出码(通常是 0)
|
||||
exit_code: Option<i32>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LaunchFailureCause {
|
||||
/// 稳定 slug(日志 / 测试用;**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn slug(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Stage { .. } => "stage",
|
||||
Self::Crashed { .. } => "crashed",
|
||||
Self::SilentFailure { .. } => "silent_failure",
|
||||
}
|
||||
}
|
||||
|
||||
/// 进程到底有没有起来过。
|
||||
#[must_use]
|
||||
pub const fn started(&self) -> bool {
|
||||
!matches!(self, Self::Stage { .. })
|
||||
}
|
||||
|
||||
/// 由一次收尾组装原因。
|
||||
///
|
||||
/// `failure` 有值 = 装配/启动阶段失败(进程没起来);`None` = 跑起来了,此时**只能**
|
||||
/// 看 `outcome`(`Ok` 不该走到这里,兜底按崩溃处理)。
|
||||
#[must_use]
|
||||
pub fn of(
|
||||
outcome: crate::dto::LogOutcome,
|
||||
failure: Option<&LaunchFailureView>,
|
||||
exit_code: Option<i32>,
|
||||
) -> Self {
|
||||
match failure {
|
||||
Some(failure) => Self::Stage {
|
||||
stage: failure.stage,
|
||||
detail: failure.detail.clone(),
|
||||
},
|
||||
None if outcome == crate::dto::LogOutcome::SilentFail => {
|
||||
Self::SilentFailure { exit_code }
|
||||
}
|
||||
None => Self::Crashed { exit_code },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! 日志页 DTO:会话列表、日志行与 D8 诊断结论。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 一次启动会话(日志页左栏单位)标识。
|
||||
pub type SessionId = String;
|
||||
|
||||
/// 一次运行的结局(G3 左栏图标:✓ 正常 / ✗ 崩溃 / ⚠ 静默失败)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LogOutcome {
|
||||
/// 正常退出
|
||||
Ok,
|
||||
/// 崩溃(非零退出码或 hs_err_pid)
|
||||
Crashed,
|
||||
/// exit0 但游戏没起来(D8 新增检测)
|
||||
SilentFail,
|
||||
}
|
||||
|
||||
/// 会话摘要。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LogSession {
|
||||
/// 会话 id
|
||||
pub id: SessionId,
|
||||
/// 实例名(会话属于哪个实例)
|
||||
pub instance: String,
|
||||
/// 展示用时间(`10-29 19:22`)
|
||||
pub when: String,
|
||||
/// 结局
|
||||
pub outcome: LogOutcome,
|
||||
/// 时长(秒)
|
||||
pub duration_secs: u64,
|
||||
}
|
||||
|
||||
/// 日志行等级(错误行高亮)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LogLevel {
|
||||
/// debug
|
||||
Debug,
|
||||
/// info
|
||||
Info,
|
||||
/// warn
|
||||
Warn,
|
||||
/// error
|
||||
Error,
|
||||
}
|
||||
|
||||
/// 一行日志。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LogLine {
|
||||
/// 原始文本(含时间戳前缀)
|
||||
pub text: String,
|
||||
/// 等级
|
||||
pub level: LogLevel,
|
||||
}
|
||||
|
||||
/// D8 规则引擎结论(日志页崩溃诊断卡片)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Diagnosis {
|
||||
/// 命中规则 id(`gpu.driver_old`)
|
||||
pub rule: String,
|
||||
/// 结论标题
|
||||
pub title: String,
|
||||
/// 0~100 置信度
|
||||
pub confidence: u8,
|
||||
/// 建议文案
|
||||
pub hint: String,
|
||||
/// 可一键执行的修复动作(无则只给建议)
|
||||
pub fix: Option<String>,
|
||||
}
|
||||
|
||||
/// 诊断结论 + 归属会话(UI 用它决定卡片显隐)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DiagnosisReport {
|
||||
/// 归属会话
|
||||
pub session: SessionId,
|
||||
/// 诊断内容
|
||||
pub diagnosis: Diagnosis,
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! presenter ↔ core/agent 的 IPC 边界(01c §5 纪律)。
|
||||
//!
|
||||
//! 这里只允许出现「扁平 DTO / 事件 / 指令」:本 crate 的 DTO 里不得出现 `unlucky_core::*`
|
||||
//! 或 `use unlucky_agent::*` 的内部类型。core/agent 侧的 host 层负责把自身类型翻译成本模块的类型,
|
||||
//! 反向的 [`AppCommand`] 也一样。P0 阶段由 [`crate::mock`] 的假后端实现 [`Backend`] 驱动骨架,
|
||||
//! P3 起由 [`crate::host`] 的真实后端驱动全部页面。
|
||||
//!
|
||||
//! 结构:**按领域拆子模块**(instances / versions / logs / agent / accounts / java /
|
||||
//! settings / install / notices / content / events),全量 `pub use` 出去,调用方依旧写
|
||||
//! `crate::dto::InstanceSummary` 这类路径。
|
||||
//! 契约说明(字段口径、事件顺序、错误语义)见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`;
|
||||
//! UI 侧的唯一入口与 iced 接线用法见 crate 文档(`src/lib.rs`)。
|
||||
//!
|
||||
//! 全部跨界类型 `Serialize + Deserialize`(AGENTS §6):事件与快照是 IPC 契约,
|
||||
//! 序列化能力由 derive 钉住,`dto` 的单测做 round-trip 断言。
|
||||
|
||||
mod accounts;
|
||||
// M223: 导入已有版本(从别的启动器只读登记 versions/<id>)
|
||||
mod adopt;
|
||||
mod agent;
|
||||
mod content;
|
||||
mod events;
|
||||
mod install;
|
||||
mod instances;
|
||||
mod java;
|
||||
// M157: 实例启动参数(启动参数区的读取视图 + 写回补丁)
|
||||
mod launch_args;
|
||||
mod launch_failure;
|
||||
mod logs;
|
||||
mod notices;
|
||||
// M113: import/export —— 整合包导入/导出的 DTO(M111/M112 管线的 IPC 面)
|
||||
mod pack;
|
||||
// M224: 本地 Mod 列表与启停(实例详情页的 MODS 区)
|
||||
mod mods;
|
||||
// M235: 实例存档 / 截图的只读列表(详情页的「存档」区)
|
||||
mod saves;
|
||||
mod settings;
|
||||
// M159: 自更新(检查结论 / 候选 / 就位状态)
|
||||
mod update;
|
||||
mod versions;
|
||||
|
||||
pub use accounts::{
|
||||
looks_like_jwt, redact, valid_offline_name, AccountKind, AccountNotice, AccountNoticeCode,
|
||||
AccountView, AuthlibLoginView, AuthlibRequest, BrowserLoginNote, DeviceLoginPhase,
|
||||
DeviceLoginView, LoginFailure, SkinImage, REDACTED,
|
||||
};
|
||||
pub use adopt::{
|
||||
AdoptCandidateView, AdoptCommand, AdoptDoneView, AdoptEvent, AdoptFailureView,
|
||||
AdoptIsolationView, AdoptPlanView, AdoptProblemView, AdoptRequestView, AdoptScanView,
|
||||
AdoptStage, AdoptState, AdoptTargetView,
|
||||
};
|
||||
pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView};
|
||||
pub use content::{
|
||||
loader_facet, ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind,
|
||||
ContentHit, ContentInstallRequest, ContentKind, ContentQuery, ContentResults, ContentSort,
|
||||
ContentSource, ContentState, ContentVersionView,
|
||||
};
|
||||
pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch};
|
||||
pub use install::{
|
||||
derive_speed, human_bytes, InstallFailureKind, InstallFailureView, InstallProgressView,
|
||||
InstallStage,
|
||||
};
|
||||
pub use instances::{
|
||||
CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchFailureCause,
|
||||
LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, Progress,
|
||||
};
|
||||
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
|
||||
pub use launch_args::{LaunchArgsPatch, LaunchArgsView};
|
||||
pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId};
|
||||
// M224: 本地 Mod 列表与启停
|
||||
pub use mods::{
|
||||
locate, ModEntryView, ModFailureKind, ModFailureView, ModListView, ModSkipKind, ModSkipView,
|
||||
ModsCommand,
|
||||
};
|
||||
pub use notices::{NoticeLevel, NoticeView};
|
||||
// M113: import/export
|
||||
pub use pack::{
|
||||
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView,
|
||||
ConflictReasonView, ConflictSourceView, ExportClassView, ExportDoneView, ExportFileView,
|
||||
ExportPreviewView, ExportReasonView, ExportRequestView, ExportState, FileActionView,
|
||||
ImportCancelView, ImportConflictView, ImportErrorView, ImportFailureStageView,
|
||||
ImportProgressView, ImportReportView, ImportRequestView, ImportScanView, ImportStageView,
|
||||
ImportState, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView,
|
||||
PackCommand, PackEvent, PackFormatView, PackState, ProbeView,
|
||||
};
|
||||
pub use settings::{
|
||||
find_row, SettingControl, SettingGroupView, SettingRowView, JAVA_REFRESH_ACTION,
|
||||
};
|
||||
// M235: 实例存档 / 截图的只读列表(M251 加逐张缩略图预览)
|
||||
pub use saves::{
|
||||
SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView,
|
||||
SavesOped, SavesSkipKind, SavesSkipView, ScreenshotPreview, ScreenshotThumbFailure,
|
||||
ScreenshotThumbKind, ScreenshotView, ThumbnailView, WorldView,
|
||||
};
|
||||
// M159: 自更新
|
||||
pub use update::{
|
||||
ReadyUpdate, UpdateBanner, UpdateCandidateView, UpdateFailureView, UpdateOutcome, UpdateView,
|
||||
};
|
||||
pub use versions::{AncientKind, VersionCategory, VersionKind, VersionSummary};
|
||||
@@ -0,0 +1,238 @@
|
||||
//! 本地 Mod 列表与启停的 IPC 面(M224 · MODTOGGLE)。
|
||||
//!
|
||||
//! M218 的结论是这块要**新建三层**(core 读写目录 + presenter DTO + 实例详情页),且**不许**
|
||||
//! 给内容页加第五个 `ContentKind`(会打断 4 处穷尽 match)。本文件是中间那层:
|
||||
//!
|
||||
//! | 方向 | 类型 |
|
||||
//! |---|---|
|
||||
//! | app → host | [`ModsCommand`](列目录 / 启停一个文件) |
|
||||
//! | host → app | [`ModListView`](整份快照)或 [`ModFailureView`](具名原因) |
|
||||
//!
|
||||
//! ## 为什么不复用内容页的 `ContentInstallRequest`
|
||||
//!
|
||||
//! 那条通路是「远端 → 本地」,入口在内容页;启停是「本地 → 本地」,入口在实例详情页。
|
||||
//! 把两者塞进一个 DTO 会让内容页为了一个它不画的字段多一次穷尽 match——正是 §3 点名要避免的。
|
||||
//!
|
||||
//! ## 文案纪律(M161)
|
||||
//!
|
||||
//! 本模块**不带人读散文**:失败给的是结构([`ModFailureKind`] + 底层 detail 串),
|
||||
//! 由 app 层按 i18n key 现求。`detail` 是 OS 原文(`Access is denied.` 之类),只进括号,
|
||||
//! 不进主句——主句必须能翻译。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::{InstanceId, InstanceSummary};
|
||||
|
||||
/// app → host 的本地 Mod 指令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ModsCommand {
|
||||
/// 列某个实例的 `mods/`(进详情页时发、启停成功后 host 自己再发一次)。
|
||||
List(InstanceId),
|
||||
/// 启停一个文件(`file` 是**磁盘上的文件名**,含后缀)。
|
||||
SetEnabled {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// 磁盘文件名(`sodium.jar.disabled`)
|
||||
file: String,
|
||||
/// 期望的启用状态
|
||||
enabled: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// 列表里的一条。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ModEntryView {
|
||||
/// 磁盘文件名(含 `.disabled` / `.old`,UI 的开关消息原样带回去)。
|
||||
pub file: String,
|
||||
/// 展示名(去掉两个后缀)。
|
||||
pub name: String,
|
||||
/// 是否启用。
|
||||
pub enabled: bool,
|
||||
/// 字节数(UI 自己格式化,不在这里拼 "1.2 MiB")。
|
||||
pub bytes: u64,
|
||||
/// 是否带 `.old`(旧版本残留,只展示不清理)。
|
||||
pub old: bool,
|
||||
}
|
||||
|
||||
/// 没列出来的一条(UI 只需计数 + 一句"为什么")。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ModSkipView {
|
||||
/// 文件名。
|
||||
pub file: String,
|
||||
/// 跳过原因(结构,不翻译)。
|
||||
pub reason: ModSkipKind,
|
||||
}
|
||||
|
||||
/// 跳过的原因(与 core 的 `addon::mods::SkipReason` 一一对应)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ModSkipKind {
|
||||
/// 符号链接(不跟随)。
|
||||
SymbolicLink,
|
||||
/// 不是普通文件(目录 / 设备)。
|
||||
NotAFile,
|
||||
/// 名字过不了路径规则。
|
||||
UnsafeName,
|
||||
/// 元数据读不出来。
|
||||
Unreadable,
|
||||
}
|
||||
|
||||
/// 一次列目录的完整快照(**整体替换**,不做增量)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ModListView {
|
||||
/// 实例 id
|
||||
pub instance: InstanceId,
|
||||
/// 被列的 `mods/` 目录(绝对路径,UI 的"打开文件夹"用)。
|
||||
pub dir: String,
|
||||
/// 目录存不存在(`false` = 这个实例还没装过 mod)。
|
||||
pub exists: bool,
|
||||
/// 列表(按展示名排序)。
|
||||
pub entries: Vec<ModEntryView>,
|
||||
/// 跳过条目。
|
||||
pub skipped: Vec<ModSkipView>,
|
||||
/// 这一份快照采样的时刻,游戏是否在该实例上运行(运行中开关只读)。
|
||||
pub running: bool,
|
||||
/// 采样时的实例快照里有没有这个实例(`false` = 实例被删了,页面该退回列表)。
|
||||
pub known: bool,
|
||||
}
|
||||
|
||||
impl ModListView {
|
||||
/// 启用中的条数。
|
||||
#[must_use]
|
||||
pub fn enabled_count(&self) -> usize {
|
||||
self.entries.iter().filter(|entry| entry.enabled).count()
|
||||
}
|
||||
|
||||
/// 禁用中的条数。
|
||||
#[must_use]
|
||||
pub fn disabled_count(&self) -> usize {
|
||||
self.entries.len() - self.enabled_count()
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次启停失败的具名原因(app 按 key 现求文案)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ModFailureView {
|
||||
/// 实例 id(迟到/串台的结果靠它判)。
|
||||
pub instance: InstanceId,
|
||||
/// 涉及的文件(列表里没有时为空串)。
|
||||
pub file: String,
|
||||
/// 失败大类。
|
||||
pub kind: ModFailureKind,
|
||||
/// 底层原文(OS 错误 / 路径细节);只进括号,不进主句。
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 失败大类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ModFailureKind {
|
||||
/// 文件名不是裸文件名。
|
||||
UnsafeName,
|
||||
/// `mods/` 不可用(不存在 / 符号链接 / 不是目录 / 读不了)。
|
||||
ModsDirUnusable,
|
||||
/// 文件不在了。
|
||||
NotFound,
|
||||
/// 目标不是普通文件。
|
||||
NotARegularFile,
|
||||
/// 目标名已被占用(不覆盖)。
|
||||
Collision,
|
||||
/// 改名失败。
|
||||
Rename,
|
||||
/// 实例不在了(快照里找不到),或隔离档位派生不出运行目录。
|
||||
InstanceUnavailable,
|
||||
/// 游戏正在这个实例上运行(host 侧主动挡:Windows 上 rename 会失败,别让用户猜)。
|
||||
Running,
|
||||
}
|
||||
|
||||
/// 实例 id 在快照里的最小定位结果(`None` = 实例已不在)。
|
||||
#[must_use]
|
||||
pub fn locate<'a>(instances: &'a [InstanceSummary], id: &str) -> Option<&'a InstanceSummary> {
|
||||
instances
|
||||
.iter()
|
||||
.find(|instance| instance.id == id || instance.name == id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dto::InstanceRuntime;
|
||||
|
||||
fn summary(id: &str, name: &str) -> InstanceSummary {
|
||||
InstanceSummary {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
mc_version: "1.20.1".to_string(),
|
||||
loader: crate::dto::LoaderInfo {
|
||||
kind: crate::dto::LoaderKind::Vanilla,
|
||||
version: String::new(),
|
||||
},
|
||||
last_played: None,
|
||||
runtime: InstanceRuntime::Idle,
|
||||
icon: None,
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.1".to_string(),
|
||||
version_present: true,
|
||||
launch: crate::dto::LaunchArgsView::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 快照计数与 IPC round-trip(AGENTS §6:跨界类型必须能序列化)。
|
||||
#[test]
|
||||
fn view_counts_and_round_trips() {
|
||||
let view = ModListView {
|
||||
instance: "inst".to_string(),
|
||||
dir: "/tmp/run/mods".to_string(),
|
||||
exists: true,
|
||||
entries: vec![
|
||||
ModEntryView {
|
||||
file: "a.jar".to_string(),
|
||||
name: "a.jar".to_string(),
|
||||
enabled: true,
|
||||
bytes: 10,
|
||||
old: false,
|
||||
},
|
||||
ModEntryView {
|
||||
file: "b.jar.disabled".to_string(),
|
||||
name: "b.jar".to_string(),
|
||||
enabled: false,
|
||||
bytes: 20,
|
||||
old: false,
|
||||
},
|
||||
],
|
||||
skipped: Vec::new(),
|
||||
running: false,
|
||||
known: true,
|
||||
};
|
||||
assert_eq!(view.enabled_count(), 1);
|
||||
assert_eq!(view.disabled_count(), 1);
|
||||
|
||||
let text = serde_json::to_string(&view).expect("序列化");
|
||||
let back: ModListView = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, view);
|
||||
|
||||
let command = ModsCommand::SetEnabled {
|
||||
id: "inst".to_string(),
|
||||
file: "a.jar".to_string(),
|
||||
enabled: false,
|
||||
};
|
||||
let text = serde_json::to_string(&command).expect("序列化");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ModsCommand>(&text).expect("反序列化"),
|
||||
command
|
||||
);
|
||||
}
|
||||
|
||||
/// 定位:id 优先、名字兜底(与 `launch_target` 同口径)。
|
||||
#[test]
|
||||
fn locate_matches_by_id_then_name() {
|
||||
let instances = vec![summary("survival", "生存"), summary("other", "别的")];
|
||||
assert_eq!(
|
||||
locate(&instances, "survival").map(|entry| entry.name.as_str()),
|
||||
Some("生存")
|
||||
);
|
||||
assert_eq!(
|
||||
locate(&instances, "别的").map(|entry| entry.id.as_str()),
|
||||
Some("other")
|
||||
);
|
||||
assert!(locate(&instances, "gone").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! 横幅通知 DTO:实例页/下载页顶部的「需要注意」提示条。
|
||||
//!
|
||||
//! 与 Toast 的分工:Toast 是「刚才发生了什么」(3 秒消失),通知条是
|
||||
//! 「现在需要你处理」(用户点了才收)。隔离模式切换提示(M30 `ModeChangeWarning`)
|
||||
//! 与启动失败都用它。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 通知级别。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NoticeLevel {
|
||||
/// 一般信息
|
||||
Info,
|
||||
/// 需要留意(黄条)
|
||||
Warn,
|
||||
/// 失败(红条)
|
||||
Error,
|
||||
}
|
||||
|
||||
impl NoticeLevel {
|
||||
/// 级别文案(无障碍/测试用)。
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Info => "信息",
|
||||
Self::Warn => "注意",
|
||||
Self::Error => "错误",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条通知。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NoticeView {
|
||||
/// 稳定 id(关闭时按它移除)
|
||||
pub id: String,
|
||||
/// 级别
|
||||
pub level: NoticeLevel,
|
||||
/// 标题
|
||||
pub title: String,
|
||||
/// 正文
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl NoticeView {
|
||||
/// 建一条。
|
||||
pub fn new(id: &str, level: NoticeLevel, title: &str, body: &str) -> Self {
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
level,
|
||||
title: title.to_string(),
|
||||
body: body.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn notices_are_serde_round_trip_safe() {
|
||||
let notice = NoticeView::new(
|
||||
"isolation:demo",
|
||||
NoticeLevel::Warn,
|
||||
"隔离模式已切换",
|
||||
"旧目录里还有 mods/saves",
|
||||
);
|
||||
let text = serde_json::to_string(¬ice).expect("序列化");
|
||||
let back: NoticeView = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, notice);
|
||||
assert_eq!(back.level.label(), "注意");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! 整合包导入/导出 DTO(M113 `PACKUI`):把 M111/M112 的管线结果搬过 IPC 边界。
|
||||
//!
|
||||
//! ## 为什么这里几乎是 core 报告的逐字段镜像
|
||||
//!
|
||||
//! `crate::dto` 的规矩(见 `dto/mod.rs`):**不得出现 `unlucky_core::*` 类型**,跨边界类型必须
|
||||
//! `Serialize + Deserialize`。所以这里一面不引 core,一面又不许「按 UI 方便重新发明一套语义」——
|
||||
//! 三态、阶段、分类、原因全部**逐条对应** `core::addon::import::report` 与
|
||||
//! `core::addon::export::report`,只把 `&'static str` 的稳定码换成人读串、把 `PathBuf` 落成
|
||||
//! 页面能显示的形态。翻译(core 类型 → 本模块)发生在 `host::addon`,页面侧不认识 core。
|
||||
//!
|
||||
//! 三条不许在翻译时丢的东西:
|
||||
//!
|
||||
//! 1. **三态**([`ImportStatusView`]):`Partial` 不是 `Success`。把「缺了 3 个文件」说成成功,
|
||||
//! 用户会在启动后才发现缺依赖;
|
||||
//! 2. **容忍 / 致命的分野**:[`MissingFileView`] 是「搞不到某个文件」(实例可用),
|
||||
//! [`ImportErrorView`] 是致命失败。UI 必须让这两类**视觉可区分**——并且致命那一类还要按
|
||||
//! [`ImportReportView::failure_stage`] 再分三档说(预检没过 / 已回滚 / 回滚没做完,M161);
|
||||
//! 3. **导出的三分类**([`ExportClassView`]):`Inline` 是「证明不了可被远程替代,原字节进
|
||||
//! `overrides/`」,`Skipped` 是「压根没进包」。`skipped > 0` 意味着包**不完整**。
|
||||
//!
|
||||
//! ## 文件分工(守 400 行)
|
||||
//!
|
||||
//! | 文件 | 职责 |
|
||||
//! |---|---|
|
||||
//! | [`format`] | [`PackFormatView`](三个格式 + slug 对齐) |
|
||||
//! | [`import`] | 请求 / 进度 / 三态 / 逐项原因 / 探测诊断 |
|
||||
//! | [`conflict`] | 冲突成因、处置、三方哈希 |
|
||||
//! | [`export`] | 三分类 / 逐条原因 / 预检 |
|
||||
//!
|
||||
//! ## 冲突:预检清单 → 用户点头 → 事后账(M160)
|
||||
//!
|
||||
//! 三样东西在三处,别混:
|
||||
//!
|
||||
//! | 类型 | 时机 | 语义 |
|
||||
//! |---|---|---|
|
||||
//! | [`ImportScanView`] / [`ImportConflictView`] | 导入**前**([`PackEvent::Scan`]) | 待用户点头的冲突清单 |
|
||||
//! | [`ConflictDecisionsView`] | 页面 → 宿主([`ImportRequestView::decisions`]) | 全部保留 / 全部覆盖 / 逐项 |
|
||||
//! | [`ConflictFileView`] | 导入**后**([`PackEvent::Report`]) | 每条实际怎么处置的 |
|
||||
//!
|
||||
//! 两处缺省都是**保留**([`ConflictDecisionsView::default`] = `KeepAll`,core 的
|
||||
//! `ConflictChoice::default()` = `KeepUser`):**绝不默认覆盖用户已有文件**。
|
||||
|
||||
mod conflict;
|
||||
mod event;
|
||||
mod export;
|
||||
mod format;
|
||||
mod import;
|
||||
|
||||
pub use conflict::{
|
||||
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView,
|
||||
ConflictReasonView, ConflictSourceView, ImportConflictView, ImportScanView,
|
||||
};
|
||||
pub use event::{PackCommand, PackEvent, PackState};
|
||||
pub use export::{
|
||||
ExportClassView, ExportDoneView, ExportFileView, ExportPreviewView, ExportReasonView,
|
||||
ExportRequestView, ExportState,
|
||||
};
|
||||
pub use format::PackFormatView;
|
||||
pub use import::{
|
||||
FileActionView, ImportCancelView, ImportErrorView, ImportFailureStageView, ImportProgressView,
|
||||
ImportReportView, ImportRequestView, ImportStageView, ImportState, ImportStatusView,
|
||||
ImportedFileView, MissingFileView, MissingReasonView, ProbeView,
|
||||
};
|
||||
@@ -0,0 +1,298 @@
|
||||
//! 冲突的 DTO:成因、本次处置、**预检的待决清单**、以及三方哈希的那一条账。
|
||||
//!
|
||||
//! 三种东西分得清清楚楚(混起来就会把"要不要覆盖"变成"已经覆盖了"):
|
||||
//!
|
||||
//! | 类型 | 什么时候出现 | 语义 |
|
||||
//! |---|---|---|
|
||||
//! | [`ImportConflictView`] | **导入前**(`PackEvent::Scan`) | 待用户点头的一条:路径、来源、目标现有体积与哈希 |
|
||||
//! | [`ConflictFileView`] | **导入后**(`PackEvent::Report`) | 事后账:这条实际怎么处置的(`choice`) |
|
||||
//! | [`ConflictDecisionsView`] | 从页面发回去(`ImportRequestView::decisions`) | 用户点的「全部保留 / 全部覆盖 / 逐项」 |
|
||||
//!
|
||||
//! 「默认保留用户那一侧」这条硬线在 [`ConflictDecisionsView::default`] 上:缺省 = 全部保留,
|
||||
//! 而 core 侧 [`ConflictChoiceView::KeepUser`] 也是缺省档——两侧都不许"没收到决策就覆盖"。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 一条冲突**来源**(预检清单里那一列)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConflictSourceView {
|
||||
/// 包内 `overrides/`(CF 清单点名的那个目录 / MultiMC 的 `.minecraft/` 也走这条)。
|
||||
Overrides,
|
||||
/// 包内 `client-overrides/`(叠层里胜出的那一侧)。
|
||||
ClientOverrides,
|
||||
/// CurseForge `files[]` 条目。
|
||||
CurseForge,
|
||||
/// Modrinth `files[]` 条目。
|
||||
Modrinth,
|
||||
}
|
||||
|
||||
impl ConflictSourceView {
|
||||
/// 稳定 slug。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Overrides => "overrides",
|
||||
Self::ClientOverrides => "client_overrides",
|
||||
Self::CurseForge => "curseforge",
|
||||
Self::Modrinth => "modrinth",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"overrides" => Some(Self::Overrides),
|
||||
"client_overrides" => Some(Self::ClientOverrides),
|
||||
"curseforge" => Some(Self::CurseForge),
|
||||
"modrinth" => Some(Self::Modrinth),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 是不是"包内的覆盖目录"(相对于清单里的远端条目)。
|
||||
#[must_use]
|
||||
pub const fn is_override_dir(self) -> bool {
|
||||
matches!(self, Self::Overrides | Self::ClientOverrides)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条**待用户点头**的冲突(导入前那张清单里的一行)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportConflictView {
|
||||
/// 实例内相对路径。
|
||||
pub path: String,
|
||||
/// 来源(包内覆盖目录 / 清单远端条目)。
|
||||
pub source: ConflictSourceView,
|
||||
/// 成因。
|
||||
pub reason: ConflictReasonView,
|
||||
/// 包内那条是不是禁用态(`.disabled`)。
|
||||
pub disabled: bool,
|
||||
/// 包内声明的 SHA-1。
|
||||
pub pack_sha1: String,
|
||||
/// 包内声明的字节数。
|
||||
pub pack_size: u64,
|
||||
/// 目标现有文件的 SHA-1。
|
||||
pub disk_sha1: String,
|
||||
/// 目标现有文件的字节数(覆盖会丢掉的量)。
|
||||
pub disk_size: u64,
|
||||
/// 上次导入写下的 SHA-1(没有基线时 `None`)。
|
||||
pub base_sha1: Option<String>,
|
||||
}
|
||||
|
||||
/// 用户的冲突决策(三条入口共用一个类型)。
|
||||
///
|
||||
/// 三条入口与 core 的 `ConflictDecisions`(`default` + `entries`)是同一件事的两种说法:
|
||||
///
|
||||
/// | 变体 | core 形状 |
|
||||
/// |---|---|
|
||||
/// | [`ConflictDecisionsView::KeepAll`] | `default = KeepUser`, 空表 |
|
||||
/// | [`ConflictDecisionsView::OverwriteAll`] | `default = Overwrite`, 空表 |
|
||||
/// | `PerItem { default, items }` | `default` + 逐项表 |
|
||||
///
|
||||
/// `PerItem` 带一个 `default` 是为了让"全部覆盖之后我又单独放回两条"这种真实操作可表达:
|
||||
/// 若只有"逐项覆盖 + 缺省保留",用户就得把剩下的几百条全点一遍。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConflictDecisionsView {
|
||||
/// **全部保留**(缺省):用户已有文件一个都不动。
|
||||
KeepAll,
|
||||
/// **全部覆盖**:整合包说了算。
|
||||
OverwriteAll,
|
||||
/// **逐项**:点过的路径按 `items`,其余按 `default`。
|
||||
PerItem {
|
||||
/// 没点过的路径按它处置。
|
||||
default: ConflictChoiceView,
|
||||
/// 点过的路径。
|
||||
items: Vec<ConflictDecisionView>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for ConflictDecisionsView {
|
||||
/// **缺省 = 全部保留**(绝不默认覆盖用户已有文件)。
|
||||
fn default() -> Self {
|
||||
Self::KeepAll
|
||||
}
|
||||
}
|
||||
|
||||
impl ConflictDecisionsView {
|
||||
/// 逐项构造(缺省档 + 点过的那些)。
|
||||
#[must_use]
|
||||
pub fn per_item(default: ConflictChoiceView, items: Vec<ConflictDecisionView>) -> Self {
|
||||
if items.is_empty() {
|
||||
return match default {
|
||||
ConflictChoiceView::KeepUser => Self::KeepAll,
|
||||
ConflictChoiceView::Overwrite => Self::OverwriteAll,
|
||||
};
|
||||
}
|
||||
Self::PerItem { default, items }
|
||||
}
|
||||
|
||||
/// 逐项表里的缺省档(`KeepAll` / `OverwriteAll` 也答得出)。
|
||||
#[must_use]
|
||||
pub const fn default_choice(&self) -> ConflictChoiceView {
|
||||
match self {
|
||||
Self::KeepAll => ConflictChoiceView::KeepUser,
|
||||
Self::OverwriteAll => ConflictChoiceView::Overwrite,
|
||||
Self::PerItem { default, .. } => *default,
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户显式要求"以包为准"吗(整批覆盖)。
|
||||
#[must_use]
|
||||
pub fn overwrites_everything(&self) -> bool {
|
||||
matches!(self, Self::OverwriteAll)
|
||||
}
|
||||
|
||||
/// 逐项表里"覆盖"的条数(不含缺省档)。
|
||||
#[must_use]
|
||||
pub fn overwrite_count(&self) -> usize {
|
||||
self.items()
|
||||
.iter()
|
||||
.filter(|item| item.choice == ConflictChoiceView::Overwrite)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 逐项表里"保留"的条数。
|
||||
#[must_use]
|
||||
pub fn keep_count(&self) -> usize {
|
||||
self.items()
|
||||
.iter()
|
||||
.filter(|item| item.choice == ConflictChoiceView::KeepUser)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 逐项表(`KeepAll` / `OverwriteAll` 是空表)。
|
||||
#[must_use]
|
||||
pub fn items(&self) -> &[ConflictDecisionView] {
|
||||
match self {
|
||||
Self::PerItem { items, .. } => items,
|
||||
Self::KeepAll | Self::OverwriteAll => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐项表的条数。
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.items().len()
|
||||
}
|
||||
|
||||
/// 有没有逐项决策。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐项决策的一条(路径 → 处置)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConflictDecisionView {
|
||||
/// 实例内相对路径。
|
||||
pub path: String,
|
||||
/// 处置。
|
||||
pub choice: ConflictChoiceView,
|
||||
}
|
||||
|
||||
/// 一次导入预检的结果(`PackEvent::Scan` 的载荷)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportScanView {
|
||||
/// 目标实例目录(人要看到"装到哪")。
|
||||
pub target: std::path::PathBuf,
|
||||
/// 命中的格式。
|
||||
pub format: Option<crate::dto::PackFormatView>,
|
||||
/// 包名。
|
||||
pub pack_name: Option<String>,
|
||||
/// 包版本。
|
||||
pub pack_version: Option<String>,
|
||||
/// 游戏版本。
|
||||
pub game_version: Option<String>,
|
||||
/// 待用户点头的冲突(按路径排序)。
|
||||
pub conflicts: Vec<ImportConflictView>,
|
||||
/// 补全前没有路径、因而判不了的远端条目数。
|
||||
pub unknown_remote: usize,
|
||||
/// 覆盖会丢掉的字节总量。
|
||||
pub lost_bytes: u64,
|
||||
/// 包内那些条目声明了多大。
|
||||
pub incoming_bytes: u64,
|
||||
/// 注记(与导入报告同源的人读串)。
|
||||
pub notes: Vec<String>,
|
||||
/// 预检本身失败(探测不过 / 开包失败)时的原因。
|
||||
pub error: Option<crate::dto::ImportErrorView>,
|
||||
}
|
||||
|
||||
impl ImportScanView {
|
||||
/// 需要用户点头吗(`false` = 可以直接导入,别打扰)。
|
||||
#[must_use]
|
||||
pub fn needs_decision(&self) -> bool {
|
||||
!self.conflicts.is_empty()
|
||||
}
|
||||
|
||||
/// 一条冲突都没有、预检也成功(可以直接导)。
|
||||
#[must_use]
|
||||
pub fn is_clean(&self) -> bool {
|
||||
self.error.is_none() && self.conflicts.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 冲突成因(core 的三条判据)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConflictReasonView {
|
||||
/// 基线里记过,磁盘内容与基线不同 ⇒ 用户改过。
|
||||
UserModified,
|
||||
/// 基线里记过,磁盘上却没了 ⇒ 用户删过。
|
||||
UserDeleted,
|
||||
/// 没有基线可判,磁盘上本来就有同名文件。
|
||||
PreExisting,
|
||||
}
|
||||
|
||||
impl ConflictReasonView {
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"user_modified" => Some(Self::UserModified),
|
||||
"user_deleted" => Some(Self::UserDeleted),
|
||||
"pre_existing" => Some(Self::PreExisting),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 冲突本次实际采用的处置。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConflictChoiceView {
|
||||
/// 保留用户侧(**默认**)。
|
||||
KeepUser,
|
||||
/// 用包内内容覆盖(用户显式要求「整合包说了算」)。
|
||||
Overwrite,
|
||||
}
|
||||
|
||||
impl ConflictChoiceView {
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"keep_user" => Some(Self::KeepUser),
|
||||
"overwrite" => Some(Self::Overwrite),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条冲突(**事后账**:本次实际怎么处置的)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConflictFileView {
|
||||
/// 实例内相对路径。
|
||||
pub path: String,
|
||||
/// 成因。
|
||||
pub reason: ConflictReasonView,
|
||||
/// 本次实际采用的处置。
|
||||
pub choice: ConflictChoiceView,
|
||||
/// 包内声明的 SHA-1。
|
||||
pub pack_sha1: String,
|
||||
/// 上次导入写下的 SHA-1(没有基线时 `None`)。
|
||||
pub base_sha1: Option<String>,
|
||||
/// 磁盘现状的 SHA-1(文件不存在时 `None`)。
|
||||
pub disk_sha1: Option<String>,
|
||||
/// 包内声明的字节数。
|
||||
pub pack_size: u64,
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! 导入/导出的**指令、事件与面板状态**(M113):`AppCommand::Pack` / `CoreEvent::Pack` 的载荷。
|
||||
//!
|
||||
//! ## 为什么整条流程只占一个枚举变体
|
||||
//!
|
||||
//! `AppCommand` / `CoreEvent` / `HostTask` 是这条装配链上最拥挤的三个枚举(M106/M107/M108/M109/M114
|
||||
//! 都在往里加变体)。所以导入导出**不各占一个变体**,而是各自一个 [`PackCommand`] / [`PackEvent`]
|
||||
//! 子枚举、外面只多一层包装:共享枚举里只多一行,rebase 时不用替别人定序。
|
||||
//!
|
||||
//! ## 取消是**界面级**的,不是管线级的(如实写)
|
||||
//!
|
||||
//! core 的 `import_pack` 没有取消令牌(`ImportRequest` 里没有 cancel 字段,`progress` 回调是
|
||||
//! 纯通知、拿不到控制流)。所以「取消导入」在本轮的含义是:
|
||||
//!
|
||||
//! 1. 立刻把面板收回([`PackEvent::Progress`] 传 `None`);
|
||||
//! 2. **丢弃**这一次运行之后回来的事件(代次比较,与 M105 的安装取消同一套做法);
|
||||
//! 3. 后台那次导入会继续跑到结束——**它不会因为点了取消而停手**。
|
||||
//!
|
||||
//! 这条必须在报告里如实登记:把它写成「真的停下来了」就是撒谎。
|
||||
//!
|
||||
//! ## 事件 → 状态只有一条路
|
||||
//!
|
||||
//! [`PackState::apply`] 是**唯一**的写入点(与 `AppState::apply` 同一条纪律):页面读到的进度、
|
||||
//! 报告、预检都只能从事件来,页面自己不做乐观回显。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::pack::{
|
||||
ExportDoneView, ExportPreviewView, ExportRequestView, ExportState, ImportProgressView,
|
||||
ImportReportView, ImportRequestView, ImportScanView, ImportState, ProbeView,
|
||||
};
|
||||
|
||||
/// app → host 的导入/导出意图。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PackCommand {
|
||||
/// **预检**导入(只读:算出冲突清单,**不写一个字节**)
|
||||
///
|
||||
/// 页面先发它,拿到 [`PackEvent::Scan`] 之后:没有冲突就直接发 [`PackCommand::Import`]
|
||||
/// (不打扰用户);有冲突就把清单画出来,用户点完再带着决策发 `Import`。
|
||||
Scan(Box<ImportRequestView>),
|
||||
/// 开始导入(目标实例已选好;`instance` 为 `None` = 按 `instance_name` 新建目录)
|
||||
Import(Box<ImportRequestView>),
|
||||
/// 跑**预检**(只扫描分类,**不写盘**)
|
||||
Export(Box<ExportRequestView>),
|
||||
/// 确认写盘。载荷是**预检时那一份请求**,不是页面当前字段——否则「预检 A、写 B」。
|
||||
Confirm(Box<ExportRequestView>),
|
||||
/// 关掉面板(导入结果 / 探测诊断 / 导出预检都收起来,回到入口态)
|
||||
Dismiss,
|
||||
}
|
||||
|
||||
/// host → app 的导入/导出事件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PackEvent {
|
||||
/// 导入进度(`None` = 收回面板:取消,或流程已经给出结果)
|
||||
Progress(Option<Box<ImportProgressView>>),
|
||||
/// 导入**预检**结果:冲突清单(M160)。空清单 = 没有文件需要用户点头。
|
||||
Scan(Box<ImportScanView>),
|
||||
/// 导入结果(三态 + 逐项原因;致命失败也走它,`report.error` 里有原因)
|
||||
Report(Box<ImportReportView>),
|
||||
/// 探测失败:这个包看起来像什么、缺哪个标记文件
|
||||
Probe(Box<ProbeView>),
|
||||
/// 导出预检结果
|
||||
Preview(Box<ExportPreviewView>),
|
||||
/// 导出写盘完成
|
||||
Exported(Box<ExportDoneView>),
|
||||
/// 导出/预检在 host 侧失败(实例找不到、目录派生失败……一句人读原因)
|
||||
ExportFailed(String),
|
||||
/// 用户取消了导入(面板立刻收回)。
|
||||
///
|
||||
/// 它只表示"取消后还没有结果":M195 起 core 有取消令牌,管线会在检查点停手并回滚,**真实
|
||||
/// 结局随后的 [`PackEvent::Report`] 才是准话**(`ImportReportView::cancel` 三档:已回滚 /
|
||||
/// 回滚没做完 / 取消太晚),所以 `Report` 一到就清掉这一位。
|
||||
Cancelled,
|
||||
/// 关掉所有面板
|
||||
Cleared,
|
||||
}
|
||||
|
||||
/// 导入/导出两个面板的状态(`AppState::pack` 的全部内容)。
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct PackState {
|
||||
/// 导入侧(进度 / 结果 / 探测诊断)
|
||||
pub import: ImportState,
|
||||
/// 导出侧(预检 / 完成 / 失败)
|
||||
pub export: ExportState,
|
||||
}
|
||||
|
||||
impl PackState {
|
||||
/// 事件 → 状态(**唯一**写入点)。
|
||||
pub fn apply(&mut self, event: PackEvent) {
|
||||
match event {
|
||||
PackEvent::Progress(progress) => {
|
||||
self.import.progress = progress.map(|progress| *progress);
|
||||
}
|
||||
PackEvent::Scan(scan) => {
|
||||
// 预检到了:进度让位,上一轮的结果/诊断作废(这一轮还没开始导)。
|
||||
self.import.progress = None;
|
||||
self.import.probe = None;
|
||||
self.import.report = None;
|
||||
self.import.scan = Some(*scan);
|
||||
}
|
||||
PackEvent::Report(report) => {
|
||||
// 结果到了:进度面板让位,探测诊断与预检清单都作废(这一轮已经有结论了)
|
||||
self.import.progress = None;
|
||||
self.import.probe = None;
|
||||
self.import.scan = None;
|
||||
// M195 r3:取消的**真实结局**在 `report.cancel` 里,`cancelled` 只作"取消后
|
||||
// 还没有结果"时的占位;结果一到就清掉,免得它跨到下一次导入上撒谎。
|
||||
self.import.cancelled = false;
|
||||
self.import.report = Some(*report);
|
||||
}
|
||||
PackEvent::Probe(probe) => {
|
||||
self.import.progress = None;
|
||||
self.import.report = None;
|
||||
self.import.scan = None;
|
||||
self.import.probe = Some(*probe);
|
||||
}
|
||||
PackEvent::Preview(preview) => {
|
||||
self.export.pending = false;
|
||||
self.export.failed = None;
|
||||
self.export.done = None;
|
||||
self.export.preview = Some(*preview);
|
||||
}
|
||||
PackEvent::Exported(done) => {
|
||||
self.export.pending = false;
|
||||
self.export.failed = None;
|
||||
// 写完之后预检就过期了:留着会让用户以为「还能再点一次确认」
|
||||
self.export.preview = None;
|
||||
self.export.done = Some(*done);
|
||||
}
|
||||
PackEvent::ExportFailed(reason) => {
|
||||
self.export.pending = false;
|
||||
self.export.failed = Some(reason);
|
||||
}
|
||||
PackEvent::Cancelled => {
|
||||
self.import.progress = None;
|
||||
self.import.cancelled = true;
|
||||
}
|
||||
PackEvent::Cleared => {
|
||||
self.import = ImportState::default();
|
||||
self.export = ExportState::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 记下「这次预检是对着哪份请求做的」(确认写盘时原样发回)。
|
||||
pub fn set_export_request(&mut self, request: ExportRequestView) {
|
||||
self.export.request = Some(request);
|
||||
self.export.pending = true;
|
||||
self.export.failed = None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! 导出侧 DTO:三分类 + 逐条原因 + **预检**(不写盘)。
|
||||
//!
|
||||
//! `ExportReasonView::class()` 是「原因 → 归类」的**唯一**映射处(与 core 同一条判据);
|
||||
//! 预检([`ExportPreviewView`])与写盘结果用同一套计数方法,于是「将内联 N / 无法导出 M」
|
||||
//! 与最终报告的 N/M 同源。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::InstanceId;
|
||||
|
||||
use super::format::PackFormatView;
|
||||
|
||||
/// 导出时一条文件的归类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ExportClassView {
|
||||
/// 写进清单 `files[]`(靠远端引用代替本地字节)。
|
||||
Remote,
|
||||
/// 写进 `overrides/`(原字节照搬)。
|
||||
Inline,
|
||||
/// 没进包(读不了 / 符号链接 / 路径不安全)。
|
||||
Skipped,
|
||||
}
|
||||
|
||||
impl ExportClassView {
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"remote" => Some(Self::Remote),
|
||||
"inline" => Some(Self::Inline),
|
||||
"skipped" => Some(Self::Skipped),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条文件为什么归到那个类里(与 core 的 `FileReason` 一一对应)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ExportReasonView {
|
||||
/// 远程引用:Modrinth SHA-1 命中。
|
||||
ModrinthSha1,
|
||||
/// 远程引用:CurseForge 指纹命中。
|
||||
CurseForgeFingerprint,
|
||||
/// 内联:两个目录都不认识它。
|
||||
NoRemoteMatch,
|
||||
/// 内联:只有 CF 认识,目标格式用不上。
|
||||
CurseOnlyMatch,
|
||||
/// 内联:只有 Modrinth 认识,CF 清单用不上。
|
||||
ModrinthOnlyMatch,
|
||||
/// 内联:命中缺 `sha512`。
|
||||
MissingSha512,
|
||||
/// 内联:命中缺 `projectID`/`fileID`。
|
||||
MissingCurseIds,
|
||||
/// 内联:禁用文件,目标格式表达不了禁用位。
|
||||
DisabledNotExpressible,
|
||||
/// 跳过:符号链接。
|
||||
SymbolicLink,
|
||||
/// 跳过:路径过不了包内规整。
|
||||
UnsafePath,
|
||||
/// 跳过:读盘或算摘要失败。
|
||||
Unreadable,
|
||||
}
|
||||
|
||||
impl ExportReasonView {
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"modrinth_sha1" => Some(Self::ModrinthSha1),
|
||||
"curseforge_fingerprint" => Some(Self::CurseForgeFingerprint),
|
||||
"no_remote_match" => Some(Self::NoRemoteMatch),
|
||||
"curse_only_match" => Some(Self::CurseOnlyMatch),
|
||||
"modrinth_only_match" => Some(Self::ModrinthOnlyMatch),
|
||||
"missing_sha512" => Some(Self::MissingSha512),
|
||||
"missing_curse_ids" => Some(Self::MissingCurseIds),
|
||||
"disabled_not_expressible" => Some(Self::DisabledNotExpressible),
|
||||
"symbolic_link" => Some(Self::SymbolicLink),
|
||||
"unsafe_path" => Some(Self::UnsafePath),
|
||||
"unreadable" => Some(Self::Unreadable),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 这个原因属于哪一类(**唯一映射处**,与 core 的 `FileReason::class` 同一条判据)。
|
||||
#[must_use]
|
||||
pub const fn class(self) -> ExportClassView {
|
||||
match self {
|
||||
Self::ModrinthSha1 | Self::CurseForgeFingerprint => ExportClassView::Remote,
|
||||
Self::NoRemoteMatch
|
||||
| Self::CurseOnlyMatch
|
||||
| Self::ModrinthOnlyMatch
|
||||
| Self::MissingSha512
|
||||
| Self::MissingCurseIds
|
||||
| Self::DisabledNotExpressible => ExportClassView::Inline,
|
||||
Self::SymbolicLink | Self::UnsafePath | Self::Unreadable => ExportClassView::Skipped,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条文件的导出预检结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExportFileView {
|
||||
/// 实例内相对路径(含 `.disabled` 后缀)。
|
||||
pub path: String,
|
||||
/// 归类。
|
||||
pub class: ExportClassView,
|
||||
/// 原因。
|
||||
pub reason: ExportReasonView,
|
||||
/// 字节数。
|
||||
pub bytes: u64,
|
||||
/// 是否禁用态(`.disabled`)。
|
||||
pub disabled: bool,
|
||||
}
|
||||
|
||||
/// **预检**:还没有写盘,先把「会怎样」摊开给用户看。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExportPreviewView {
|
||||
/// 目标格式。
|
||||
pub format: PackFormatView,
|
||||
/// 源实例。
|
||||
pub instance: InstanceId,
|
||||
/// 包名。
|
||||
pub name: String,
|
||||
/// 包版本。
|
||||
pub version: String,
|
||||
/// 预定的落盘路径(确认之后写这里)。
|
||||
pub target: PathBuf,
|
||||
/// 逐文件结果(按路径排序)。
|
||||
pub files: Vec<ExportFileView>,
|
||||
/// 已知的表达不了的差异 / 取舍(core 的 `notes`)。
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl ExportPreviewView {
|
||||
/// 某一类的条数。
|
||||
#[must_use]
|
||||
pub fn count(&self, class: ExportClassView) -> usize {
|
||||
self.files.iter().filter(|file| file.class == class).count()
|
||||
}
|
||||
|
||||
/// 远程引用条数。
|
||||
#[must_use]
|
||||
pub fn remote_count(&self) -> usize {
|
||||
self.count(ExportClassView::Remote)
|
||||
}
|
||||
|
||||
/// 内联条数(「将内联 N 个文件」的那个 N)。
|
||||
#[must_use]
|
||||
pub fn inline_count(&self) -> usize {
|
||||
self.count(ExportClassView::Inline)
|
||||
}
|
||||
|
||||
/// 跳过条数(「无法导出 M 项」的那个 M)。
|
||||
#[must_use]
|
||||
pub fn skipped_count(&self) -> usize {
|
||||
self.count(ExportClassView::Skipped)
|
||||
}
|
||||
|
||||
/// 内联文件总字节数。
|
||||
#[must_use]
|
||||
pub fn inline_bytes(&self) -> u64 {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|file| file.class == ExportClassView::Inline)
|
||||
.map(|file| file.bytes)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// 包会完整吗(`false` = 有文件压根没进包,必须单独列给用户)。
|
||||
#[must_use]
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.skipped_count() == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出格式选择 + 一次导出请求。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExportRequestView {
|
||||
/// 源实例。
|
||||
pub instance: InstanceId,
|
||||
/// 目标格式(只能是 [`PackFormatView::EXPORTABLE`] 里的两个)。
|
||||
pub format: PackFormatView,
|
||||
/// 包名(写进清单 `name`)。
|
||||
pub name: String,
|
||||
/// 包版本(写进清单 `version` / `versionId`)。
|
||||
pub version: String,
|
||||
/// 落盘路径(`None` = host 按「下载目录 + 包名 + 扩展名」给一个缺省)。
|
||||
pub target: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// 导出完成(写盘之后的账)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExportDoneView {
|
||||
/// 产出的包路径。
|
||||
pub path: PathBuf,
|
||||
/// 包的字节数(写完量的,不是估算)。
|
||||
pub bytes: u64,
|
||||
/// 远程引用条数。
|
||||
pub remote: u32,
|
||||
/// 内联条数。
|
||||
pub inline: u32,
|
||||
/// 跳过条数。
|
||||
pub skipped: u32,
|
||||
}
|
||||
|
||||
/// 导出侧的宿主状态(`AppState::export` 的全部内容)。
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct ExportState {
|
||||
/// 预检结果(`None` = 还没预检)。
|
||||
pub preview: Option<ExportPreviewView>,
|
||||
/// **这次预检是对着哪份请求做的**(确认写盘时原样发回,防「预检 A、写 B」)。
|
||||
pub request: Option<ExportRequestView>,
|
||||
/// 预检在飞。
|
||||
pub pending: bool,
|
||||
/// 最近一次写盘的结果。
|
||||
pub done: Option<ExportDoneView>,
|
||||
/// 失败原因(预检或写盘)。
|
||||
pub failed: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! 整合包格式:探测结果与导出目标共用一个枚举。
|
||||
//!
|
||||
//! `slug()` 与 core 的格式 id **逐字一致**(`curseforge` / `modrinth` / `multimc`),
|
||||
//! `from_slug` 是反向唯一入口——翻译发生在 `host::addon`,页面侧只认这两个函数。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 整合包格式(探测结果与导出目标共用一个枚举)。
|
||||
///
|
||||
/// MultiMC / Prism 只**导入**不导出(core 的有意省略),所以导出侧两个入口是
|
||||
/// [`Self::Modrinth`] / [`Self::Curseforge`]。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum PackFormatView {
|
||||
/// CurseForge:`manifest.json` + `overrides/`。
|
||||
Curseforge,
|
||||
/// Modrinth:`.mrpack`(`modrinth.index.json` + `overrides/`)。
|
||||
Modrinth,
|
||||
/// MultiMC / Prism:`mmc-pack.json` + `.minecraft/`(**只读**)。
|
||||
Multimc,
|
||||
}
|
||||
|
||||
impl PackFormatView {
|
||||
/// 导出菜单里可选的两个(顺序 = 画出来的顺序)。
|
||||
pub const EXPORTABLE: [PackFormatView; 2] =
|
||||
[PackFormatView::Modrinth, PackFormatView::Curseforge];
|
||||
|
||||
/// 稳定 slug(与 core 的格式 id 逐字一致,**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Curseforge => "curseforge",
|
||||
Self::Modrinth => "modrinth",
|
||||
Self::Multimc => "multimc",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 core 的格式 id 反解(`None` = 不认识的 id,调用方按「未知」处理,别猜)。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"curseforge" => Some(Self::Curseforge),
|
||||
"modrinth" => Some(Self::Modrinth),
|
||||
"multimc" => Some(Self::Multimc),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
//! 导入侧 DTO:请求、进度、三态、逐项原因、探测诊断。
|
||||
//!
|
||||
//! 状态档(`Success` / `Partial` / `Failed` / `Cancelled`)与 core 一一对应,**`Partial` 不是
|
||||
//! `Success`**、**取消不是失败**;容忍域与致命域分属不同类型,UI 不可能混成一个列表。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::InstanceId;
|
||||
|
||||
use super::conflict::{ConflictDecisionsView, ConflictFileView, ImportScanView};
|
||||
use super::format::PackFormatView;
|
||||
|
||||
mod cancel;
|
||||
mod failure_stage;
|
||||
|
||||
pub use cancel::ImportCancelView;
|
||||
pub use failure_stage::ImportFailureStageView;
|
||||
|
||||
/// 导入阶段(与 core 的 `ImportStage` 一一对应)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum ImportStageView {
|
||||
/// 读取清单(开包 + 安全四检 + 探测)。
|
||||
Probe,
|
||||
/// 解压 / 三方合并覆盖内容。
|
||||
Extract,
|
||||
/// 补全并下载远端文件。
|
||||
Fetch,
|
||||
/// 落地。
|
||||
Commit,
|
||||
/// 登记实例。
|
||||
Register,
|
||||
}
|
||||
|
||||
impl ImportStageView {
|
||||
/// 五个阶段的声明顺序(= 进度条上的先后)。
|
||||
pub const ALL: [ImportStageView; 5] = [
|
||||
ImportStageView::Probe,
|
||||
ImportStageView::Extract,
|
||||
ImportStageView::Fetch,
|
||||
ImportStageView::Commit,
|
||||
ImportStageView::Register,
|
||||
];
|
||||
|
||||
/// 稳定 slug。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Probe => "probe",
|
||||
Self::Extract => "extract",
|
||||
Self::Fetch => "fetch",
|
||||
Self::Commit => "commit",
|
||||
Self::Register => "register",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|stage| stage.slug() == slug)
|
||||
}
|
||||
|
||||
/// 这一阶段有没有「逐文件」的进度(`Probe` 只看清单,没条目可数)。
|
||||
#[must_use]
|
||||
pub const fn counts_files(self) -> bool {
|
||||
!matches!(self, Self::Probe)
|
||||
}
|
||||
}
|
||||
|
||||
/// 导入三态(`Partial` **不是** `Success`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ImportStatusView {
|
||||
/// 全部落地、无冲突。
|
||||
Success,
|
||||
/// 有缺文件或有冲突(内容不完整,但实例可用)。
|
||||
Partial,
|
||||
/// 致命失败。
|
||||
///
|
||||
/// **「动没动过目标目录」不在这里**(M161):同一个 `Failed` 既可能是预检没过(一个字节都没写),
|
||||
/// 也可能是写完又回滚干净,还可能是回滚没做完——三种由
|
||||
/// [`ImportReportView::failure_stage`] 分开说,界面按它选标题与图标。
|
||||
Failed,
|
||||
/// **用户取消,管线停手并回滚**(M195 r3):不是失败,也不是「部分成功」。
|
||||
/// 「回滚干净 / 没做完」由 [`ImportReportView::cancel`] 再分;取消**太晚**(管线已跑完)
|
||||
/// 不落这一档——那时实例真装上了,状态是 `Success`/`Partial`,只在 `cancel` 记 `TooLate`。
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl ImportStatusView {
|
||||
/// 稳定 slug。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Success => "success",
|
||||
Self::Partial => "partial",
|
||||
Self::Failed => "failed",
|
||||
Self::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"success" => Some(Self::Success),
|
||||
"partial" => Some(Self::Partial),
|
||||
"failed" => Some(Self::Failed),
|
||||
"cancelled" => Some(Self::Cancelled),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 实例可用吗(`Success` / `Partial` 是「装上了」;取消与失败都没装上)。
|
||||
#[must_use]
|
||||
pub const fn installed(self) -> bool {
|
||||
matches!(self, Self::Success | Self::Partial)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条落地文件本次被怎么处理。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum FileActionView {
|
||||
/// 新写入。
|
||||
Installed,
|
||||
/// 覆盖写入(用户没改过)。
|
||||
Replaced,
|
||||
/// 与包内一致,没动。
|
||||
SkippedIdentical,
|
||||
/// 用户改过 / 删过,按策略保留了用户那一侧。
|
||||
KeptUser,
|
||||
}
|
||||
|
||||
impl FileActionView {
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"installed" => Some(Self::Installed),
|
||||
"replaced" => Some(Self::Replaced),
|
||||
"skipped_identical" => Some(Self::SkippedIdentical),
|
||||
"kept_user" => Some(Self::KeptUser),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否真的写了盘。
|
||||
#[must_use]
|
||||
pub const fn wrote(self) -> bool {
|
||||
matches!(self, Self::Installed | Self::Replaced)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次导入的请求。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportRequestView {
|
||||
/// 包路径:zip(`.zip` / `.mrpack`)或 MultiMC 实例目录。
|
||||
pub pack: PathBuf,
|
||||
/// 目标实例(`None` = 按包名新建一个)。
|
||||
pub instance: Option<InstanceId>,
|
||||
/// 新实例的展示名(`None` = 用包名)。
|
||||
pub instance_name: Option<String>,
|
||||
/// 冲突决策(**缺省 [`ConflictDecisionsView::KeepAll`]** = 用户已有文件一个都不动)。
|
||||
///
|
||||
/// 页面上:`KeepAll` 是缺省与「全部保留」,`OverwriteAll` 是「全部覆盖」,
|
||||
/// `PerItem` 是逐项勾选的结果。只有用户**显式点过**之后才会出现后两者。
|
||||
pub decisions: ConflictDecisionsView,
|
||||
}
|
||||
|
||||
/// 一条导入进度。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportProgressView {
|
||||
/// 阶段。
|
||||
pub stage: ImportStageView,
|
||||
/// 阶段内已完成条目数。
|
||||
pub done: u32,
|
||||
/// 阶段内总条目数(`0` = 未知)。
|
||||
pub total: u32,
|
||||
/// 当前条目的实例内路径(阶段刚开始时为 `None`)。
|
||||
pub current: Option<String>,
|
||||
/// 包名(面板标题那一行)。
|
||||
pub pack: String,
|
||||
}
|
||||
|
||||
impl ImportProgressView {
|
||||
/// 空进度(刚派发,后端还没回第一条)。
|
||||
#[must_use]
|
||||
pub fn new(pack: impl Into<String>) -> Self {
|
||||
Self {
|
||||
stage: ImportStageView::Probe,
|
||||
done: 0,
|
||||
total: 0,
|
||||
current: None,
|
||||
pack: pack.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 计数已知吗(`false` = 不画确定态进度条,避免「静止的假进度」)。
|
||||
#[must_use]
|
||||
pub const fn is_known(&self) -> bool {
|
||||
self.total > 0
|
||||
}
|
||||
|
||||
/// 阶段内完成比例 0.0~1.0(未知时 `0.0`)。
|
||||
#[must_use]
|
||||
pub fn ratio(&self) -> f32 {
|
||||
if self.total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
(self.done as f32 / self.total as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条落地文件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportedFileView {
|
||||
/// 实例内相对路径。
|
||||
pub path: String,
|
||||
/// 本次处置。
|
||||
pub action: FileActionView,
|
||||
/// 字节数。
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
/// 一个文件「搞不到」的成因(**容忍**域)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MissingReasonView {
|
||||
/// 调用方没给 fetcher(离线导入)。
|
||||
Offline,
|
||||
/// 条目补不出下载地址(CF 缺 API 客户端 / `downloads` 为空)。
|
||||
NoSource,
|
||||
/// 尝试过但失败(404 / 网络)。
|
||||
FetchFailed,
|
||||
}
|
||||
|
||||
impl MissingReasonView {
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"offline" => Some(Self::Offline),
|
||||
"no_source" => Some(Self::NoSource),
|
||||
"fetch_failed" => Some(Self::FetchFailed),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个「搞不到」的文件(容忍,但逐条列出)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MissingFileView {
|
||||
/// 实例内路径(CF 补全失败时没有)。
|
||||
pub path: Option<String>,
|
||||
/// 人读标识(CF 条目是 `projectID:fileID`,其余是路径)。
|
||||
pub display: String,
|
||||
/// 为什么搞不到。
|
||||
pub reason: MissingReasonView,
|
||||
/// 细节(错误原文)。
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 一次致命失败(是不是「已整包回滚」看 [`ImportReportView::failure_stage`])。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportErrorView {
|
||||
/// 稳定码(`pack_traversal` / `import_integrity` …,进日志与 `--json`)。
|
||||
pub code: String,
|
||||
/// 人读大类名。
|
||||
pub label: String,
|
||||
/// 具体是什么。
|
||||
pub message: String,
|
||||
/// 补救建议(core 有就给)。
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
/// 一次导入的完整结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportReportView {
|
||||
/// 三态。
|
||||
pub status: ImportStatusView,
|
||||
/// 命中的格式(`None` = 探测都没过)。
|
||||
pub format: Option<PackFormatView>,
|
||||
/// 包名。
|
||||
pub pack_name: Option<String>,
|
||||
/// 包版本。
|
||||
pub pack_version: Option<String>,
|
||||
/// 游戏版本。
|
||||
pub game_version: Option<String>,
|
||||
/// 加载器(人读串,如 `fabric-loader 0.15.11`)。
|
||||
pub loader: Option<String>,
|
||||
/// 实例绑定的版本 id。
|
||||
pub version_id: Option<String>,
|
||||
/// 目标实例目录。
|
||||
pub target: PathBuf,
|
||||
/// 落地的文件。
|
||||
pub files: Vec<ImportedFileView>,
|
||||
/// 冲突清单。
|
||||
pub conflicts: Vec<ConflictFileView>,
|
||||
/// 缺文件清单(容忍)。
|
||||
pub missing: Vec<MissingFileView>,
|
||||
/// MultiMC 不支持的 component(逐项)。
|
||||
pub rejected: Vec<String>,
|
||||
/// 注记。
|
||||
pub warnings: Vec<String>,
|
||||
/// 致命错误(`status == Failed` 时必有)。
|
||||
pub error: Option<ImportErrorView>,
|
||||
/// 登记的实例名。
|
||||
pub registered: Option<String>,
|
||||
/// 本次写入的字节数。
|
||||
pub bytes_written: u64,
|
||||
/// 回滚是否完整(`false` = 目标目录可能与导入前不一致,逐条在 `warnings` 里)。
|
||||
///
|
||||
/// **注意**:`true` 有两种来源——「回滚完整完成」与「从未写过目标目录」(core 的
|
||||
/// `abandoned`)。要区分它们看 [`ImportReportView::failure_stage`];界面上的措辞
|
||||
/// **必须**按那个字段选(M161)。
|
||||
pub rolled_back: bool,
|
||||
/// 失败发生在哪一步(`None` = 这次不是致命失败)。
|
||||
pub failure_stage: Option<ImportFailureStageView>,
|
||||
/// 这次运行与「取消」的关系(M195 r3:取消的**真实结局**,界面按它选文案)。
|
||||
///
|
||||
/// 只靠 `status` 表达不了三档:已回滚 / 回滚没做完都在 [`ImportStatusView::Cancelled`] 里,
|
||||
/// 而「取消太晚、已经跑完」的状态是 `Success`/`Partial`——三档见 [`ImportCancelView`]。
|
||||
pub cancel: ImportCancelView,
|
||||
}
|
||||
|
||||
impl ImportReportView {
|
||||
/// 真正写盘的文件条数。
|
||||
#[must_use]
|
||||
pub fn written(&self) -> usize {
|
||||
self.files.iter().filter(|file| file.action.wrote()).count()
|
||||
}
|
||||
|
||||
/// 要用户看一眼的东西条数(冲突 + 缺文件 + 被拒 component)。
|
||||
#[must_use]
|
||||
pub fn issue_count(&self) -> usize {
|
||||
self.conflicts.len() + self.missing.len() + self.rejected.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测失败的**具名**诊断:「看起来像 X,但缺 Y」。
|
||||
///
|
||||
/// 为什么不由 core 给:core 的 `detect` 只回答「谁认领」,一个都不认领时只回一句
|
||||
/// `pack_unsupported` + 试过哪些格式。而用户手里那个包往往**差一点点**就是某个已知格式
|
||||
/// (CF 导出漏了 `manifest.json`、mrpack 被浏览器改名成 `.zip` 后解压丢了 index)。
|
||||
/// 判断「像哪个」要看包内到底有哪些标记文件,这是宿主层拿着 `PackArchive` 才看得见的事。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProbeView {
|
||||
/// 最像哪个格式(`None` = 三个都不像)。
|
||||
pub looks_like: Option<PackFormatView>,
|
||||
/// 该格式**缺**的标记文件(逐条,按判据顺序)。
|
||||
pub missing: Vec<String>,
|
||||
/// 包里实际看到的标记(逐条)。
|
||||
pub saw: Vec<String>,
|
||||
/// 一句兜底说明(也是 core 的原文:试过哪些格式)。
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 导入侧的宿主状态(`AppState::import` 的全部内容)。
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct ImportState {
|
||||
/// 正在跑的进度(`None` = 没在跑)。
|
||||
pub progress: Option<ImportProgressView>,
|
||||
/// 用户点了取消、**结果还没回来**(M195 r3 收窄了它的含义)。
|
||||
///
|
||||
/// 它只作"取消后还没有结果"的占位:真实结局随后的 `report.cancel` 才是准话(已回滚 /
|
||||
/// 回滚没做完 / 取消太晚)。所以 `PackEvent::Report` 一到就清掉这一位,免得它跨到下一次
|
||||
/// 导入上替结局下结论。
|
||||
pub cancelled: bool,
|
||||
/// 最近一次的预检(M160:冲突清单;`None` = 还没预检过或已被新结果顶掉)。
|
||||
pub scan: Option<ImportScanView>,
|
||||
/// 最近一次的结果(`None` = 还没导过)。
|
||||
pub report: Option<ImportReportView>,
|
||||
/// 探测失败的具名诊断(有它就让用户看清「像 X 缺 Y」,而不是一句「无法识别」)。
|
||||
pub probe: Option<ProbeView>,
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! 一次导入与「取消」的关系(**真实结局**,不是"用户点没点过取消")。
|
||||
//!
|
||||
//! 从父模块拆出来只为行数(父文件已到 400 硬限附近),判据与文案口径原样。
|
||||
//!
|
||||
//! 三档各有各的话要说,混成一句就是撒谎(M195 r3):
|
||||
//! - [`ImportCancelView::RolledBack`]:停手了,目标目录与导入前一致;
|
||||
//! - [`ImportCancelView::RollbackIncomplete`]:停手了,但回滚没做完,目标目录可能与导入前不同;
|
||||
//! - [`ImportCancelView::TooLate`]:**没来得及停**,管线已经跑完——实例会保留,同名文件可能已被覆盖。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 一次导入与「取消」的关系(**真实结局**)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum ImportCancelView {
|
||||
/// 这次运行没被取消过。
|
||||
#[default]
|
||||
NotRequested,
|
||||
/// 取消生效,管线停手并**完整**回滚(目标目录与导入前一致,实例没登记)。
|
||||
RolledBack,
|
||||
/// 取消生效,但回滚没做完(目标目录可能与导入前不同,逐条在 `warnings` 里)。
|
||||
RollbackIncomplete,
|
||||
/// 取消到得太晚:管线已经跑完,实例保留,同名文件可能已被覆盖。
|
||||
TooLate,
|
||||
}
|
||||
|
||||
impl ImportCancelView {
|
||||
/// 稳定 slug。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::NotRequested => "not_requested",
|
||||
Self::RolledBack => "rolled_back",
|
||||
Self::RollbackIncomplete => "rollback_incomplete",
|
||||
Self::TooLate => "too_late",
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户这次取消有没有**真的生效**(前两档是真停手了)。
|
||||
#[must_use]
|
||||
pub const fn stopped(self) -> bool {
|
||||
matches!(self, Self::RolledBack | Self::RollbackIncomplete)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! 致命失败发生在哪一步(M161:`rolled_back` 一个人说不了两件事)。
|
||||
//!
|
||||
//! 从父模块拆出来只为行数;判据与文案口径原样。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 致命失败的**阶段**(M161:`rolled_back` 一个人说不了两件事)。
|
||||
///
|
||||
/// core 的 `ImportReport::rolled_back` 语义是「目标目录已确认与导入前一致」——它同时覆盖
|
||||
/// 「回滚完整完成」和「**从未写过**目标目录」两种情形(见 `core::addon::import::pipeline` 的
|
||||
/// `record_rollback` / `abandoned`)。UI 拿一个 bool 判不出该说哪一句,于是预检失败也会被
|
||||
/// 说成「已回滚」,而实际上一个字节都没写。这个枚举把两种情形重新分开,并补上第三种
|
||||
/// (回滚有失败项,目标目录可能与导入前不同)。
|
||||
///
|
||||
/// 只在 `status == Failed` 时有值:成功/部分成功的报告没有「失败阶段」可言。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ImportFailureStageView {
|
||||
/// 预检/装配阶段就失败了:**目标目录一个字节都没写**(用户该去修包/修配置再试)。
|
||||
NotStarted,
|
||||
/// 已经写过目标目录,回滚**完整**完成(零残留)。
|
||||
RolledBack,
|
||||
/// 写过了,但回滚有失败项:目标目录可能与导入前不同(`warnings` 里逐条列了)。
|
||||
RollbackIncomplete,
|
||||
}
|
||||
|
||||
impl ImportFailureStageView {
|
||||
/// 稳定 slug(日志 / 测试用;**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::NotStarted => "not_started",
|
||||
Self::RolledBack => "rolled_back",
|
||||
Self::RollbackIncomplete => "rollback_incomplete",
|
||||
}
|
||||
}
|
||||
|
||||
/// 本次失败到底动没动目标目录。
|
||||
#[must_use]
|
||||
pub const fn touched_target(self) -> bool {
|
||||
!matches!(self, Self::NotStarted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
//! 实例**存档 / 截图的快照**与写操作的 IPC 面(M235 · STOREMVP,M247 · SAVESOPS 加写路径)。
|
||||
//!
|
||||
//! # 三层里的第二层
|
||||
//!
|
||||
//! 这一版是「只读列表 + 打开文件夹」**加上**三个写操作:`core::game::saves`(读盘/写盘,唯一的
|
||||
//! 真相)→ 本模块(IPC 形状)→ `crates/app-iced/src/pages/instances/saves.rs`(画)。
|
||||
//!
|
||||
//! | 方向 | 类型 |
|
||||
//! |---|---|
|
||||
//! | app → host | [`SavesCommand`](列目录 / 打开文件夹 / 重命名 / 删除 / 备份) |
|
||||
//! | host → app | [`SavesListView`](整份快照 + 上一次写操作的[回执](SavesOped))或 [`SavesFailureView`] |
|
||||
//!
|
||||
//! # 写操作为什么走同一条回程
|
||||
//!
|
||||
//! 三个写操作的结果都是**一份新快照**(`Ok`)+ 一个[回执](SavesOped):写完之后磁盘变了,
|
||||
//! 界面必须跟着变;而回执是「这次动的是哪一条、动成了什么」。失败则走同一个事件的 `Err` 一支,
|
||||
//! 带上一个**具名类别**(重命名撞名 / 删除越界 / 备份超限……),见 [`SavesFailureKind`]。
|
||||
//! 两条都不静默:成功有回执、失败有类别 + OS 原文。
|
||||
//!
|
||||
//! # 为什么另开一族,不并进 `ModListView`
|
||||
//!
|
||||
//! 两者形状相近(都是"列一个实例的某个目录"),但语义不同:mod 有启停(看游戏在不在跑),
|
||||
//! 存档这边有三个写操作(重命名 / 删除 / 备份)。合并会让两边的 `pending` 字段互相变成
|
||||
//! 永远为假的分支(M224 拒绝给内容页加第五个 `ContentKind` 是同一条理由:别让穷尽 match 为
|
||||
//! 不相干的字段服务)。
|
||||
//!
|
||||
//! # 文案纪律(M161)
|
||||
//!
|
||||
//! 本模块**不带人读散文**:失败给的是结构([`SavesFailureKind`] + 底层 `detail` 串)、
|
||||
//! 回执给的是事实(谁改成什么、备份落到哪个路径、几个字节、跳过几个链接),
|
||||
//! 时间戳给的是**毫秒数**(UTC 毫秒,UI 自己格式化)。文案一律由 app 层按 i18n key 现求。
|
||||
//!
|
||||
//! # 时间戳为什么是 `Option<u64>` 的毫秒
|
||||
//!
|
||||
//! 取不到(早于 1970 / 文件系统不给)就是 `None`——**不拿 0 或当前时间填**:那会让"时间未知"
|
||||
//! 看起来像"1970 年"或"刚刚"。格式化交给 UI(`dto::logs` 的时戳同理)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::InstanceId;
|
||||
|
||||
mod preview;
|
||||
|
||||
pub use preview::{ScreenshotPreview, ScreenshotThumbFailure, ScreenshotThumbKind, ThumbnailView};
|
||||
|
||||
/// app → host 的存档指令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavesCommand {
|
||||
/// 列某个实例的 `saves/` 与 `screenshots/`(进详情页时发、点「重试」时再发)。
|
||||
List(InstanceId),
|
||||
/// 用系统文件管理器打开存档目录。
|
||||
///
|
||||
/// 打开**哪个**目录由 host 决定(`saves/` → `screenshots/` → 运行目录,依次退化),
|
||||
/// 因为那是"磁盘上到底有什么"的事实,不该让 UI 猜。
|
||||
OpenFolder {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
},
|
||||
/// 重命名一个世界(M247):`saves/<dir>` → `saves/<to>`。
|
||||
///
|
||||
/// 名字规则、撞名、边界校验全在 core(host 只转发):UI 传坏了也不会落到 `saves/` 之外。
|
||||
RenameWorld {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// 现在的磁盘目录名
|
||||
dir: String,
|
||||
/// 新名字(单段;不许带 `/` `\` `:` 与 `..`)
|
||||
to: String,
|
||||
},
|
||||
/// 删除一个世界(M247):递归删 `saves/<dir>`。
|
||||
///
|
||||
/// **UI 必须先二次确认**:这是本页唯一不可逆的动作。host 侧再走一遍边界校验
|
||||
/// (越界 / 符号链接一律拒),不靠确认框把调用方「教好」。
|
||||
DeleteWorld {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// 磁盘目录名
|
||||
dir: String,
|
||||
},
|
||||
/// 备份一个世界(M247):把一个世界导出成 zip。
|
||||
///
|
||||
/// `dest` 是**父目录**(不是文件):`None` = 启动器自己的 `<data>/exports/saves/`。
|
||||
/// 文件名由 core 按「世界名 + UTC 时戳」生成,撞名顺延,**绝不覆盖**上一份。
|
||||
BackupWorld {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// 磁盘目录名
|
||||
dir: String,
|
||||
/// 导出到哪个父目录(`None` = 启动器自己的 exports)
|
||||
dest: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 一次写操作成功后的事实(**不含人读文案**:文案由 app 按 i18n key 现求)。
|
||||
///
|
||||
/// 为什么成功也要有回执:重命名 / 删除的结果在列表上自己会说话(行改名、行消失),
|
||||
/// 而**备份成功没有话可说**——zip 落到了文件系统某处,用户必须知道完整路径 + 字节数,
|
||||
/// 否则「点了备份然后什么也没发生」就是一次静默失败。
|
||||
///
|
||||
/// 回执挂在快照上([`SavesListView::last_op`]):下一次普通 `List` 会让它消失,
|
||||
/// 所以它表达的是「最近这一次操作」,不是一段历史。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavesOped {
|
||||
/// 改名成功。
|
||||
Renamed {
|
||||
/// 原磁盘名
|
||||
from: String,
|
||||
/// 新磁盘名
|
||||
to: String,
|
||||
},
|
||||
/// 删除成功。
|
||||
Deleted {
|
||||
/// 被删掉的磁盘名
|
||||
dir: String,
|
||||
},
|
||||
/// 备份成功。
|
||||
BackedUp {
|
||||
/// 被备份的世界(磁盘名)
|
||||
dir: String,
|
||||
/// 落点文件名
|
||||
file: String,
|
||||
/// 落点完整路径
|
||||
path: String,
|
||||
/// zip 的字节数
|
||||
bytes: u64,
|
||||
/// 被跳过的符号链接数(不跟随;0 = 一个都没跳)
|
||||
skipped: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// 一个世界目录。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorldView {
|
||||
/// 磁盘上的目录名(这一版它就是展示名:`LevelName` 在 `level.dat` 里,本版不解析 NBT)。
|
||||
pub dir: String,
|
||||
/// 有没有 `icon.png`(只判存在,不解码)。
|
||||
///
|
||||
/// M251 之后**世界图标仍未解**:缩略图墙([`ScreenshotPreview`])只覆盖 `screenshots/`,
|
||||
/// 这是 D54 的范围。这行字眼原来写的是「缩略图墙不在 U3 范围内」,那句话已失真。
|
||||
pub icon: bool,
|
||||
/// 目录 mtime(UTC 毫秒;`None` = 取不到)。
|
||||
pub modified_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// 一张截图(元数据 + **预览**)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ScreenshotView {
|
||||
/// 磁盘文件名(含扩展名)。
|
||||
pub file: String,
|
||||
/// 字节数(UI 自己格式化,不在这里拼 `1.2 MiB`)。
|
||||
pub bytes: u64,
|
||||
/// 文件 mtime(UTC 毫秒;`None` = 取不到)。
|
||||
pub modified_ms: Option<u64>,
|
||||
/// 预览(缩略图像素 / 没生成 / 具名失败原因),见 [`ScreenshotPreview`]。
|
||||
///
|
||||
/// 像素**不落盘**(D54 第 2 条):解码在 host 的后台线程里做完,随这份快照一次性交给
|
||||
/// 壳层。`(file, modified_ms, bytes)` 同时也是 host 侧缓存键的三段——三者任一变了就是
|
||||
/// 另一张图,必须重解。
|
||||
pub preview: ScreenshotPreview,
|
||||
}
|
||||
|
||||
/// 被跳过的条目来自哪个目录。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavesArea {
|
||||
/// `<run_dir>/saves/`
|
||||
Saves,
|
||||
/// `<run_dir>/screenshots/`
|
||||
Screenshots,
|
||||
}
|
||||
|
||||
/// 没收进列表的原因(与 `core::game::saves::SkipKind` 一一对应)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavesSkipKind {
|
||||
/// 符号链接(不跟随)。
|
||||
SymbolicLink,
|
||||
/// `saves/` 下的非目录。
|
||||
NotADirectory,
|
||||
/// `screenshots/` 下的非普通文件。
|
||||
NotAFile,
|
||||
/// 名字过不了路径规则。
|
||||
UnsafeName,
|
||||
/// 元数据读不出来。
|
||||
Unreadable,
|
||||
/// 不是认得的图片扩展名。
|
||||
NotAnImage,
|
||||
}
|
||||
|
||||
/// 一个没能进列表的条目。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavesSkipView {
|
||||
/// 来自哪个目录。
|
||||
pub area: SavesArea,
|
||||
/// 磁盘名字。
|
||||
pub file: String,
|
||||
/// 为什么没收。
|
||||
pub reason: SavesSkipKind,
|
||||
}
|
||||
|
||||
/// 一次列举的完整快照(**整体替换**,不做增量)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavesListView {
|
||||
/// 实例 id
|
||||
pub instance: InstanceId,
|
||||
/// 被列的 `saves/` 目录(绝对路径)。
|
||||
pub dir: String,
|
||||
/// `saves/` 存不存在(`false` = 这实例还没存过档,不是错误)。
|
||||
pub exists: bool,
|
||||
/// 世界列表(按名字排序)。
|
||||
pub worlds: Vec<WorldView>,
|
||||
/// 被列的 `screenshots/` 目录(绝对路径)。
|
||||
pub screenshots_dir: String,
|
||||
/// `screenshots/` 存不存在。
|
||||
pub screenshots_exists: bool,
|
||||
/// 截图列表(**新的在前**)。
|
||||
pub screenshots: Vec<ScreenshotView>,
|
||||
/// 跳过的条目(UI 只需计数 + 一句"为什么")。
|
||||
pub skipped: Vec<SavesSkipView>,
|
||||
/// 最近一次写操作的回执(`None` = 这一拍只是列举,没写东西)。
|
||||
pub last_op: Option<SavesOped>,
|
||||
}
|
||||
|
||||
impl SavesListView {
|
||||
/// 世界数。
|
||||
#[must_use]
|
||||
pub fn world_count(&self) -> usize {
|
||||
self.worlds.len()
|
||||
}
|
||||
|
||||
/// 截图数。
|
||||
#[must_use]
|
||||
pub fn screenshot_count(&self) -> usize {
|
||||
self.screenshots.len()
|
||||
}
|
||||
|
||||
/// 跳过数。
|
||||
#[must_use]
|
||||
pub fn skipped_count(&self) -> usize {
|
||||
self.skipped.len()
|
||||
}
|
||||
|
||||
/// 两个目录都不存在(新实例的常态:还没进过游戏)。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.worlds.is_empty() && self.screenshots.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次失败(app 按 key 现求文案)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavesFailureView {
|
||||
/// 实例 id(迟到/串台的结果靠它判)。
|
||||
pub instance: InstanceId,
|
||||
/// 涉及的那个世界(写操作才有;读路径给空串)。
|
||||
///
|
||||
/// 为什么要这一格:失败那句话是「重命名 **A** 失败:同名世界已存在」——没有名字,
|
||||
/// 用户不知道该改哪一行(与 `ModFailureView::file` 同一条理由)。
|
||||
pub file: String,
|
||||
/// 这是哪一类**写**操作(`None` = 读路径 / 打开文件夹的失败)。
|
||||
pub op: Option<SavesFailureOp>,
|
||||
/// 失败大类。
|
||||
pub kind: SavesFailureKind,
|
||||
/// 底层原文(OS 错误 / 路径细节);只进括号,不进主句。
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 失败出自哪一类写操作(决定失败那句主句用哪个动词)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavesFailureOp {
|
||||
/// 重命名。
|
||||
Rename,
|
||||
/// 删除。
|
||||
Delete,
|
||||
/// 备份。
|
||||
Backup,
|
||||
}
|
||||
|
||||
/// 失败大类(**每一种在 UI 上都有一句话**,没有静默失败)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavesFailureKind {
|
||||
/// 实例不在了(快照里找不到),或隔离档位派生不出运行目录。
|
||||
InstanceUnavailable,
|
||||
/// `saves/` / `screenshots/` 存在但不可用(符号链接 / 不是目录 / 读不了)。
|
||||
DirUnusable,
|
||||
/// 「打开文件夹」失败(目录不存在 / 拉不起文件管理器)。
|
||||
OpenFailed,
|
||||
/// 写操作:名字过不了规则(空、`.`、`..`、含 `/` `\` `:` NUL)。
|
||||
WriteUnsafeName,
|
||||
/// 写操作:目标或路径上的一段是符号链接(不跟随)。
|
||||
WriteSymlink,
|
||||
/// 写操作:那个世界(或 `saves/`)已经不在了。
|
||||
WriteMissing,
|
||||
/// 写操作:那个名字底下不是目录。
|
||||
WriteNotADirectory,
|
||||
/// 写操作:同名世界已存在 / 备份文件名撞满。
|
||||
WriteConflict,
|
||||
/// 写操作:路径落在 `saves/` 之外(含层级异常深)。
|
||||
WriteBoundary,
|
||||
/// 写操作:备份超过体积或条目上限。
|
||||
WriteTooLarge,
|
||||
/// 写操作:实例正在启动/运行中,**一个字节都没碰盘**(见 `host::saves::dispatch`)。
|
||||
///
|
||||
/// 单独一类而不是折进 [`WriteFailed`](Self::WriteFailed):Linux 上删一个正在跑的世界**会成功**,
|
||||
/// 所以这不是"写盘失败了",是"我们主动没写"。界面必须说「游戏正在运行,先退出再操作」。
|
||||
GameRunning,
|
||||
/// 写操作:其它系统错误(权限 / 被别的进程占用),OS 原文进 `detail`。
|
||||
WriteFailed,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,88 @@
|
||||
//! 一张截图的**预览**(M251 · SAVESHOTS,D54 第 2/6 条)。
|
||||
//!
|
||||
//! ## 像素为什么走 DTO
|
||||
//!
|
||||
//! D54 定「**不落盘缩略图**」:写 PNG 要编码器,而编码器只为一份磁盘缓存引入不划算。于是像素
|
||||
//! 就直接躺在快照里跟着 IPC 面走,由壳层用 `iced::widget::image::Handle::from_rgba` 建句柄。
|
||||
//! 代价是快照变大(60 张 × 160² × 4B ≈ 6MB),收益是零编码、零临时文件、零清理。
|
||||
//!
|
||||
//! ## 三种状态,一种都不许静默
|
||||
//!
|
||||
//! | 状态 | 什么时候 | 界面 |
|
||||
//! |---|---|---|
|
||||
//! | [`ScreenshotPreview::Ready`] | 解出来了 | 缩略图 |
|
||||
//! | [`ScreenshotPreview::NotGenerated`] | 本次刷新超出 60 张上限(按 mtime 倒序只解前 60) | 「未生成预览」占位 |
|
||||
//! | [`ScreenshotPreview::Failed`] | 超过 8 MiB / 读不出来 / 解不出来 | 占位 + **逐条**原因 |
|
||||
//!
|
||||
//! **不拼人读文案**(M161):这里只有结构与 [`ScreenshotThumbKind`],句子由 app 层按 i18n key 现求。
|
||||
//!
|
||||
//! ## 为什么失败也带 `detail`
|
||||
//!
|
||||
//! 「读不出来」有五种完全不同的原因(权限 / 被占用 / 文件已删 / 是目录 / 盘掉了),用户能自己
|
||||
//! 处理的那几种全靠这句话。原文只进括号,不进主句(与 [`super::SavesFailureView`] 同一条口径)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 一张截图的预览状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ScreenshotPreview {
|
||||
/// 还没生成:本次刷新超出上限(按 mtime 倒序只解前 60 张,上限常量在
|
||||
/// `crate::host::saves::thumbs::MAX_PER_REFRESH`——策略住在解码那一侧,DTO 只表达状态)。
|
||||
NotGenerated,
|
||||
/// 解出来了(RGBA8,行主序,`width × height × 4`)。
|
||||
Ready(ThumbnailView),
|
||||
/// 没解出来(逐条可见的原因 + 原文)。
|
||||
Failed(ScreenshotThumbFailure),
|
||||
}
|
||||
|
||||
impl ScreenshotPreview {
|
||||
/// 这一拍有没有可画的像素。
|
||||
#[must_use]
|
||||
pub fn thumbnail(&self) -> Option<&ThumbnailView> {
|
||||
match self {
|
||||
Self::Ready(view) => Some(view),
|
||||
Self::NotGenerated | Self::Failed(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一张缩略图的像素(**未落盘**,见模块文档)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ThumbnailView {
|
||||
/// 宽(像素)。
|
||||
pub width: u32,
|
||||
/// 高(像素)。
|
||||
pub height: u32,
|
||||
/// RGBA8 字节,行主序,长度必须是 `width × height × 4`。
|
||||
pub pixels: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ThumbnailView {
|
||||
/// 像素数与宽高对得上吗(壳层建 `Handle` 之前**必须**成立:`from_rgba` 不校验长度,
|
||||
/// 长度不对就是一张花屏图或者一次 panic 后的空窗)。
|
||||
#[must_use]
|
||||
pub fn is_consistent(&self) -> bool {
|
||||
let expected = u64::from(self.width) * u64::from(self.height) * 4;
|
||||
expected == self.pixels.len() as u64
|
||||
}
|
||||
}
|
||||
|
||||
/// 一张截图的预览为什么没出来(**每一种在 UI 上都有一句话**)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ScreenshotThumbFailure {
|
||||
/// 大类。
|
||||
pub kind: ScreenshotThumbKind,
|
||||
/// 底层原文(OS 错误 / 解码器原文);只进括号,不进主句。
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 预览失败的大类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ScreenshotThumbKind {
|
||||
/// 单张超过解码上限(8 MiB):**不解码**,直接跳过(D54 第 5 条)。
|
||||
TooLarge,
|
||||
/// 读不出来(不存在 / 无权限 / 被占用)。
|
||||
Unreadable,
|
||||
/// 读到了但解不出来(坏 PNG / 不支持的格式)。
|
||||
Decode,
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! 存档 IPC 面的用例:**IPC round-trip**(生成物必须能反序列化回来)。
|
||||
|
||||
use super::*;
|
||||
|
||||
fn view() -> SavesListView {
|
||||
SavesListView {
|
||||
instance: "inst".to_string(),
|
||||
dir: "/tmp/run/saves".to_string(),
|
||||
exists: true,
|
||||
worlds: vec![WorldView {
|
||||
dir: "World".to_string(),
|
||||
icon: true,
|
||||
modified_ms: Some(1_700_000_000_000),
|
||||
}],
|
||||
screenshots_dir: "/tmp/run/screenshots".to_string(),
|
||||
screenshots_exists: true,
|
||||
screenshots: vec![ScreenshotView {
|
||||
file: "2024-05-01_10.00.00.png".to_string(),
|
||||
bytes: 1024,
|
||||
modified_ms: None,
|
||||
// M251:像素真的能过 IPC(4 字节 = 1×1 RGBA),这是「不落盘直接交给壳层」的前提。
|
||||
preview: ScreenshotPreview::Ready(ThumbnailView {
|
||||
width: 1,
|
||||
height: 1,
|
||||
pixels: vec![0x2E, 0x7D, 0x4F, 0xFF],
|
||||
}),
|
||||
}],
|
||||
skipped: vec![SavesSkipView {
|
||||
area: SavesArea::Screenshots,
|
||||
file: "notes.txt".to_string(),
|
||||
reason: SavesSkipKind::NotAnImage,
|
||||
}],
|
||||
last_op: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 计数与 IPC round-trip(AGENTS §6:跨界类型必须能序列化)。
|
||||
#[test]
|
||||
fn view_counts_and_round_trips() {
|
||||
let view = view();
|
||||
assert_eq!(view.world_count(), 1);
|
||||
assert_eq!(view.screenshot_count(), 1);
|
||||
assert_eq!(view.skipped_count(), 1);
|
||||
assert!(!view.is_empty());
|
||||
|
||||
let text = serde_json::to_string(&view).expect("序列化");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SavesListView>(&text).expect("反序列化"),
|
||||
view
|
||||
);
|
||||
}
|
||||
|
||||
/// 五条指令都要能过 IPC(含写操作三条——它们带世界名,名字里可能有空格与中文)。
|
||||
#[test]
|
||||
fn commands_round_trip() {
|
||||
for command in [
|
||||
SavesCommand::List("inst".to_string()),
|
||||
SavesCommand::OpenFolder {
|
||||
id: "inst".to_string(),
|
||||
},
|
||||
SavesCommand::RenameWorld {
|
||||
id: "inst".to_string(),
|
||||
dir: "Better World".to_string(),
|
||||
to: "更好的世界".to_string(),
|
||||
},
|
||||
SavesCommand::DeleteWorld {
|
||||
id: "inst".to_string(),
|
||||
dir: "Better World".to_string(),
|
||||
},
|
||||
SavesCommand::BackupWorld {
|
||||
id: "inst".to_string(),
|
||||
dir: "Better World".to_string(),
|
||||
dest: Some("D:/backups".to_string()),
|
||||
},
|
||||
SavesCommand::BackupWorld {
|
||||
id: "inst".to_string(),
|
||||
dir: "Better World".to_string(),
|
||||
dest: None,
|
||||
},
|
||||
] {
|
||||
let text = serde_json::to_string(&command).expect("序列化");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SavesCommand>(&text).expect("反序列化"),
|
||||
command
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 成功回执也要能过 IPC(备份那一支字段最多:路径 / 字节 / 跳过数)。
|
||||
#[test]
|
||||
fn op_receipts_round_trip() {
|
||||
let cases = [
|
||||
SavesOped::Renamed {
|
||||
from: "Old".to_string(),
|
||||
to: "New".to_string(),
|
||||
},
|
||||
SavesOped::Deleted {
|
||||
dir: "Old".to_string(),
|
||||
},
|
||||
SavesOped::BackedUp {
|
||||
dir: "Old".to_string(),
|
||||
file: "Old-20240501-100000.zip".to_string(),
|
||||
path: "D:/exports/Old-20240501-100000.zip".to_string(),
|
||||
bytes: 4096,
|
||||
skipped: 2,
|
||||
},
|
||||
];
|
||||
for op in cases {
|
||||
let mut view = view();
|
||||
view.last_op = Some(op);
|
||||
let text = serde_json::to_string(&view).expect("序列化");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SavesListView>(&text).expect("反序列化"),
|
||||
view
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 两个目录都不存在 ⇒ 空(`is_empty` 只看内容,不看 `exists`:一个目录有空世界也算非空)。
|
||||
#[test]
|
||||
fn empty_means_no_worlds_and_no_screenshots() {
|
||||
let mut view = view();
|
||||
view.worlds.clear();
|
||||
view.screenshots.clear();
|
||||
assert!(view.is_empty(), "没有世界也没有截图就该算空");
|
||||
view.screenshots.push(ScreenshotView {
|
||||
file: "a.png".to_string(),
|
||||
bytes: 0,
|
||||
modified_ms: None,
|
||||
preview: ScreenshotPreview::NotGenerated,
|
||||
});
|
||||
assert!(!view.is_empty());
|
||||
}
|
||||
|
||||
/// 预览的三种状态都要能过 IPC,且**失败原因逐条带得回来**(M251 · D54 第 6 条)。
|
||||
#[test]
|
||||
fn previews_round_trip_in_every_state() {
|
||||
let failure = ScreenshotThumbFailure {
|
||||
kind: ScreenshotThumbKind::TooLarge,
|
||||
detail: "9000000 > 8388608 字节".to_string(),
|
||||
};
|
||||
for preview in [
|
||||
ScreenshotPreview::NotGenerated,
|
||||
ScreenshotPreview::Ready(ThumbnailView {
|
||||
width: 2,
|
||||
height: 1,
|
||||
pixels: vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
}),
|
||||
ScreenshotPreview::Failed(failure),
|
||||
] {
|
||||
let text = serde_json::to_string(&preview).expect("序列化");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ScreenshotPreview>(&text).expect("反序列化"),
|
||||
preview
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 「像素长度与宽高对得上」这条判据本身(壳层建 `Handle` 前要它成立)。
|
||||
#[test]
|
||||
fn thumbnail_consistency_catches_a_short_buffer() {
|
||||
let good = ThumbnailView {
|
||||
width: 2,
|
||||
height: 2,
|
||||
pixels: vec![0; 16],
|
||||
};
|
||||
assert!(good.is_consistent());
|
||||
let short = ThumbnailView {
|
||||
pixels: vec![0; 15],
|
||||
..good.clone()
|
||||
};
|
||||
assert!(!short.is_consistent(), "少一个字节也必须判不一致");
|
||||
}
|
||||
|
||||
/// 只有 `Ready` 才有像素可画(另外两支都是占位,界面不许把它们当成「黑图」)。
|
||||
#[test]
|
||||
fn only_ready_hands_out_pixels() {
|
||||
assert!(ScreenshotPreview::Ready(ThumbnailView {
|
||||
width: 1,
|
||||
height: 1,
|
||||
pixels: vec![0, 0, 0, 255],
|
||||
})
|
||||
.thumbnail()
|
||||
.is_some());
|
||||
assert!(ScreenshotPreview::NotGenerated.thumbnail().is_none());
|
||||
assert!(ScreenshotPreview::Failed(ScreenshotThumbFailure {
|
||||
kind: ScreenshotThumbKind::Unreadable,
|
||||
detail: "access denied".to_string(),
|
||||
})
|
||||
.thumbnail()
|
||||
.is_none());
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! 设置页 DTO:设置分组的通用行模型。
|
||||
//!
|
||||
//! 「行」是 UI 与 `core::setting` schema 之间的唯一形状:一行 = 一个键 + 一种控件 +
|
||||
//! 当前值。写回走 [`crate::dto::AppCommand::SetSetting`],由 host 用 `SettingStore::set`
|
||||
//! 落盘(不自己拼 JSON、不另立配置文件,`AGENTS §6`)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 设置页 Java 组「写回完成,请重发快照」的动作键(M204)。
|
||||
///
|
||||
/// ## 它解决的问题
|
||||
///
|
||||
/// Java 组把三键(`java.default_memory_mb` / `java.extra_args` / `java.selected_path`)**一次原子**
|
||||
/// 写进 `launcher.json`(`app-iced` 的 `pages/settings/java/store.rs`:`set` 几个键 + 一次
|
||||
/// `save()`),走的是页面自己的 `SettingStore`,**不经过** [`crate::dto::AppCommand::SetSetting`]
|
||||
/// 的写盘分支。而 `CoreEvent::SettingsChanged` 全仓只有两处发出(`host/dispatch.rs` 的
|
||||
/// `SetSetting` 分支、`host/mod.rs` 的 `refresh_all`)。于是三键写成功之后,presenter 的 java 组
|
||||
/// 快照会陈旧到下一次任何别的写入——`AppState::settings_groups` 里的 `java.selected_path` /
|
||||
/// `java.extra_args` 还是旧值。
|
||||
///
|
||||
/// ## 为什么不是「把写盘搬进 SetSetting」
|
||||
///
|
||||
/// 那会把一次原子写拆成最多三条命令(`SetSetting` 一次只收一个键),于是内存与 JVM 参数的保存
|
||||
/// 会出现「第一键落盘、第二键被 schema 拒」的中间态;每条命令还要各走一次异步落盘,用户点一次
|
||||
/// 保存会看到三次写盘与三次快照回流。取舍见 M204 报告。
|
||||
///
|
||||
/// ## 形状
|
||||
///
|
||||
/// 与 `host::engine::DETECT_REGION_ACTION`(`download.action.detect_region`,M165)同一族:
|
||||
/// **动作键不写盘**,`host::settings::set` 见到它就返回 `Ok(())`,于是 `dispatch.rs` 的
|
||||
/// `SetSetting` 分支照常执行它那四个副作用臂(重建引擎 / 重发 `SettingsChanged` / 清 Java 链)。
|
||||
/// 键名带 `action` 段,是为了让人一眼看出它**不是** schema 键(`core::setting::keys` 里没有它,
|
||||
/// 手写进 `launcher.json` 也只会被当成未知键忽略)。
|
||||
pub const JAVA_REFRESH_ACTION: &str = "java.action.refresh";
|
||||
|
||||
/// 控件类型(决定设置页画什么)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SettingControl {
|
||||
/// 开关
|
||||
Toggle,
|
||||
/// 分段单选(`options` + `selected`)
|
||||
Choice,
|
||||
/// 单行文本(`value_text`)
|
||||
Text,
|
||||
/// 只读展示(当前路径/当前版本这类)
|
||||
ReadOnly,
|
||||
/// 动作按钮(`action` 给回调 id)
|
||||
Action,
|
||||
}
|
||||
|
||||
/// 一行设置。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SettingRowView {
|
||||
/// schema 键(`download.concurrency`)
|
||||
pub key: String,
|
||||
/// 行标题
|
||||
pub label: String,
|
||||
/// 说明/当前值附注
|
||||
pub description: String,
|
||||
/// 控件
|
||||
pub control: SettingControl,
|
||||
/// 开关值
|
||||
pub flag: bool,
|
||||
/// 单选序号
|
||||
pub selected: i32,
|
||||
/// 单选项文案
|
||||
pub options: Vec<String>,
|
||||
/// 文本值
|
||||
pub value_text: String,
|
||||
/// 动作 id(`detect_region` 这类)
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
impl SettingRowView {
|
||||
/// 开关行。
|
||||
pub fn toggle(key: &str, label: &str, description: &str, value: bool) -> Self {
|
||||
Self {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
description: description.to_string(),
|
||||
control: SettingControl::Toggle,
|
||||
flag: value,
|
||||
selected: 0,
|
||||
options: Vec::new(),
|
||||
value_text: String::new(),
|
||||
action: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 单选行。
|
||||
pub fn choice(
|
||||
key: &str,
|
||||
label: &str,
|
||||
description: &str,
|
||||
options: &[&str],
|
||||
selected: i32,
|
||||
) -> Self {
|
||||
Self {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
description: description.to_string(),
|
||||
control: SettingControl::Choice,
|
||||
flag: false,
|
||||
selected,
|
||||
options: options.iter().map(|item| (*item).to_string()).collect(),
|
||||
value_text: String::new(),
|
||||
action: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本行。
|
||||
pub fn text(key: &str, label: &str, description: &str, value: &str) -> Self {
|
||||
Self {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
description: description.to_string(),
|
||||
control: SettingControl::Text,
|
||||
flag: false,
|
||||
selected: 0,
|
||||
options: Vec::new(),
|
||||
value_text: value.to_string(),
|
||||
action: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 只读行。
|
||||
pub fn read_only(label: &str, value: &str) -> Self {
|
||||
Self {
|
||||
key: String::new(),
|
||||
label: label.to_string(),
|
||||
description: value.to_string(),
|
||||
control: SettingControl::ReadOnly,
|
||||
flag: false,
|
||||
selected: 0,
|
||||
options: Vec::new(),
|
||||
value_text: value.to_string(),
|
||||
action: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 动作行。
|
||||
pub fn action(key: &str, label: &str, description: &str, action: &str) -> Self {
|
||||
Self {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
description: description.to_string(),
|
||||
control: SettingControl::Action,
|
||||
flag: false,
|
||||
selected: 0,
|
||||
options: Vec::new(),
|
||||
value_text: String::new(),
|
||||
action: action.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个设置分组(设置页右栏的一段)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SettingGroupView {
|
||||
/// 分组 id(与设置页子导航的 `SettingSection` 同名:download / java / data / ai / mcp)
|
||||
pub id: String,
|
||||
/// 分组标题
|
||||
pub title: String,
|
||||
/// 行
|
||||
pub rows: Vec<SettingRowView>,
|
||||
}
|
||||
|
||||
impl SettingGroupView {
|
||||
/// 建组。
|
||||
pub fn new(id: &str, title: &str) -> Self {
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
rows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加一行(链式)。
|
||||
pub fn with(mut self, row: SettingRowView) -> Self {
|
||||
self.rows.push(row);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 在分组快照里找一个键的当前值(设置页回填用)。
|
||||
pub fn find_row<'a>(groups: &'a [SettingGroupView], key: &str) -> Option<&'a SettingRowView> {
|
||||
groups
|
||||
.iter()
|
||||
.flat_map(|group| group.rows.iter())
|
||||
.find(|row| row.key == key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rows_carry_exactly_one_control_kind() {
|
||||
let toggle = SettingRowView::toggle("agent.mcp.enabled", "MCP", "", true);
|
||||
assert_eq!(toggle.control, SettingControl::Toggle);
|
||||
assert!(toggle.flag);
|
||||
|
||||
let choice = SettingRowView::choice("download.region", "地区", "", &["auto", "cn"], 1);
|
||||
assert_eq!(choice.options, vec!["auto", "cn"]);
|
||||
assert_eq!(choice.selected, 1);
|
||||
|
||||
let action = SettingRowView::action("java.action", "检测", "", "detect_region");
|
||||
assert_eq!(action.action, "detect_region");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_row_scans_all_groups() {
|
||||
let groups = vec![
|
||||
SettingGroupView::new("download", "下载").with(SettingRowView::text(
|
||||
"download.proxy_url",
|
||||
"代理",
|
||||
"",
|
||||
"",
|
||||
)),
|
||||
SettingGroupView::new("mcp", "MCP").with(SettingRowView::toggle(
|
||||
"agent.mcp.enabled",
|
||||
"开关",
|
||||
"",
|
||||
false,
|
||||
)),
|
||||
];
|
||||
assert_eq!(
|
||||
find_row(&groups, "agent.mcp.enabled").map(|row| row.label.as_str()),
|
||||
Some("开关")
|
||||
);
|
||||
assert!(find_row(&groups, "nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_are_serde_round_trip_safe() {
|
||||
let group = SettingGroupView::new("download", "下载").with(SettingRowView::choice(
|
||||
"download.concurrency",
|
||||
"并发",
|
||||
"",
|
||||
&["4", "8", "16"],
|
||||
1,
|
||||
));
|
||||
let text = serde_json::to_string(&group).expect("序列化");
|
||||
let back: SettingGroupView = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, group);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
//! 自更新 DTO(M159):一次检查的结论、候选、失败分类、就位状态。
|
||||
//!
|
||||
//! 口径四条:
|
||||
//!
|
||||
//! 1. **「检查失败」不是「已是最新」**——两者是 [`UpdateOutcome`] 的两个变体,各有自己的文案。
|
||||
//! 把它们折叠成一个(例如失败时也发 `UpToDate`)会让用户以为「已经最新」,而网络其实一次都没通;
|
||||
//! 2. **「有更高版本但没给你的平台发包」也不是「已是最新」**([`UpdateOutcome::MissingArtifact`]):
|
||||
//! 那是发布侧的漏项,说成「最新」等于把发布的错报成用户的好消息;
|
||||
//! 3. **版本比较只发生在 core**([`unlucky_core::update::UpdateChecker`]):本模块只搬运结论,
|
||||
//! UI 与 CLI 都不许自己比版本号(mission:GUI 与 CLI 行为一致,不许两套逻辑);
|
||||
//! 4. 本模块不含 core 类型(D42:dto 只放扁平 IPC 类型)。
|
||||
//!
|
||||
//! 「忽略这个版本」([`UpdateView::skipped`])记的是**版本号**,不是「别再问我了」:
|
||||
//!
|
||||
//! * 用户点「忽略这个版本」时写下**当时那个版本号**(会话级,不落盘);
|
||||
//! * 同一次运行里如果又出现**更高**的版本(0.2.3 被忽略后冒出 0.2.4),提示条**照常弹**——
|
||||
//! 忽略的是那一个版本,文案也确实是「忽略这个版本」;
|
||||
//! * 设置页的「立即检查」会清掉它(手动检查 = 用户要看真结果),静默检查**永不**清。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 检查更新的一次结论。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum UpdateOutcome {
|
||||
/// 还没查过(启动那一刻的状态)。
|
||||
#[default]
|
||||
Idle,
|
||||
/// 正在查(网络在后台线程)。
|
||||
Checking,
|
||||
/// 已是最新。
|
||||
UpToDate {
|
||||
/// 该渠道准入的最高版本;清单里还没有任何发布时为 `None`(「还没发过版」不是错误)。
|
||||
latest: Option<String>,
|
||||
},
|
||||
/// 有可装的更新。
|
||||
Available(Box<UpdateCandidateView>),
|
||||
/// 有更高的版本,但里面没有本平台的产物(发布侧漏项,不能对用户说「已是最新」)。
|
||||
MissingArtifact {
|
||||
/// 那个更高版本。
|
||||
version: String,
|
||||
},
|
||||
/// 检查失败(连不上 / 清单坏了 / 渠道不符 …)。
|
||||
Failed(UpdateFailureView),
|
||||
}
|
||||
|
||||
impl UpdateOutcome {
|
||||
/// 是不是正在查。
|
||||
#[must_use]
|
||||
pub const fn is_checking(&self) -> bool {
|
||||
matches!(self, Self::Checking)
|
||||
}
|
||||
|
||||
/// 可装的候选(没有则为 `None`)。
|
||||
#[must_use]
|
||||
pub fn candidate(&self) -> Option<&UpdateCandidateView> {
|
||||
match self {
|
||||
Self::Available(candidate) => Some(candidate),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败信息(不是失败则为 `None`)。
|
||||
#[must_use]
|
||||
pub const fn failure(&self) -> Option<&UpdateFailureView> {
|
||||
match self {
|
||||
Self::Failed(failure) => Some(failure),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 稳定 id(日志 / 测试 / 事件流用;**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Idle => "idle",
|
||||
Self::Checking => "checking",
|
||||
Self::UpToDate { .. } => "up_to_date",
|
||||
Self::Available(_) => "available",
|
||||
Self::MissingArtifact { .. } => "missing_artifact",
|
||||
Self::Failed(_) => "failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个可安装的候选(提示条与设置页共用的四件)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UpdateCandidateView {
|
||||
/// 新版本号(清单里的原文,如 `0.2.3`)。
|
||||
pub version: String,
|
||||
/// 包体字节数(提示条上的「体积」)。
|
||||
pub size: u64,
|
||||
/// 更新说明摘要(清单里的 `notes`,已截断;空串 = 发布没写说明)。
|
||||
pub notes: String,
|
||||
/// 急不急(`true` = 发布标了强制,或当前版本低于发布侧的最低支持版本)。
|
||||
pub forced: bool,
|
||||
}
|
||||
|
||||
/// 检查失败的分类与人话。
|
||||
///
|
||||
/// `message` 直接来自 core 的错误(中文、含路径/URL 细节),UI 只在它前面加一句自己的话;
|
||||
/// `kind` 是 core 的稳定分类 id(`network` / `invalid_manifest` / `digest_mismatch` …),
|
||||
/// 给「要不要显示重试」这类判据用。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UpdateFailureView {
|
||||
/// 稳定分类 id(`UpdateErrorKind::as_str`)。
|
||||
pub kind: String,
|
||||
/// 人话原因。
|
||||
pub message: String,
|
||||
/// 换个时间/换个源再试是否可能变好。
|
||||
pub retryable: bool,
|
||||
}
|
||||
|
||||
impl UpdateFailureView {
|
||||
/// 建一条。
|
||||
pub fn new(kind: &str, message: impl Into<String>, retryable: bool) -> Self {
|
||||
Self {
|
||||
kind: kind.to_string(),
|
||||
message: message.into(),
|
||||
retryable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 已经就位的新版本(下载 + 校验 + 替换都成功,等用户重启)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReadyUpdate {
|
||||
/// 新版本号。
|
||||
pub version: String,
|
||||
/// 替换后的目标路径(人读展示;「下次启动生效」说的就是它)。
|
||||
pub path: String,
|
||||
/// 旧版本被挪到哪(`None` = 原本没有文件 / 已清理)。
|
||||
pub backup: Option<String>,
|
||||
}
|
||||
|
||||
/// 提示条此刻要显示什么。
|
||||
///
|
||||
/// 「检查失败」只有在**用户主动查**的时候才弹条([`UpdateView::silent`] = `false`):
|
||||
/// 启动时的静默检查失败是常态(离线、公司网、镜像没同步),拿一条红条迎面上来等于每次开
|
||||
/// 启动器先骂网络一次。失败原因仍然进设置页的「更新」区,那里随时能看。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UpdateBanner<'a> {
|
||||
/// 有新版本(可下载)。
|
||||
Available(&'a UpdateCandidateView),
|
||||
/// 正在下载/校验/替换。
|
||||
Applying,
|
||||
/// 已就位,等重启。
|
||||
Ready(&'a ReadyUpdate),
|
||||
/// 检查/应用失败(只在用户主动查之后出现)。
|
||||
Failed(&'a UpdateFailureView),
|
||||
}
|
||||
|
||||
/// 更新页 / 提示条的全部状态(一条快照整体替换)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UpdateView {
|
||||
/// 当前版本(`CARGO_PKG_VERSION`,由 host 填)。
|
||||
pub current_version: String,
|
||||
/// 渠道(D50:先只发 stable)。
|
||||
pub channel: String,
|
||||
/// 启动时是否静默检查(设置页开关)。
|
||||
pub auto_check: bool,
|
||||
/// 检查结论。
|
||||
pub outcome: UpdateOutcome,
|
||||
/// 上次检查时间(`MM-DD HH:MM`;空串 = 没查过)。
|
||||
pub checked_at: String,
|
||||
/// 这一份结论来自**静默**检查(启动时/自动)而不是用户点的「立即检查」。
|
||||
pub silent: bool,
|
||||
/// 正在下载/校验/替换(一条慢操作,期间禁用「立即安装」)。
|
||||
pub applying: bool,
|
||||
/// 已经就位的新版本(等重启)。
|
||||
pub ready: Option<ReadyUpdate>,
|
||||
/// 用户点了「忽略这个版本」时记下的**版本号**(`None` = 没忽略)。
|
||||
///
|
||||
/// 只压住**这一个**版本:0.2.3 被忽略后 0.2.4 仍然提示(文案是「忽略这个版本」,
|
||||
/// 不是「别再问我」)。设置页手动检查会把它清空。
|
||||
pub skipped: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for UpdateView {
|
||||
/// 出厂状态:当前版本取本进程的版本号(**不是空串**——mock 后端/未接线的路径下,
|
||||
/// 设置页的「当前版本」那一行至少要显示自己的版本,而不是一片空白)。
|
||||
fn default() -> Self {
|
||||
Self::new(env!("CARGO_PKG_VERSION"), "stable", true)
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateView {
|
||||
/// 建一份初始状态。
|
||||
#[must_use]
|
||||
pub fn new(current: impl Into<String>, channel: &str, auto_check: bool) -> Self {
|
||||
Self {
|
||||
current_version: current.into(),
|
||||
channel: channel.to_string(),
|
||||
auto_check,
|
||||
outcome: UpdateOutcome::Idle,
|
||||
checked_at: String::new(),
|
||||
silent: false,
|
||||
applying: false,
|
||||
ready: None,
|
||||
skipped: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 提示条此刻要显示什么(`None` = 不显示)。
|
||||
///
|
||||
/// 优先级:就位 > 进行中 > 失败(仅主动查) > 有新版本(未被忽略)。
|
||||
#[must_use]
|
||||
pub fn banner(&self) -> Option<UpdateBanner<'_>> {
|
||||
if let Some(ready) = &self.ready {
|
||||
return Some(UpdateBanner::Ready(ready));
|
||||
}
|
||||
if self.applying {
|
||||
return Some(UpdateBanner::Applying);
|
||||
}
|
||||
if let Some(failure) = self.outcome.failure() {
|
||||
return (!self.silent).then_some(UpdateBanner::Failed(failure));
|
||||
}
|
||||
self.outcome
|
||||
.candidate()
|
||||
.filter(|candidate| self.skipped.as_deref() != Some(candidate.version.as_str()))
|
||||
.map(UpdateBanner::Available)
|
||||
}
|
||||
|
||||
/// 有没有「需要用户看一眼」的结论(提示条 / 就位后的重启提示 / 强制更新)。
|
||||
#[must_use]
|
||||
pub fn needs_attention(&self) -> bool {
|
||||
self.banner().is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn candidate(version: &str) -> UpdateCandidateView {
|
||||
UpdateCandidateView {
|
||||
version: version.to_string(),
|
||||
size: 21_000_000,
|
||||
notes: "修掉启动崩溃".to_string(),
|
||||
forced: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_is_not_up_to_date() {
|
||||
let mut view = UpdateView {
|
||||
outcome: UpdateOutcome::Failed(UpdateFailureView::new(
|
||||
"network",
|
||||
"取更新清单失败:连接被重置",
|
||||
true,
|
||||
)),
|
||||
..UpdateView::default()
|
||||
};
|
||||
assert!(view.outcome.failure().is_some());
|
||||
assert!(view.outcome.candidate().is_none());
|
||||
assert!(!matches!(view.outcome, UpdateOutcome::UpToDate { .. }));
|
||||
assert_eq!(view.outcome.as_str(), "failed");
|
||||
|
||||
// 用户主动查出来的失败 → 提示条要说清原因(不是「已是最新」)。
|
||||
assert!(matches!(view.banner(), Some(UpdateBanner::Failed(_))));
|
||||
// 静默检查的失败 → 不弹条(离线是常态,别每次开机先骂网络一次)。
|
||||
view.silent = true;
|
||||
assert!(view.banner().is_none(), "静默检查失败不该变成提示条");
|
||||
assert!(
|
||||
view.outcome.failure().is_some(),
|
||||
"但失败原因仍留在快照里(设置页要看)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipping_one_version_does_not_silence_the_next_one() {
|
||||
let mut view = UpdateView {
|
||||
outcome: UpdateOutcome::Available(Box::new(candidate("0.2.3"))),
|
||||
..UpdateView::default()
|
||||
};
|
||||
assert!(matches!(view.banner(), Some(UpdateBanner::Available(_))));
|
||||
|
||||
view.skipped = Some("0.2.3".to_string());
|
||||
assert!(view.banner().is_none(), "忽略 0.2.3 之后它自己不再打扰");
|
||||
// 候选没被丢掉:设置页仍能显示「有新版本 0.2.3」并手动装
|
||||
assert_eq!(
|
||||
view.outcome.candidate().map(|item| item.version.as_str()),
|
||||
Some("0.2.3")
|
||||
);
|
||||
|
||||
// 同一次运行里冒出更高的版本:**照常提示**(文案是「忽略这个版本」,不是「别再问我」)。
|
||||
view.outcome = UpdateOutcome::Available(Box::new(candidate("0.2.4")));
|
||||
assert!(
|
||||
matches!(view.banner(), Some(UpdateBanner::Available(c)) if c.version == "0.2.4"),
|
||||
"0.2.3 被忽略不该压住 0.2.4"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_state_replaces_the_banner() {
|
||||
let view = UpdateView {
|
||||
outcome: UpdateOutcome::Available(Box::new(candidate("0.2.3"))),
|
||||
ready: Some(ReadyUpdate {
|
||||
version: "0.2.3".to_string(),
|
||||
path: "C:/ULL/unlucky-launcher.exe".to_string(),
|
||||
backup: Some("C:/ULL/.unlucky-launcher.exe.ullold-1-0".to_string()),
|
||||
}),
|
||||
..UpdateView::default()
|
||||
};
|
||||
assert!(matches!(view.banner(), Some(UpdateBanner::Ready(_))));
|
||||
assert!(view.needs_attention(), "就位之后要提示重启");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_to_date_and_idle_show_no_banner() {
|
||||
let mut view = UpdateView::default();
|
||||
assert!(view.banner().is_none(), "Idle 还没查过,不该弹条");
|
||||
view.outcome = UpdateOutcome::UpToDate {
|
||||
latest: Some("0.2.2".to_string()),
|
||||
};
|
||||
assert!(view.banner().is_none(), "已是最新没有任何要说的");
|
||||
view.outcome = UpdateOutcome::MissingArtifact {
|
||||
version: "0.2.3".to_string(),
|
||||
};
|
||||
assert!(view.banner().is_none(), "缺产物不进提示条,进设置页原因");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_round_trips_as_ipc() {
|
||||
let mut view = UpdateView::new("0.2.2", "stable", false);
|
||||
view.checked_at = "11-08 12:00".to_string();
|
||||
view.outcome = UpdateOutcome::UpToDate {
|
||||
latest: Some("0.2.2".to_string()),
|
||||
};
|
||||
let text = serde_json::to_string(&view).expect("序列化");
|
||||
let back: UpdateView = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, view);
|
||||
assert!(!back.auto_check);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! 下载页 DTO:版本列表与分类(含远古版子类,D16/Q24-4)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 版本类型(远古版按 unlisted-versions 的分类拆开)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VersionKind {
|
||||
/// 正式版
|
||||
Release,
|
||||
/// 预览版(snapshot)
|
||||
Snapshot,
|
||||
/// 远古版(带子类)
|
||||
Ancient(AncientKind),
|
||||
}
|
||||
|
||||
/// 远古版子类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AncientKind {
|
||||
/// classic(rd-*)
|
||||
Classic,
|
||||
/// old_alpha(a1.*)
|
||||
OldAlpha,
|
||||
/// old_beta(b1.*)
|
||||
OldBeta,
|
||||
/// 未列入官方清单的远古版汇总
|
||||
Unlisted,
|
||||
}
|
||||
|
||||
impl VersionKind {
|
||||
/// 是否属于「远古版」——列表行要加警示徽标。
|
||||
pub fn is_ancient(self) -> bool {
|
||||
matches!(self, Self::Ancient(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载页版本行。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VersionSummary {
|
||||
/// 版本 id(如 `1.20.4` / `rd-132211`)
|
||||
pub id: String,
|
||||
/// 版本类型
|
||||
pub kind: VersionKind,
|
||||
/// 发布日期(ISO-8601 日期部分)
|
||||
pub released: String,
|
||||
/// 本地是否已安装(行尾显示「已装」)
|
||||
pub installed: bool,
|
||||
}
|
||||
|
||||
/// 下载页左侧分类(页内常驻侧栏的选中项)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VersionCategory {
|
||||
/// 正式版
|
||||
Release,
|
||||
/// 预览版
|
||||
Snapshot,
|
||||
/// 远古版某子类
|
||||
Ancient(AncientKind),
|
||||
/// 整合包(P1)
|
||||
Modpack,
|
||||
/// Mod(P1)
|
||||
Mod,
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
//! 账户:三轨统一列表(只读公开字段)+ 离线新增 + 删除/切换 + 微软登录(两条流)。
|
||||
//!
|
||||
//! 凭据红线(D9):`Credentials` / `MicrosoftSession` 只在本模块与 `launch.rs` 里出现,
|
||||
//! 出口一律是 [`AccountView`] / [`DeviceLoginView`]。
|
||||
//!
|
||||
//! 两条登录流(M178 改了默认):**设备码是默认**([`start_login`]:不依赖任何应用注册项),
|
||||
//! 授权码([`start_browser_login`],浏览器 + 本地回调)降级成「用户显式要求才试」的加速路,
|
||||
//! 且带回环注册预检 + 失败自动回退设备码。除了「怎么拿到 MS access_token」之外完全共用:
|
||||
//! 同一个取消槽位、同一段账户入库、同一个刷新链。
|
||||
//!
|
||||
//! 取消语义:轮询/等回调都跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时
|
||||
//! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。
|
||||
//!
|
||||
//! M132:缺 client id 只在**登录入口**失败(`auth_failure`),所以说明接在账户页的登录面板,
|
||||
//! 不接启动流程——没配 id 的发布版根本建不出微软账户,启动那条路用户撞不到。
|
||||
//!
|
||||
//! M236 拆分:两条流的编排搬到 `accounts/login.rs`(纯搬移,路径靠下面的 `pub use` 保住),
|
||||
//! 本文件只留列表/增删/句柄/测试缝——原文件正好 400 行零余量,测试缝换身份前必须先拆。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_core::auth::account::Credentials;
|
||||
use unlucky_core::auth::microsoft::{now_ms, BrowserFallbackReason};
|
||||
use unlucky_core::auth::offline::offline_meta;
|
||||
use unlucky_core::auth::{AccountStore, AuthError};
|
||||
|
||||
use crate::dto::CoreEvent;
|
||||
use crate::dto::{
|
||||
valid_offline_name, AccountKind, AccountNotice, AccountNoticeCode, AccountView,
|
||||
BrowserLoginNote, LoginFailure,
|
||||
};
|
||||
|
||||
use super::env::HostEnv;
|
||||
|
||||
/// 第三方轨(服务发现 / 密码登录 / 皮肤抓取)。
|
||||
pub mod authlib;
|
||||
|
||||
mod login;
|
||||
|
||||
mod refresh;
|
||||
|
||||
mod failure;
|
||||
|
||||
pub use failure::AccountFailure;
|
||||
|
||||
pub use login::{start_browser_login, start_login};
|
||||
|
||||
pub use authlib::{fetch_skin, spawn_probe, start_login as start_authlib_login};
|
||||
|
||||
use failure::classify;
|
||||
|
||||
impl super::CoreHost {
|
||||
/// 推一条账户页横幅(一次性操作结果;页面渲染后由用户关掉)。
|
||||
pub(super) fn notice(&mut self, code: AccountNoticeCode, subject: &str, detail: &str) {
|
||||
self.send(CoreEvent::AccountNoticeChanged(Some(AccountNotice::new(
|
||||
code, subject, detail,
|
||||
))));
|
||||
}
|
||||
|
||||
/// 账户写操作失败:**原文进日志**,页面只拿结构化码 + 主题名。
|
||||
pub(super) fn notice_failure(&mut self, failure: AccountFailure, subject: &str) {
|
||||
eprintln!(
|
||||
"[accounts] 操作失败({},subject={subject}):{}",
|
||||
failure.code.code(),
|
||||
failure.detail
|
||||
);
|
||||
self.notice(failure.code, subject, &failure.detail);
|
||||
}
|
||||
}
|
||||
|
||||
/// 账户列表(公开字段)。
|
||||
pub fn accounts(env: &HostEnv, active: Option<&str>) -> Vec<AccountView> {
|
||||
let store = AccountStore::open(env.layout.auth_data());
|
||||
store
|
||||
.accounts()
|
||||
.iter()
|
||||
.map(|meta| AccountView {
|
||||
id: meta.id.as_str().to_string(),
|
||||
name: meta.display_name().to_string(),
|
||||
kind: AccountKind::parse(meta.kind.as_str()),
|
||||
profile_id: meta.profile_id.clone(),
|
||||
login_name: meta.login_name.clone().unwrap_or_default(),
|
||||
created: display_ms(meta.created_at_ms),
|
||||
active: active.is_some_and(|id| id == meta.id.as_str()),
|
||||
server_url: meta.server_url.clone().unwrap_or_default(),
|
||||
// 皮肤是网络产物:列表先给 `None`,host 抓到之后按账户 id 盖回去(见 `CoreHost`)
|
||||
skin: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 毫秒时间戳 → `2026-10-31 20:12`(UTC;账户列表只用来排序/辨认)。
|
||||
pub fn display_ms(ms: u64) -> String {
|
||||
if ms == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let total_secs = ms / 1000;
|
||||
let days = total_secs / 86_400;
|
||||
let rem = total_secs % 86_400;
|
||||
let (year, month, day) = civil_from_days(i64::try_from(days).unwrap_or(0));
|
||||
format!(
|
||||
"{year:04}-{month:02}-{day:02} {:02}:{:02}",
|
||||
rem / 3_600,
|
||||
(rem % 3_600) / 60
|
||||
)
|
||||
}
|
||||
|
||||
/// Howard Hinnant 的 `civil_from_days`(1970-01-01 为 0)。
|
||||
fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
(if m <= 2 { y + 1 } else { y }, m as u32, d as u32)
|
||||
}
|
||||
|
||||
/// 加一个离线账户。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 名字不合法、同名冲突、落盘失败时返回 [`AccountFailure`](带结构化码)。
|
||||
pub fn add_offline(env: &HostEnv, name: &str) -> Result<String, AccountFailure> {
|
||||
let name = name.trim();
|
||||
if !valid_offline_name(name) {
|
||||
return Err(AccountFailure {
|
||||
code: AccountNoticeCode::OfflineNameInvalid,
|
||||
detail: format!("离线名字必须是 3–16 位字母/数字/下划线,收到 {name:?}"),
|
||||
});
|
||||
}
|
||||
let meta = offline_meta(name, now_ms())
|
||||
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::OfflineNameInvalid))?;
|
||||
let display = meta.display_name().to_string();
|
||||
let mut store = AccountStore::open(env.layout.auth_data());
|
||||
store
|
||||
.insert(meta, Credentials::Offline)
|
||||
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::AccountExists))?;
|
||||
store
|
||||
.save()
|
||||
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::StoreFailed))?;
|
||||
Ok(display)
|
||||
}
|
||||
|
||||
/// 删除账户(按 id 或名字)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 找不到、落盘失败时返回 [`AccountFailure`]。
|
||||
pub fn remove(env: &HostEnv, key: &str) -> Result<String, AccountFailure> {
|
||||
let mut store = AccountStore::open(env.layout.auth_data());
|
||||
let meta = store
|
||||
.remove(key)
|
||||
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::NotFound))?;
|
||||
store
|
||||
.save()
|
||||
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::StoreFailed))?;
|
||||
Ok(meta.display_name().to_string())
|
||||
}
|
||||
|
||||
/// 微软登录任务句柄(取消标志 + 账户名回执;两条流共用)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoginJob {
|
||||
/// 用户点了取消(线程收尾时据此丢弃结果)
|
||||
pub cancelled: Arc<AtomicBool>,
|
||||
/// 测试缝的**稳定身份**(见 `test_seam`):进程内单调递增、永不复用。
|
||||
///
|
||||
/// 只在测试构建里存在——发布产物不需要它。它会存在,是因为「这条记录属于哪个 job」
|
||||
/// 不能用堆地址回答:地址是会被复用的资源,不是身份(M236 的 ABA 假红)。
|
||||
#[cfg(test)]
|
||||
id: JobId,
|
||||
}
|
||||
|
||||
impl Default for LoginJob {
|
||||
/// 与 [`LoginJob::new`] 同一个东西:`id` 不许走 `Default` 的零值,否则两个 job 撞身份。
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl LoginJob {
|
||||
/// 新建一个未取消的句柄。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cancelled: Arc::new(AtomicBool::new(false)),
|
||||
#[cfg(test)]
|
||||
id: JobId::next(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消。
|
||||
pub fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 是否已取消。
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancelled.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`LoginJob`] 的稳定身份:进程内单调递增,**永不复用**。
|
||||
///
|
||||
/// 为什么不用 `Arc::as_ptr(&job.cancelled)`:job 一 drop,它的控制块就可以被下一个
|
||||
/// `LoginJob` 复用,于是「新 job」会读到「旧 job」记下的模式(ABA)——并行跑的用例因此
|
||||
/// 偶发假红(实测形态:设备码入口读到别人的 `browser_first=true`)。
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct JobId(u64);
|
||||
|
||||
#[cfg(test)]
|
||||
impl JobId {
|
||||
/// 取下一个身份。`Relaxed` 够用:只要求唯一与单调,不要求跨线程的可见性顺序。
|
||||
fn next() -> Self {
|
||||
use std::sync::atomic::AtomicU64;
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(1);
|
||||
Self(NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
}
|
||||
|
||||
/// 回退原因码 → 页面说明码(两边各留一个枚举:presenter 的 DTO 不依赖 core 的中文诊断)。
|
||||
fn browser_note(reason: BrowserFallbackReason) -> BrowserLoginNote {
|
||||
match reason {
|
||||
BrowserFallbackReason::RedirectNotRegistered => BrowserLoginNote::RedirectNotRegistered,
|
||||
BrowserFallbackReason::PreflightUnavailable => BrowserLoginNote::PreflightUnavailable,
|
||||
BrowserFallbackReason::AttemptFailed => BrowserLoginNote::AttemptFailed,
|
||||
}
|
||||
}
|
||||
|
||||
/// 微软登录的两个入口(决定 `MicrosoftAuth::browser_first`,M178)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum MsaEntry {
|
||||
/// 默认路:设备码。**一个监听口都不开**,也永远不做回环预检。
|
||||
DeviceCode,
|
||||
/// 用户显式点了「用浏览器登录」:浏览器优先(预检 → 授权码 → 失败自动回退设备码)。
|
||||
Browser,
|
||||
}
|
||||
|
||||
impl MsaEntry {
|
||||
/// 这个入口要不要把 `browser_first` 置位。
|
||||
const fn browser_first(self) -> bool {
|
||||
matches!(self, Self::Browser)
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试缝:记下生产入口**实际请求**的模式,让守卫能钉住「按钮 → browser_first=true」这条线。
|
||||
///
|
||||
/// 为什么需要它:两条入口在没有 client id 的测试构建里都会在网络之前早退,拿不到
|
||||
/// `MicrosoftAuth` 实例去问 `browser_first()`;而 P1-1 的 bug 恰恰在**调用点**。这里在被调用
|
||||
/// 的一刻就把模式记下来,把调用点变成可断言的事实。
|
||||
///
|
||||
/// 为什么按 job 分桶:同一进程里别的用例也在调这两个入口,一个全局「最后一次」记录会串台
|
||||
/// (实测过:并行跑时读到别人的 Browser ⇒ 假红)。
|
||||
///
|
||||
/// **身份是 [`JobId`],不是堆地址**(M236):`Arc::as_ptr` 那种做法会被地址复用骗到——job A
|
||||
/// drop 之后控制块立刻分给 job B,B 于是读到 A 的记录(ABA 假红)。
|
||||
#[cfg(test)]
|
||||
mod test_seam {
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
use super::{JobId, LoginJob, MsaEntry};
|
||||
|
||||
/// 表里的一条记录。
|
||||
///
|
||||
/// `alive` 只用来回收「job 已经 drop」的条目,**不参与身份判定**(身份是 `job`)。它同时把
|
||||
/// 控制块钉住:`Weak` 还在时控制块不会释放,`strong_count() == 0` 因此读得准。
|
||||
pub(super) struct Entry {
|
||||
job: JobId,
|
||||
alive: Weak<AtomicBool>,
|
||||
browser_first: bool,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
pub(super) fn new(job: JobId, cancelled: &Arc<AtomicBool>, browser_first: bool) -> Self {
|
||||
Self {
|
||||
job,
|
||||
alive: Arc::downgrade(cancelled),
|
||||
browser_first,
|
||||
}
|
||||
}
|
||||
|
||||
/// job 还活着吗(`false` ⇒ 这条只是还没被收走的死条目)。
|
||||
pub(super) fn is_alive(&self) -> bool {
|
||||
self.alive.strong_count() > 0
|
||||
}
|
||||
|
||||
pub(super) fn job(&self) -> JobId {
|
||||
self.job
|
||||
}
|
||||
|
||||
pub(super) fn browser_first(&self) -> bool {
|
||||
self.browser_first
|
||||
}
|
||||
}
|
||||
|
||||
static ENTRIES: Mutex<Vec<Entry>> = Mutex::new(Vec::new());
|
||||
|
||||
/// 写一条记录:**同一个 job 只留最后一次**,顺手收走已 drop 的 job。
|
||||
///
|
||||
/// 「多条时取第一条」的歧义在这里被消掉:表里每个 job 恒为一条,所以 [`mode_for`] 取到的
|
||||
/// 那条就是它最后一次请求的模式。收死条目的 `retain` 也在这里——表只随活 job 增长。
|
||||
pub(super) fn remember(entries: &mut Vec<Entry>, entry: Entry) {
|
||||
entries.retain(Entry::is_alive);
|
||||
match entries.iter_mut().find(|known| known.job == entry.job) {
|
||||
Some(slot) => slot.browser_first = entry.browser_first,
|
||||
None => entries.push(entry),
|
||||
}
|
||||
}
|
||||
|
||||
/// 生产入口被调用时记一笔。
|
||||
pub(super) fn record(job: &LoginJob, entry: MsaEntry) {
|
||||
let mut entries = ENTRIES.lock().unwrap_or_else(|error| error.into_inner());
|
||||
remember(
|
||||
&mut entries,
|
||||
Entry::new(job.id, &job.cancelled, entry.browser_first()),
|
||||
);
|
||||
}
|
||||
|
||||
/// 这个 job 的生产构造请求的模式(`None` = 还没调用过)。
|
||||
pub(super) fn mode_for(job: &LoginJob) -> Option<bool> {
|
||||
let entries = ENTRIES.lock().unwrap_or_else(|error| error.into_inner());
|
||||
entries
|
||||
.iter()
|
||||
.find(|known| known.job == job.id)
|
||||
.map(Entry::browser_first)
|
||||
}
|
||||
|
||||
/// 造一个「共用同一个取消槽、但确定是另一个 job」的句柄(ABA 用例的构造件)。
|
||||
///
|
||||
/// 两个不同 job 指向同一个控制块:这正是 `Arc::as_ptr` 做 key 时无法区分的情形。
|
||||
pub(super) fn job_around(cancelled: &Arc<AtomicBool>) -> LoginJob {
|
||||
LoginJob {
|
||||
cancelled: Arc::clone(cancelled),
|
||||
id: JobId::next(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `msa_auth` 失败 → 原因码。
|
||||
///
|
||||
/// **只有「两处都没配 client id」单独成码**(M132):`from_env` 的 `Unsupported` 是它唯一的
|
||||
/// 产出点,且这条路径零网络。上屏的正文是 catalog 里的固定文案(原文照旧只进日志),理由是
|
||||
/// 这一类的原因就是用户的下一步(设 `ULL_MSA_CLIENT_ID`);其余失败仍走 [`classify`]。
|
||||
fn auth_failure(error: &AuthError) -> LoginFailure {
|
||||
match error {
|
||||
AuthError::Unsupported(_) => LoginFailure::MsaNotConfigured,
|
||||
other => classify(other),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,337 @@
|
||||
//! 第三方轨(authlib-injector):服务发现校验、密码登录、皮肤原图抓取。
|
||||
//!
|
||||
//! 全部跑在后台线程里(网络),出口只有 [`AuthlibLoginView`] / [`SkinImage`]——
|
||||
//! 密码与 accessToken 在调用栈里就地丢弃,永不进 DTO 回程(M114 凭据红线)。
|
||||
//!
|
||||
//! ## 三条流的坐标
|
||||
//!
|
||||
//! | 动作 | 端点 | 产出 |
|
||||
//! |---|---|---|
|
||||
//! | 服务发现(「校验」按钮) | `GET {apiRoot}` | 服务器名 / skinDomains(`meta` 缺失即判「不是认证服务器」) |
|
||||
//! | 登录 | `POST {apiRoot}authserver/authenticate` | `AuthlibSession`(入库,不外传) |
|
||||
//! | 皮肤 | `GET {apiRoot}sessionserver/session/minecraft/profile/{uuid}` → textures → 皮肤 URL | 皮肤 PNG 原图 |
|
||||
//!
|
||||
//! `core::download::http::build_client` 是唯一的 HTTP 基座(复用下载器的客户端配置),
|
||||
//! 所以本 crate **不新增任何依赖**(`presenter-no-ui` 与依赖最小化同时成立)。
|
||||
|
||||
use unlucky_core::auth::account::{AccountKind as CoreAccountKind, AccountMeta, Credentials};
|
||||
use unlucky_core::auth::authlib::{
|
||||
normalize_api_root, AuthlibAuth, AuthlibServerMeta, AuthlibSession,
|
||||
};
|
||||
use unlucky_core::auth::microsoft::now_ms;
|
||||
use unlucky_core::auth::{AccountStore, Secret};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
|
||||
use crate::dto::{AuthlibLoginView, AuthlibRequest, LoginFailure, SkinImage};
|
||||
|
||||
mod util;
|
||||
|
||||
pub use util::scrub_known;
|
||||
|
||||
use util::{base64_decode, client_token, get_bytes, get_text};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::super::job::{spawn, HostTask, JobSender};
|
||||
use super::LoginJob;
|
||||
|
||||
/// 校验认证服务器地址(「校验」按钮的后端)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 地址不合法、网络不可达、HTTP 非 2xx、响应不是 authlib-injector 的服务发现 JSON 时,
|
||||
/// 返回**具名**的用户可读文案(这是任务要求的「失败态 + 具名错误」)。
|
||||
pub fn probe_authlib(env: &HostEnv, input: &str) -> Result<(String, AuthlibServerMeta), Failure> {
|
||||
let api_root = normalize_api_root(input)
|
||||
.map_err(|error| Failure::new(LoginFailure::Other, error.to_string()))?;
|
||||
let (status, text) =
|
||||
get_text(env, &api_root).map_err(|error| Failure::new(LoginFailure::Unreachable, error))?;
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(Failure::new(
|
||||
LoginFailure::NotAuthlibServer,
|
||||
format!("服务器返回 HTTP {status}({api_root} 不像可用的 authlib 认证服务器)"),
|
||||
));
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(&text).map_err(|_| {
|
||||
Failure::new(
|
||||
LoginFailure::NotAuthlibServer,
|
||||
format!("响应不是 JSON:{api_root} —— 可能填成了网页地址,而不是 API 根(通常以 /api/yggdrasil/ 结尾)"),
|
||||
)
|
||||
})?;
|
||||
if value.get("meta").is_none() && value.get("skinDomains").is_none() {
|
||||
return Err(Failure::new(
|
||||
LoginFailure::NotAuthlibServer,
|
||||
"响应里没有 meta / skinDomains:这看起来不是 authlib-injector 服务器",
|
||||
));
|
||||
}
|
||||
let meta = AuthlibServerMeta::from_value(&value);
|
||||
Ok((api_root, meta))
|
||||
}
|
||||
|
||||
/// 第三方轨的失败:**原因码 + 原文**(原文进日志,页面据码出 i18n)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Failure {
|
||||
/// 原因码
|
||||
pub code: LoginFailure,
|
||||
/// 原始诊断(已脱敏)
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl Failure {
|
||||
fn new(code: LoginFailure, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `AuthError` → 原因码(只看变体)。
|
||||
fn from_error(error: &unlucky_core::auth::AuthError) -> Self {
|
||||
use unlucky_core::auth::AuthError;
|
||||
let code = match error {
|
||||
AuthError::Network { .. } | AuthError::Timeout { .. } => LoginFailure::Unreachable,
|
||||
AuthError::Denied { .. } => LoginFailure::BadCredentials,
|
||||
AuthError::Store { .. } => LoginFailure::StoreFailed,
|
||||
_ => LoginFailure::Other,
|
||||
};
|
||||
Self::new(code, error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 起一次第三方轨登录(后台线程)。
|
||||
pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob, request: AuthlibRequest) {
|
||||
spawn("authlib-login", move || {
|
||||
let mut request = request;
|
||||
// 日志只打脱敏形态;密码在下面用完立刻丢弃。
|
||||
eprintln!("[auth] authlib 登录开始({})", request.redacted());
|
||||
stage(&tx, &request.server, "正在校验服务器地址…");
|
||||
|
||||
let (api_root, meta) = match probe_authlib(&env, &request.server) {
|
||||
Ok(found) => found,
|
||||
Err(failure) => return fail(&tx, failure),
|
||||
};
|
||||
let server_name = meta
|
||||
.name
|
||||
.clone()
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or_else(|| api_root.clone());
|
||||
stage(
|
||||
&tx,
|
||||
&api_root,
|
||||
&format!("服务器 {server_name} 可用,正在登录…"),
|
||||
);
|
||||
|
||||
let auth = match AuthlibAuth::new() {
|
||||
Ok(auth) => auth,
|
||||
Err(error) => return fail(&tx, Failure::new(LoginFailure::Other, error.to_string())),
|
||||
};
|
||||
let client_token = Secret::new(client_token());
|
||||
let password_text = std::mem::take(&mut request.password);
|
||||
let password = Secret::new(password_text.clone());
|
||||
let login_name = request.login.clone();
|
||||
let result = block_on(auth.authenticate(&api_root, &login_name, &password, &client_token));
|
||||
drop(password);
|
||||
|
||||
if job.is_cancelled() {
|
||||
let _ = tx.send(HostTask::Authlib(Box::default()));
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Ok(session) => match store(&env, session, &api_root, &login_name) {
|
||||
Ok(account) => {
|
||||
let _ = tx.send(HostTask::Authlib(Box::new(AuthlibLoginView::succeeded(
|
||||
&server_name,
|
||||
&account,
|
||||
))));
|
||||
let _ = tx.send(HostTask::Accounts(super::accounts(
|
||||
&env,
|
||||
Some(account.as_str()),
|
||||
)));
|
||||
}
|
||||
Err(failure) => fail(&tx, failure),
|
||||
},
|
||||
// 错误原文里可能有服务端回显的凭据:先按**形状**脱敏(`AuthlibLoginView::failed`),
|
||||
// 再按**已知值**兜一遍(万一服务端把密码原样回显)。
|
||||
Err(error) => {
|
||||
let failure = Failure::from_error(&error);
|
||||
fail(
|
||||
&tx,
|
||||
Failure::new(failure.code, scrub_known(&failure.detail, &password_text)),
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(password_text);
|
||||
});
|
||||
}
|
||||
|
||||
/// 只校验服务器地址(「校验服务器」按钮);结果走同一条 `AuthlibLoginChanged`。
|
||||
pub fn spawn_probe(env: HostEnv, tx: JobSender, server: String) {
|
||||
spawn("authlib-probe", move || {
|
||||
stage(&tx, &server, "正在校验服务器地址…");
|
||||
match probe_authlib(&env, &server) {
|
||||
Ok((api_root, meta)) => {
|
||||
let name = meta
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map_or_else(|| api_root.clone(), str::to_string);
|
||||
let _ = tx.send(HostTask::Authlib(Box::new(AuthlibLoginView::reachable(
|
||||
&api_root, &name,
|
||||
))));
|
||||
}
|
||||
Err(failure) => fail(&tx, failure),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 抓第三方轨账户的皮肤原图(`profile_id` → textures → 皮肤 URL → PNG 字节)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 服务器不可达、响应缺 textures、纹理不是 PNG 时返回原因(调用方按「没有皮肤」处理)。
|
||||
pub fn fetch_skin(env: &HostEnv, api_root: &str, profile_id: &str) -> Result<SkinImage, String> {
|
||||
let base = normalize_api_root(api_root).map_err(|error| error.to_string())?;
|
||||
let url = format!("{base}sessionserver/session/minecraft/profile/{profile_id}");
|
||||
let (status, text) = get_text(env, &url)?;
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(format!("皮肤接口返回 HTTP {status}"));
|
||||
}
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&text).map_err(|error| format!("皮肤接口响应不是 JSON:{error}"))?;
|
||||
let encoded = value
|
||||
.get("properties")
|
||||
.and_then(|properties| properties.as_array())
|
||||
.and_then(|properties| {
|
||||
properties
|
||||
.iter()
|
||||
.find(|entry| entry.get("name").and_then(|name| name.as_str()) == Some("textures"))
|
||||
})
|
||||
.and_then(|entry| entry.get("value"))
|
||||
.and_then(|value| value.as_str())
|
||||
.ok_or_else(|| "档案里没有 textures 属性".to_string())?;
|
||||
let raw = base64_decode(encoded)?;
|
||||
let textures: serde_json::Value =
|
||||
serde_json::from_slice(&raw).map_err(|error| format!("textures 属性解不开:{error}"))?;
|
||||
let skin_url = textures
|
||||
.get("textures")
|
||||
.and_then(|textures| textures.get("SKIN"))
|
||||
.and_then(|skin| skin.get("url"))
|
||||
.and_then(|url| url.as_str())
|
||||
.ok_or_else(|| "textures 里没有 SKIN 条目".to_string())?;
|
||||
let (status, bytes) = get_bytes(env, skin_url)?;
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(format!("皮肤图片返回 HTTP {status}"));
|
||||
}
|
||||
if !bytes.starts_with(b"\x89PNG") {
|
||||
return Err("皮肤图片不是 PNG".to_string());
|
||||
}
|
||||
Ok(SkinImage {
|
||||
png: bytes,
|
||||
source: skin_url.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 入库(元数据 + 凭据分文件);返回展示名。
|
||||
fn store(
|
||||
env: &HostEnv,
|
||||
session: AuthlibSession,
|
||||
api_root: &str,
|
||||
login_name: &str,
|
||||
) -> Result<String, Failure> {
|
||||
let profile = session
|
||||
.selected_profile
|
||||
.clone()
|
||||
.filter(|profile| !profile.name.trim().is_empty() && !profile.id.is_empty())
|
||||
.ok_or_else(|| Failure::new(LoginFailure::NoProfile, "服务器没有返回角色"))?;
|
||||
let mut meta = AccountMeta::new(
|
||||
CoreAccountKind::AuthlibInjector,
|
||||
profile.id,
|
||||
profile.name,
|
||||
now_ms(),
|
||||
);
|
||||
meta.login_name = Some(login_name.to_string());
|
||||
meta.server_url = Some(api_root.to_string());
|
||||
let name = meta.display_name().to_string();
|
||||
let mut account_store = AccountStore::open(env.layout.auth_data());
|
||||
account_store
|
||||
.insert(meta, Credentials::AuthlibInjector(session))
|
||||
.and_then(|()| account_store.save())
|
||||
.map_err(|error| Failure::new(LoginFailure::StoreFailed, error.to_string()))?;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// 发一条「进行中」状态(UI 据此只转圈不误报)。
|
||||
fn stage(tx: &JobSender, server: &str, message: &str) {
|
||||
let _ = tx.send(HostTask::Authlib(Box::new(AuthlibLoginView::running(
|
||||
server, message,
|
||||
))));
|
||||
}
|
||||
|
||||
/// 失败收尾:**先脱敏再上屏**(服务端错误原文里可能带 token)。
|
||||
/// 失败收尾:原文进日志,页面拿**原因码**(复审 r1 P1-1)。
|
||||
fn fail(tx: &JobSender, failure: Failure) {
|
||||
eprintln!(
|
||||
"[auth] authlib 失败({}):{}",
|
||||
failure.code.code(),
|
||||
failure.detail
|
||||
);
|
||||
let _ = tx.send(HostTask::Authlib(Box::new(AuthlibLoginView::failed_as(
|
||||
failure.code,
|
||||
failure.detail,
|
||||
))));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_decodes_textures_payload() {
|
||||
// `{"textures":{}}` 的 base64
|
||||
let encoded = "eyJ0ZXh0dXJlcyI6e319";
|
||||
assert_eq!(base64_decode(encoded).expect("解码"), br#"{"textures":{}}"#);
|
||||
// URL-safe 变体 + 填充 + 换行都要吃
|
||||
assert_eq!(
|
||||
base64_decode("eyJ0ZXh0dXJlcyI6e319\n").expect("解码"),
|
||||
br#"{"textures":{}}"#
|
||||
);
|
||||
assert!(base64_decode("!!!").is_err());
|
||||
assert!(base64_decode("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_token_is_stable_length_and_unique() {
|
||||
let a = client_token();
|
||||
let b = client_token();
|
||||
assert_eq!(a.len(), 32);
|
||||
assert_ne!(
|
||||
a, b,
|
||||
"两次生成的 clientToken 不能相同(会被服务端当成重放)"
|
||||
);
|
||||
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
use crate::dto::REDACTED;
|
||||
|
||||
#[test]
|
||||
fn known_secret_is_scrubbed_from_error_text() {
|
||||
let text = scrub_known("登录失败:hunter2 被拒绝", "hunter2");
|
||||
assert!(!text.contains("hunter2"), "{text}");
|
||||
assert!(text.contains(REDACTED));
|
||||
assert_eq!(scrub_known("普通错误", ""), "普通错误");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_web_page_address_is_rejected_with_a_named_error() {
|
||||
// 不发网络请求:只测「规范化 + 空输入」这条不联网的分支
|
||||
let dir = std::env::temp_dir().join(format!("ull-authlib-probe-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
std::fs::create_dir_all(env.layout.cache()).expect("cache");
|
||||
let failure = probe_authlib(&env, " ").expect_err("空地址必须报错");
|
||||
assert!(failure.detail.contains("不能为空"), "{failure:?}");
|
||||
let failure = probe_authlib(&env, "https://").expect_err("只给协议头也必须报错");
|
||||
assert!(!failure.detail.is_empty());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! authlib 轨的 HTTP 基座与纯函数小工具:`clientToken` 生成、GET 文本/字节、base64 解码、按已知值脱敏。
|
||||
//!
|
||||
//! 从 `authlib.rs` 拆出来守 400 行硬线。网络只走**既有下载引擎**的客户端
|
||||
//! (`host::engine::engine(env).client()`):代理/超时/证书栈与下载器同源,本 crate 不新增依赖。
|
||||
|
||||
use unlucky_core::auth::microsoft::now_ms;
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
|
||||
use crate::dto::REDACTED;
|
||||
|
||||
use super::super::super::engine;
|
||||
use super::super::super::env::HostEnv;
|
||||
|
||||
/// 一次 GET 的超时上限(秒):服务器不可达时要能失败,而不是挂住 UI 的登录面板。
|
||||
pub(super) const HTTP_TIMEOUT_SECS: u64 = 15;
|
||||
|
||||
/// 单次 GET 的响应体上限(字节):用户能填任意 URL,服务发现 JSON/皮肤 PNG 都远小于它。
|
||||
pub(super) const MAX_BODY_BYTES: usize = 512 * 1024;
|
||||
/// base64 解码输出上限(textures 属性是个小 JSON)。
|
||||
pub(super) const MAX_BASE64_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// 启动器侧生成的 `clientToken`(Yggdrasil 要求刷新/校验都带,且服务端回传必须一致)。
|
||||
pub(super) fn client_token() -> String {
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
let mut out = String::with_capacity(32);
|
||||
for salt in 0..2u64 {
|
||||
// `RandomState` 每个实例自带随机种子(标准库给的是进程级随机),不引第三方随机库。
|
||||
let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
|
||||
hasher.write_u64(now_ms() ^ salt);
|
||||
hasher.write_u32(std::process::id());
|
||||
out.push_str(&format!("{:016x}", hasher.finish()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// GET 文本(状态码 + 正文)。
|
||||
///
|
||||
/// 客户端来自**既有的下载引擎**(`host::engine::engine`):代理/超时/证书栈与本仓的下载器
|
||||
/// 完全同源,不在这里另起一套 HTTP 配置(本 crate 也没有 reqwest 依赖,拿到的只是它的 `&Client`)。
|
||||
pub(super) fn get_text(env: &HostEnv, url: &str) -> Result<(u16, String), String> {
|
||||
let engine = engine::engine(env);
|
||||
let client = engine.client();
|
||||
let url = url.to_string();
|
||||
block_on(async move {
|
||||
let reply = client
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("服务器不可达:{error}"))?;
|
||||
let status = reply.status().as_u16();
|
||||
let text = reply
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| format!("读取响应失败:{error}"))?;
|
||||
Ok((status, text))
|
||||
})
|
||||
}
|
||||
|
||||
/// GET 字节(皮肤图片)。
|
||||
pub(super) fn get_bytes(env: &HostEnv, url: &str) -> Result<(u16, Vec<u8>), String> {
|
||||
let engine = engine::engine(env);
|
||||
let client = engine.client();
|
||||
let url = url.to_string();
|
||||
block_on(async move {
|
||||
let reply = client
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("皮肤图片不可达:{error}"))?;
|
||||
let status = reply.status().as_u16();
|
||||
let bytes = reply
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| format!("读取皮肤图片失败:{error}"))?
|
||||
.to_vec();
|
||||
if bytes.len() > MAX_BODY_BYTES {
|
||||
return Err(format!(
|
||||
"图片过大({} 字节,上限 {MAX_BODY_BYTES})",
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
Ok((status, bytes))
|
||||
})
|
||||
}
|
||||
|
||||
/// base64 解码(标准字母表,容忍 URL-safe 变体与缺失填充)。
|
||||
///
|
||||
/// 为什么自己写:textures 属性是 base64 编码的 JSON,而本 crate 不能引第三方解码库
|
||||
/// (依赖最小化);这段是 40 行的确定性算法,配单测比拖一个依赖更划算。
|
||||
pub(super) fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
|
||||
let mut out = Vec::with_capacity(input.len() / 4 * 3);
|
||||
let mut buffer: u32 = 0;
|
||||
let mut bits = 0u32;
|
||||
for byte in input.bytes() {
|
||||
let value = match byte {
|
||||
b'A'..=b'Z' => byte - b'A',
|
||||
b'a'..=b'z' => byte - b'a' + 26,
|
||||
b'0'..=b'9' => byte - b'0' + 52,
|
||||
b'+' | b'-' => 62,
|
||||
b'/' | b'_' => 63,
|
||||
b'=' | b'\n' | b'\r' | b' ' => continue,
|
||||
other => return Err(format!("base64 里有非法字符:{:?}", other as char)),
|
||||
};
|
||||
buffer = (buffer << 6) | u32::from(value);
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push(u8::try_from((buffer >> bits) & 0xFF).unwrap_or(0));
|
||||
}
|
||||
if out.len() > MAX_BASE64_BYTES {
|
||||
return Err(format!("base64 输出超过上限 {MAX_BASE64_BYTES} 字节"));
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err("base64 内容为空".to_string());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 错误文案里可能混进已知凭据值:按值再兜一遍(形状脱敏由 `AuthlibLoginView::failed` 做)。
|
||||
#[must_use]
|
||||
pub fn scrub_known(message: &str, secret: &str) -> String {
|
||||
if secret.is_empty() {
|
||||
return message.to_string();
|
||||
}
|
||||
message.replace(secret, REDACTED)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! 账户写操作的失败分类 + 离线名字契约(M114 r2)。
|
||||
//!
|
||||
//! 分类**只看 `AuthError` 的变体与 ascii 码**,不看中文文案——文案随时会改,
|
||||
//! 而页面据 `AccountNoticeCode` 选 i18n,两边必须能独立演进。
|
||||
|
||||
use unlucky_core::auth::AuthError;
|
||||
|
||||
use unlucky_core::auth::BodyKind;
|
||||
|
||||
use crate::dto::{AccountNoticeCode, LoginFailure};
|
||||
|
||||
/// 账户写操作的失败:**结构化码 + 原文**(原文只进日志/详细信息,不上屏)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountFailure {
|
||||
/// 结果码(页面据此选 i18n)
|
||||
pub code: AccountNoticeCode,
|
||||
/// 原始诊断(已脱敏)
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl AccountFailure {
|
||||
/// 按 core 的错误类型分类(不看中文文案,只看变体)。
|
||||
pub(super) fn classify(error: AuthError, fallback: AccountNoticeCode) -> Self {
|
||||
let code = match &error {
|
||||
unlucky_core::auth::AuthError::Conflict { .. } => AccountNoticeCode::AccountExists,
|
||||
unlucky_core::auth::AuthError::NotFound { .. } => AccountNoticeCode::NotFound,
|
||||
unlucky_core::auth::AuthError::Malformed(_) => AccountNoticeCode::OfflineNameInvalid,
|
||||
unlucky_core::auth::AuthError::Store { .. } => AccountNoticeCode::StoreFailed,
|
||||
_ => fallback,
|
||||
};
|
||||
Self {
|
||||
code,
|
||||
detail: error.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `AuthError` → 原因码(**只看变体与 ascii 码,不看中文文案**)。
|
||||
pub(super) fn classify(error: &AuthError) -> LoginFailure {
|
||||
match error {
|
||||
AuthError::Network { .. } | AuthError::Timeout { .. } => LoginFailure::Unreachable,
|
||||
AuthError::Denied { code, description } => {
|
||||
if mentions_whitelist(code) || mentions_whitelist(description) {
|
||||
LoginFailure::NotWhitelisted
|
||||
} else {
|
||||
LoginFailure::Denied
|
||||
}
|
||||
}
|
||||
// 只看 `ResponseDetail` 的**结构化字段**(status / kind / server_message),
|
||||
// 不把整条错误 Debug 成字符串再去扫——那会把中文 `what`/`digest` 也算进判据里。
|
||||
AuthError::Response(detail) => {
|
||||
let server = detail.server_message.as_deref().unwrap_or_default();
|
||||
let digest = detail.digest.as_str();
|
||||
if mentions_whitelist(server) || mentions_whitelist(digest) {
|
||||
LoginFailure::NotWhitelisted
|
||||
} else if detail.status >= 500 || matches!(detail.kind, BodyKind::Html | BodyKind::Text)
|
||||
{
|
||||
// 5xx / 拦截页:服务器或链路出了问题,不是「它拒绝了你」。
|
||||
LoginFailure::Unreachable
|
||||
} else {
|
||||
LoginFailure::Denied
|
||||
}
|
||||
}
|
||||
AuthError::Xbox { .. } | AuthError::CredentialExpired => LoginFailure::Denied,
|
||||
AuthError::Store { .. } => LoginFailure::StoreFailed,
|
||||
_ => LoginFailure::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否属于「应用注册未获批」这一类(AADSTS7000218 / consent required / whitelist)。
|
||||
pub(super) fn mentions_whitelist(text: &str) -> bool {
|
||||
let lowered = text.to_lowercase();
|
||||
lowered.contains("aadsts7000218")
|
||||
|| lowered.contains("aadsts65001")
|
||||
|| lowered.contains("consent")
|
||||
|| lowered.contains("whitelist")
|
||||
|| lowered.contains("not approved")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_core::auth::body::ResponseDetail;
|
||||
|
||||
fn response(status: u16, kind: BodyKind, server_message: Option<&str>) -> AuthError {
|
||||
AuthError::Response(Box::new(ResponseDetail {
|
||||
step: "xsts".to_string(),
|
||||
what: "XSTS 授权".to_string(),
|
||||
status,
|
||||
content_type: None,
|
||||
kind,
|
||||
server_message: server_message.map(str::to_string),
|
||||
digest: "digest".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 403 带 AADSTS 码 → 白名单未批;403 不带 → 一般性拒绝。
|
||||
#[test]
|
||||
fn aadsts_markers_win_over_the_generic_denial() {
|
||||
assert_eq!(
|
||||
classify(&response(
|
||||
403,
|
||||
BodyKind::Json,
|
||||
Some("AADSTS7000218: app not approved")
|
||||
)),
|
||||
LoginFailure::NotWhitelisted
|
||||
);
|
||||
assert_eq!(
|
||||
classify(&response(403, BodyKind::Json, Some("invalid_grant"))),
|
||||
LoginFailure::Denied
|
||||
);
|
||||
}
|
||||
|
||||
/// 5xx 与「拦截页」(HTML/纯文本)归「连不上」,不是「服务器拒绝了你」。
|
||||
#[test]
|
||||
fn upstream_failures_and_intercept_pages_are_unreachable() {
|
||||
assert_eq!(
|
||||
classify(&response(503, BodyKind::Json, None)),
|
||||
LoginFailure::Unreachable
|
||||
);
|
||||
assert_eq!(
|
||||
classify(&response(200, BodyKind::Html, None)),
|
||||
LoginFailure::Unreachable
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! 微软登录的两条流(设备码默认 / 浏览器优先失败回退设备码)+ 会话入库。
|
||||
//!
|
||||
//! 从 `accounts.rs` 拆出来(M236):父文件顶着 400 行硬线,而本轮要给测试缝换一个**稳定身份**
|
||||
//! (`Arc::as_ptr` 会被堆地址复用骗到,见父模块的 `test_seam`),拆出子模块才有地方落。
|
||||
//!
|
||||
//! **纯搬移**:下面每个函数体、每一行签名都与搬移前逐行相同(没有改一个字的可见性);原来的
|
||||
//! 公开路径靠父模块的 `pub use` 保住(`host::accounts::start_login` / `start_browser_login`)。
|
||||
//!
|
||||
//! 两条流除了「怎么拿到 MS access_token」之外完全共用:同一个取消槽位(父模块的 `LoginJob`)、
|
||||
//! 同一段入库(`store_session`)、同一个收尾(`finish`)——所以它们必须住在同一个文件里。
|
||||
|
||||
use unlucky_core::auth::account::Credentials;
|
||||
use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth, MicrosoftSession};
|
||||
use unlucky_core::auth::{AccountStore, AuthResult};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
|
||||
use crate::dto::{DeviceLoginView, LoginFailure};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::super::job::{spawn, HostTask, JobSender};
|
||||
use super::super::opener;
|
||||
use super::failure::classify;
|
||||
#[cfg(test)]
|
||||
use super::test_seam;
|
||||
use super::{accounts, auth_failure, browser_note, LoginJob, MsaEntry};
|
||||
|
||||
/// 起一次微软登录(M178:**GUI 的默认路**——设备码)。
|
||||
///
|
||||
/// 设备码流不依赖任何应用注册项,是唯一「在官方产物上开箱可用」的路;它**一个本地监听口都不开**,
|
||||
/// 也就不会撞上 `redirect_uri is not valid`(守卫断言 `listeners_opened == 0`,在 bind 现场加)。
|
||||
pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) {
|
||||
spawn("msa-login", move || {
|
||||
let auth = match msa_auth(&env, MsaEntry::DeviceCode, &job) {
|
||||
Ok(auth) => auth,
|
||||
Err(error) => return fail(&tx, auth_failure(&error), error.to_string()),
|
||||
};
|
||||
let tx_prompt = tx.clone();
|
||||
let result = block_on(auth.device_code_login(|prompt| {
|
||||
let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::waiting(
|
||||
&prompt.user_code,
|
||||
&prompt.verification_uri,
|
||||
prompt.expires_in.as_secs(),
|
||||
&format!("{}#{}", prompt.verification_uri, prompt.user_code),
|
||||
))));
|
||||
}));
|
||||
finish(&env, &tx, &job, result);
|
||||
});
|
||||
}
|
||||
|
||||
/// 起一次「浏览器优先、失败自动回退设备码」的登录(M178:可选加速路)。
|
||||
///
|
||||
/// 只在用户**显式要求**时才走到这里(账户页的「用浏览器登录」)。即使如此也不盲试:`core` 先做
|
||||
/// 回环注册预检,没登记就**不绑端口、不弹浏览器**,直接设备码;试了但失败同样回退并给一句说明。
|
||||
pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) {
|
||||
spawn("msa-browser", move || {
|
||||
// **P1-1 的那一行**:这里是「用户点了用浏览器登录」,必须把 browser_first 置位,
|
||||
// 否则下面这段预检/回退编排整段不执行(复审 r1 实测的洞)。
|
||||
let auth = match msa_auth(&env, MsaEntry::Browser, &job) {
|
||||
Ok(auth) => auth,
|
||||
Err(error) => return fail(&tx, auth_failure(&error), error.to_string()),
|
||||
};
|
||||
let tx_browser = tx.clone();
|
||||
let tx_device = tx.clone();
|
||||
let result = block_on(auth.browser_then_device_login(
|
||||
|prompt| {
|
||||
// 浏览器拉不起来不是致命错误:面板里还有可复制的授权链接。
|
||||
let opened = opener::open_url(&prompt.authorization_url);
|
||||
let _ =
|
||||
tx_browser.send(HostTask::Login(Box::new(DeviceLoginView::browser_waiting(
|
||||
&prompt.authorization_url,
|
||||
prompt.timeout.as_secs(),
|
||||
opened.err().as_deref(),
|
||||
))));
|
||||
},
|
||||
|prompt, fallback| {
|
||||
let view = DeviceLoginView::waiting(
|
||||
&prompt.user_code,
|
||||
&prompt.verification_uri,
|
||||
prompt.expires_in.as_secs(),
|
||||
&format!("{}#{}", prompt.verification_uri, prompt.user_code),
|
||||
);
|
||||
let view = match fallback {
|
||||
Some(fallback) => {
|
||||
// 回退原文只进日志;面板只看码。
|
||||
eprintln!(
|
||||
"[auth] 浏览器登录未走成({}),已回退设备码:{}",
|
||||
fallback.reason.code(),
|
||||
fallback.detail
|
||||
);
|
||||
view.with_browser_note(browser_note(fallback.reason))
|
||||
}
|
||||
None => view,
|
||||
};
|
||||
let _ = tx_device.send(HostTask::Login(Box::new(view)));
|
||||
},
|
||||
));
|
||||
finish(&env, &tx, &job, result.map(|outcome| outcome.session));
|
||||
});
|
||||
}
|
||||
|
||||
/// 生产入口的 `MicrosoftAuth`(client id 取环境/构建期,失败日志落 `<数据根>/logs/`)。
|
||||
///
|
||||
/// **`browser_first` 必须在这里按入口置位**(复审 r1 P1-1):只构造默认 `MicrosoftAuth` 的话,
|
||||
/// 浏览器按钮会静默退化成设备码——预检/回退码/说明全不可达。
|
||||
fn msa_auth(env: &HostEnv, entry: MsaEntry, _job: &LoginJob) -> AuthResult<MicrosoftAuth> {
|
||||
#[cfg(test)]
|
||||
test_seam::record(_job, entry);
|
||||
MicrosoftAuth::from_env().map(|auth| {
|
||||
auth.with_browser_first(entry.browser_first())
|
||||
.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir(
|
||||
env.layout.logs(),
|
||||
)))
|
||||
})
|
||||
}
|
||||
|
||||
/// 失败收尾(带**原因码**):原文进日志,页面据码出 i18n(复审 r1 P1-1)。
|
||||
fn fail(tx: &JobSender, failure: LoginFailure, detail: impl Into<String>) {
|
||||
let detail = detail.into();
|
||||
eprintln!("[auth] 微软登录失败({}):{detail}", failure.code());
|
||||
let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed_as(
|
||||
failure, detail,
|
||||
))));
|
||||
}
|
||||
|
||||
/// 收尾:取消就吞掉结果;成功入库;失败带 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, LoginFailure::StoreFailed, message),
|
||||
},
|
||||
Err(error) => {
|
||||
let failure = classify(&error);
|
||||
fail(tx, failure, 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)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! 账户列表的刷新与皮肤抓取(`impl CoreHost` 的这一段,从 `host/mod.rs` 拆出来守 400 行)。
|
||||
//!
|
||||
//! 为什么在这里:`host/mod.rs` 在 M109/M113 之后已经贴着 400 行硬线,而这三段是**账户专属**
|
||||
//! 的编排(读账户库 → 盖皮肤 → 起抓取线程)。放回 `host/accounts.rs` 也不行——那个文件同样
|
||||
//! 380 行。归属仍是 accounts(`mod accounts { mod refresh; }`),只是按功能再分一层。
|
||||
|
||||
use crate::dto::{AccountKind, CoreEvent};
|
||||
|
||||
use super::super::{accounts, job, CoreHost, HostTask};
|
||||
|
||||
impl CoreHost {
|
||||
pub(in crate::host) fn refresh_accounts(&mut self) {
|
||||
self.accounts = accounts::accounts(&self.env, self.active_account.as_deref());
|
||||
if self.active_account.is_none() {
|
||||
// 默认选中第一个账户(没有账户时保持 None = 离线占位)
|
||||
self.active_account = self.accounts.first().map(|account| account.id.clone());
|
||||
self.accounts = accounts::accounts(&self.env, self.active_account.as_deref());
|
||||
}
|
||||
self.attach_skins();
|
||||
self.request_skins();
|
||||
self.send(CoreEvent::AccountsChanged(self.accounts.clone()));
|
||||
}
|
||||
|
||||
/// 把已抓到的皮肤盖到账户视图上。
|
||||
///
|
||||
/// 只盖第三方轨:离线档案没有皮肤,微软轨不做真头像(M114 口径)——
|
||||
/// 「没有皮肤就画首字母块」,绝不给假皮肤/白块。
|
||||
pub(in crate::host) fn attach_skins(&mut self) {
|
||||
for account in &mut self.accounts {
|
||||
if account.kind != AccountKind::Authlib {
|
||||
account.skin = None;
|
||||
continue;
|
||||
}
|
||||
if let Some(skin) = self.skins.get(&account.id) {
|
||||
account.skin = skin.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 给还没抓过皮肤的第三方轨账户起后台抓取(每个账户只抓一次)。
|
||||
///
|
||||
/// 抓不到就记 `None`:错误在面板上不显示(皮肤是装饰,不是业务失败),但也不会重试刷屏。
|
||||
fn request_skins(&mut self) {
|
||||
let mut pending = Vec::new();
|
||||
for account in &self.accounts {
|
||||
if account.kind != AccountKind::Authlib || account.server_url.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if self.skin_requested.insert(account.id.clone()) {
|
||||
pending.push((
|
||||
account.id.clone(),
|
||||
account.server_url.clone(),
|
||||
account.profile_id.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
for (id, server, profile) in pending {
|
||||
let tx = self.tx.clone();
|
||||
let env = self.env.clone();
|
||||
job::spawn("account-skin", move || {
|
||||
let skin = accounts::fetch_skin(&env, &server, &profile).ok();
|
||||
let _ = tx.send(HostTask::Avatar { id, skin });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
//! 账户 host 的判据:入库/删除/名字规则、失败分类,以及**缺 client id 的上屏链路**(M132)。
|
||||
//!
|
||||
//! ## 为什么这个文件要跟 `accounts.rs` 分开
|
||||
//!
|
||||
//! `accounts.rs` 顶着 400 行硬线,而 M132 的锚必须**真的走一遍登录入口**(`start_login` /
|
||||
//! `start_browser_login`)才能钉住「入口 → 原因码」那一行——只测 `auth_failure` 的话,把调用点
|
||||
//! 改回 `LoginFailure::Other` 仍然全绿(复审 r1 P2-1 实测)。测试拆出来是为了留出这段空间。
|
||||
//!
|
||||
//! 拆法照抄仓库既有先例(`dto/accounts.rs`、`host/install.rs` 等):父文件里留
|
||||
//! `#[cfg(test)] mod tests;`,本文件 `use super::*;` 照旧拿到父模块的一切。
|
||||
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use unlucky_core::auth::microsoft::{client_id_status, CLIENT_ID_ENV};
|
||||
use unlucky_core::auth::AuthError;
|
||||
|
||||
use super::*;
|
||||
|
||||
use super::super::job::{HostTask, JobSender};
|
||||
use super::failure::mentions_whitelist;
|
||||
|
||||
#[test]
|
||||
fn time_display_matches_known_timestamps() {
|
||||
assert_eq!(display_ms(0), "");
|
||||
// 1_769_000_000_000 ms = 2026-01-21T12:53:20Z(取整到分钟)
|
||||
assert_eq!(display_ms(1_769_000_000_000), "2026-01-21 12:53");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_add_list_remove_round_trips() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-accounts-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
std::fs::create_dir_all(env.layout.auth_data()).expect("auth 目录");
|
||||
|
||||
let name = add_offline(&env, "Steve").expect("加账户");
|
||||
assert_eq!(name, "Steve");
|
||||
let list = accounts(&env, None);
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].kind, AccountKind::Offline);
|
||||
assert!(!list[0].active);
|
||||
|
||||
let active = accounts(&env, Some(list[0].id.as_str()));
|
||||
assert!(active[0].active);
|
||||
|
||||
let removed = remove(&env, "Steve").expect("删账户");
|
||||
assert_eq!(removed, "Steve");
|
||||
assert!(accounts(&env, None).is_empty());
|
||||
assert!(add_offline(&env, " ").is_err());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
fn denied(code: &str) -> AuthError {
|
||||
AuthError::Denied {
|
||||
code: code.to_string(),
|
||||
description: "bad".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn network() -> AuthError {
|
||||
AuthError::Network {
|
||||
url: "https://x".to_string(),
|
||||
detail: "dns".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_codes_are_classified_from_variants_not_chinese_text() {
|
||||
assert_eq!(classify(&network()), LoginFailure::Unreachable);
|
||||
let whitelist = classify(&denied("AADSTS7000218"));
|
||||
assert_eq!(whitelist, LoginFailure::NotWhitelisted);
|
||||
assert_eq!(classify(&denied("invalid_grant")), LoginFailure::Denied);
|
||||
assert_eq!(classify(&AuthError::Canceled), LoginFailure::Other);
|
||||
assert!(mentions_whitelist("AADSTS7000218"));
|
||||
assert!(!mentions_whitelist("invalid_grant"));
|
||||
}
|
||||
|
||||
/// **M132**:只有「缺 client id」变成「需要配置」,网络/凭据失败不许跟着变(否则成噪音)。
|
||||
#[test]
|
||||
fn only_a_missing_client_id_becomes_the_config_gap_code() {
|
||||
let missing = auth_failure(&AuthError::Unsupported(String::new()));
|
||||
assert_eq!(missing, LoginFailure::MsaNotConfigured);
|
||||
assert_eq!(auth_failure(&network()), LoginFailure::Unreachable);
|
||||
assert_eq!(auth_failure(&denied("invalid_grant")), LoginFailure::Denied);
|
||||
}
|
||||
|
||||
/// **M132 的入口锚(复审 r1 P2-1)**:缺 client id 时,**两条登录流**回给页面的那条事件里
|
||||
/// 必须带 `MsaNotConfigured`。
|
||||
///
|
||||
/// 抓得住(实测过的变异):把 `start_login` / `start_browser_login` 里的
|
||||
/// `auth_failure(&error)` 改回 `LoginFailure::Other`——那是页面退回「完整原因在启动器日志里」
|
||||
/// 的唯一开关,而 `auth_failure` 自己的单测对它一声不响。
|
||||
/// 零网络:`MicrosoftAuth::from_env` 在 id 判定处就地早退,连 HTTP 客户端都不建,所以这里
|
||||
/// 不需要任何桩,也不会因为没网而红。
|
||||
///
|
||||
/// **它要求一个「两处都没有 client id」的构建**:构建期烘焙了 id(`option_env!`)的构建里它
|
||||
/// `panic!` 而不是跳过——跳过就等于让这条锚悄悄失效(r2 复审实测过那种形态),红着喊人才
|
||||
/// 逼得人做决定(不带 id 构建,或给 `resolve_client_id` 加接缝后重写这条锚)。
|
||||
#[test]
|
||||
fn a_missing_client_id_reaches_the_login_panel_as_a_config_gap() {
|
||||
// 环境变量是**进程级**的:动它的用例必须串行(core 侧同一套写法),否则并行跑互相污染。
|
||||
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let saved = std::env::var(CLIENT_ID_ENV).ok();
|
||||
std::env::remove_var(CLIENT_ID_ENV);
|
||||
// panic 也要还原:不还原的话后面所有用例都会在一个「没配 id」的进程里跑。
|
||||
struct Restore(Option<String>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
Some(value) => std::env::set_var(CLIENT_ID_ENV, value),
|
||||
None => std::env::remove_var(CLIENT_ID_ENV),
|
||||
}
|
||||
}
|
||||
}
|
||||
let _restore = Restore(saved);
|
||||
|
||||
// 环境变量已经摘掉了,此时若仍报告「已配置」,来源只可能是构建期注入
|
||||
// (`ULL_MSA_CLIENT_ID=<id> cargo build` ⇒ `option_env!`),或者 `resolve_client_id` 的
|
||||
// 实现变了。**这种构建里必须红**:这条锚验证不了缺 id 路径,静默 `return` 就是让锚悄悄失效
|
||||
// (r2 复审实测:注入 id + 同一变异 → 全绿 22 passed,正是塔台最怕的「守门空跑」形态)。
|
||||
if let Some(source) = client_id_status().source() {
|
||||
panic!(
|
||||
"这条锚要求「两处都没有 client id」的构建,但二进制里 client id 已被报告为已配置\
|
||||
(source={source:?},环境变量 {CLIENT_ID_ENV} 已摘掉)。要么不带 \
|
||||
ULL_MSA_CLIENT_ID 构建,要么给 `unlucky_core::auth::microsoft::resolve_client_id` \
|
||||
加一个可注入的接缝,再按接缝重写这条锚。"
|
||||
);
|
||||
}
|
||||
|
||||
assert_config_gap("device", start_login);
|
||||
assert_config_gap("authcode", start_browser_login);
|
||||
}
|
||||
|
||||
/// 起一条登录流、等它回的第一条登录事件,并断言那是一条「缺配置」而不是通用失败。
|
||||
///
|
||||
/// 缺 id 时这条路径**零网络**(`from_env` 在 id 判定处早退),所以事件立刻到,不需要桩。
|
||||
fn assert_config_gap(name: &str, start: fn(HostEnv, JobSender, LoginJob)) {
|
||||
let dir = std::env::temp_dir().join(format!("ull-msa-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (tx, rx) = super::super::job::channel();
|
||||
start(HostEnv::at_root(&dir), tx, LoginJob::new());
|
||||
let view = match rx.recv_timeout(Duration::from_secs(10)) {
|
||||
Ok(HostTask::Login(view)) => view,
|
||||
Ok(_) => panic!("{name}:缺 id 时第一条事件就该是登录失败"),
|
||||
Err(error) => panic!("{name}:等不到登录事件({error})"),
|
||||
};
|
||||
assert_eq!(view.failure, Some(LoginFailure::MsaNotConfigured), "{name}");
|
||||
// 原文(含要设的变量名)仍然只在 `message` 里:它只进日志,页面画不出来。
|
||||
assert!(
|
||||
view.message.contains(CLIENT_ID_ENV),
|
||||
"{name}: {}",
|
||||
view.message
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_name_rule_matches_the_ui_contract() {
|
||||
assert!(valid_offline_name("Steve"));
|
||||
assert!(valid_offline_name("a_1"));
|
||||
assert!(!valid_offline_name("ab"), "太短");
|
||||
assert!(!valid_offline_name("中文名字"), "非 ascii");
|
||||
assert!(!valid_offline_name("has space"));
|
||||
assert!(!valid_offline_name("x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_offline_name_is_classified_not_stringified() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-name-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
std::fs::create_dir_all(env.layout.auth_data()).expect("auth 目录");
|
||||
let failure = add_offline(&env, "ab").expect_err("太短要报错");
|
||||
assert_eq!(failure.code, AccountNoticeCode::OfflineNameInvalid);
|
||||
assert!(!failure.detail.is_empty());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_job_cancel_flag_is_shared() {
|
||||
let job = LoginJob::new();
|
||||
let clone = job.clone();
|
||||
assert!(!clone.is_cancelled());
|
||||
job.cancel();
|
||||
assert!(clone.is_cancelled());
|
||||
}
|
||||
|
||||
/// **M178 复审 r1 P1-1 的守卫**:生产路径上,「用浏览器登录」这颗按钮必须真的把
|
||||
/// `browser_first` 置位,设备码那颗必须不置位。
|
||||
///
|
||||
/// 修复前实测过的形态:`start_browser_login` 用共享的 `microsoft_auth(&env)`(默认
|
||||
/// `browser_first=false`),于是 `browser_then_device_login` 里整段预检/回退编排不执行——
|
||||
/// 按钮静默退化成设备码、连一句说明都不出,`preflight` / `BrowserFallbackReason` /
|
||||
/// `BrowserLoginNote` / 三条 i18n 文案全成不可达代码。
|
||||
///
|
||||
/// 抓得住(本用例就是为此写的):把 `msa_auth(&env, MsaEntry::Browser)` 改回
|
||||
/// `MsaEntry::DeviceCode`(或删掉 `.with_browser_first(...)` 那一跳以外的模式选择),
|
||||
/// 记录会变成 `false` ⇒ 红。
|
||||
/// 抓不住:真的去连微软(无 client id 的测试构建里两条入口都在网络之前早退)。
|
||||
#[test]
|
||||
fn the_browser_button_actually_turns_on_browser_first() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-msa-entry-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
std::fs::create_dir_all(env.layout.auth_data()).expect("auth 目录");
|
||||
|
||||
// 设备码入口:不许碰浏览器(也不许碰回环预检)。
|
||||
let device_job = LoginJob::new();
|
||||
let (tx, _rx) = super::super::job::channel();
|
||||
start_login(env.clone(), tx, device_job.clone());
|
||||
assert_eq!(
|
||||
wait_for_entry(&device_job),
|
||||
Some(false),
|
||||
"设备码入口把 browser_first 置成了 true:默认路会去开监听口/弹浏览器"
|
||||
);
|
||||
|
||||
// 浏览器入口:**必须**置位,否则预检与回退整段不可达。
|
||||
let browser_job = LoginJob::new();
|
||||
let (tx, _rx) = super::super::job::channel();
|
||||
start_browser_login(env, tx, browser_job.clone());
|
||||
assert_eq!(
|
||||
wait_for_entry(&browser_job),
|
||||
Some(true),
|
||||
"「用浏览器登录」没有置位 browser_first:按钮会静默退化成设备码(复审 r1 P1-1 的洞)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 等登录线程把「这个 job 的入口模式」记下来(线程是 `spawn` 出去的,最多等这么久)。
|
||||
///
|
||||
/// 按 job 分桶而不是读「最后一次」:同一进程里别的用例也在调这两个入口(实测会串台假红)。
|
||||
fn wait_for_entry(job: &LoginJob) -> Option<bool> {
|
||||
for _ in 0..200 {
|
||||
if let Some(flag) = super::test_seam::mode_for(job) {
|
||||
return Some(flag);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// **M236(台账「真开着的测试债」之一):测试缝的身份不许是会复用的堆地址。**
|
||||
///
|
||||
/// 构造:两个**不同**的 `LoginJob` 共用同一个 `cancelled` 控制块地址(`Arc::clone` 不换地址,
|
||||
/// `job_around` 各自给一个新身份)。这正是 `Arc::as_ptr` 做 key 时那个假设——「地址相同 ⇒
|
||||
/// 同一个 job」——失效的形态;真实世界里它由「job A 被 drop 后控制块立刻分给 job B」触发。
|
||||
///
|
||||
/// 反向证据(实测过):把身份退回 `Arc::as_ptr` ⇒ 本用例红(`mode_for(&younger)` 读到
|
||||
/// `Some(true)`);那个读串台正是并行跑时「设备码入口读到别人的 browser_first=true」的假红。
|
||||
/// 抓不住:两条入口真的去连微软(无 client id 的测试构建里它们都在网络之前早退)。
|
||||
#[test]
|
||||
fn the_test_seam_does_not_reuse_a_heap_address_as_identity() {
|
||||
let shared = Arc::new(AtomicBool::new(false));
|
||||
let older = super::test_seam::job_around(&shared);
|
||||
let younger = super::test_seam::job_around(&shared);
|
||||
assert_eq!(
|
||||
Arc::as_ptr(&older.cancelled),
|
||||
Arc::as_ptr(&younger.cancelled),
|
||||
"前提:两个 job 必须共用同一个取消槽地址(否则本用例什么也没构造出来)"
|
||||
);
|
||||
assert_eq!(
|
||||
super::test_seam::mode_for(&younger),
|
||||
None,
|
||||
"前提:这两个 job 都还没请求过"
|
||||
);
|
||||
super::test_seam::record(&older, MsaEntry::Browser);
|
||||
assert_eq!(
|
||||
super::test_seam::mode_for(&older),
|
||||
Some(true),
|
||||
"前提:记录本身要能按 job 读回来"
|
||||
);
|
||||
assert_eq!(
|
||||
super::test_seam::mode_for(&younger),
|
||||
None,
|
||||
"同一个堆地址、但确定是另一个 job:读到 older 的记录就是把「会被复用的地址」当身份"
|
||||
);
|
||||
}
|
||||
|
||||
/// **M236**:测试缝的书写规则——每 job 恒一条(覆盖成最后一次)、死 job 的条目被 `retain` 收走。
|
||||
///
|
||||
/// 改之前 `ENTRIES` 只 push 不 clear/retain:条目随登录次数无上限增长;`mode_for` 又用「第一条」
|
||||
/// 回答,同一个 job 记两次(重试)时会永远读到第一次的模式。
|
||||
/// 这里直接对纯函数 `remember` 造活/死条目,不碰全局表——并行用例不会互相污染。
|
||||
#[test]
|
||||
fn the_test_seam_keeps_the_latest_mode_per_job_and_forgets_dropped_ones() {
|
||||
let alive = Arc::new(AtomicBool::new(false));
|
||||
// 一个已经 drop 的 job:`Entry` 里的 `Weak` 把控制块钉住 ⇒ `strong_count() == 0` 读得准。
|
||||
let dead = {
|
||||
let gone = Arc::new(AtomicBool::new(false));
|
||||
super::test_seam::Entry::new(JobId(1), &gone, true)
|
||||
};
|
||||
assert!(!dead.is_alive(), "构造失败:这条应当已经是死条目");
|
||||
|
||||
let mut entries = vec![dead, super::test_seam::Entry::new(JobId(2), &alive, false)];
|
||||
super::test_seam::remember(
|
||||
&mut entries,
|
||||
super::test_seam::Entry::new(JobId(2), &alive, true),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
1,
|
||||
"死 job 的条目必须被收走(表只随活 job 增长)"
|
||||
);
|
||||
assert_eq!(entries[0].job(), JobId(2));
|
||||
assert!(
|
||||
entries[0].browser_first(),
|
||||
"同一个 job 记两次必须取最后一次"
|
||||
);
|
||||
}
|
||||
|
||||
/// 置位不是「记了个数」:`msa_auth` 必须把模式真的落到 `MicrosoftAuth::browser_first` 上。
|
||||
///
|
||||
/// 前半段(入口 → 模式)由上一个用例守;这里守后半段(模式 → 实例字段)。两者缺一,
|
||||
/// 就会出现「日志说置位了、实际没置位」这类假绿。
|
||||
#[test]
|
||||
fn the_entry_mode_lands_on_the_auth_instance() {
|
||||
assert!(!MsaEntry::DeviceCode.browser_first());
|
||||
assert!(MsaEntry::Browser.browser_first());
|
||||
// 无 client id 时 `from_env` 早退,所以这里用显式 id 走同一个 builder 链。
|
||||
let auth = unlucky_core::auth::microsoft::MicrosoftAuth::new("0000-entry-test")
|
||||
.expect("构造")
|
||||
.with_browser_first(MsaEntry::Browser.browser_first());
|
||||
assert!(
|
||||
auth.browser_first(),
|
||||
"with_browser_first 没把字段置上(core 里 browser_then_device_login 会整段跳过)"
|
||||
);
|
||||
}
|
||||
|
||||
/// **M178**:core 的回退原因码 → 页面的说明码。两边的 ascii 码必须逐字相同——
|
||||
/// 日志里看到的码就是页面上 `Code:` 那一行,对不上就没法用用户回报的码定位。
|
||||
///
|
||||
/// 抓得住:core 加/改一个回退原因而 presenter 忘了接(`browser_note` 是 `match`,
|
||||
/// 新增变体会编译失败;这里再把**码的字符串**钉住,防的是两侧各改各的)。
|
||||
#[test]
|
||||
fn browser_fallback_reasons_map_to_the_page_notes_with_the_same_codes() {
|
||||
for (reason, note) in [
|
||||
(
|
||||
BrowserFallbackReason::RedirectNotRegistered,
|
||||
BrowserLoginNote::RedirectNotRegistered,
|
||||
),
|
||||
(
|
||||
BrowserFallbackReason::PreflightUnavailable,
|
||||
BrowserLoginNote::PreflightUnavailable,
|
||||
),
|
||||
(
|
||||
BrowserFallbackReason::AttemptFailed,
|
||||
BrowserLoginNote::AttemptFailed,
|
||||
),
|
||||
] {
|
||||
assert_eq!(browser_note(reason), note);
|
||||
assert_eq!(note.code(), reason.code());
|
||||
}
|
||||
// 「没登记回环」是唯一带「怎么修」的那一条,码也必须稳定(排障脚本按它 grep 日志)。
|
||||
assert_eq!(
|
||||
BrowserLoginNote::RedirectNotRegistered.code(),
|
||||
"redirect_not_registered"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! 内容库的宿主实现(M109 `CONTENTDL`):**检索** + **装进指定实例**。
|
||||
//!
|
||||
//! ## 为什么这些代码在 presenter 而不是 core(临时落点,必须写清)
|
||||
//!
|
||||
//! `crates/core` 的 `addon` 模块今天只有两件事:加载器安装(`addon::loader`)与整合包**格式**
|
||||
//! 解析(`addon::pack`,M110)。它**没有** CurseForge / Modrinth 的 API 客户端,也没有
|
||||
//! 「远程整合包一键装进实例」的管线(M111/M112 才做)。而任务书的硬约束是「UI 层不许自己发
|
||||
//! HTTP,新增能力一律经 presenter 的 host 转发」——所以本轮把客户端放在这里。
|
||||
//!
|
||||
//! **长期应下沉到 `core::addon`**(已报 finding):那时本模块只剩「DTO ↔ 领域类型」的翻译,
|
||||
//! 与 `host/versions.rs` 现在的角色一样。
|
||||
//!
|
||||
//! ## 三条纪律
|
||||
//!
|
||||
//! | 纪律 | 落点 |
|
||||
//! |---|---|
|
||||
//! | 网络只在后台线程 | [`spawn_search`] / [`spawn_install`] 走 `job::spawn`,UI 线程只收事件 |
|
||||
//! | Modrinth 走 core 的下载引擎 | `addon/http.rs` 的 `download_bytes` 路径带**信号量限流 + 源健康度 + 指数退避**,不另写重试循环 |
|
||||
//! | 密钥不进仓库 / 日志 | CF 的 `X-API-KEY` 只从 `ULL_CF_API_KEY` 读,日志只打 `set`/`unset`,DTO 里只有一个 `bool` |
|
||||
//!
|
||||
//! ## 模块
|
||||
//!
|
||||
//! | 文件 | 职责 |
|
||||
//! |---|---|
|
||||
//! | [`sources`] | 两个站点的 JSON 方言 → [`crate::dto::ContentHit`] |
|
||||
//! | `http` | 取文本:无 header 走引擎、带 header 走 client + 退避、百分号编码 |
|
||||
//! | [`install`] | 一键装入:解析文件(含 `required` 前置)→ 下载到实例目录 → 进度回流 |
|
||||
//! | [`downloads`] | 下载阶段(可测):逐个下、失败进名单不中断、两处取消点 |
|
||||
//! | [`plan`] | 依赖计划的宿主侧:扫目录、两档失败 → DTO 大类、人读文案 |
|
||||
//!
|
||||
//! ## 整合包与 CF 的边界(都写进报告)
|
||||
//!
|
||||
//! - 整合包**不做**「一键装入」:`ContentKind::Modpack.dir()` 是 `None`,UI 侧按钮禁用带原因,
|
||||
//! host 侧再挡一次(就算绕过 UI 发指令也只回一条 `Failed`)。真流程是 M111 的导入管线。
|
||||
//! - CurseForge 本轮只做**检索**(需要用户配 `ULL_CF_API_KEY`),文件解析未接通。
|
||||
|
||||
mod downloads;
|
||||
mod http;
|
||||
mod install;
|
||||
mod plan;
|
||||
// M113: import/export —— 整合包导入/导出(拆成子目录:父文件只留既有内容 + mod)
|
||||
mod pack;
|
||||
mod sources;
|
||||
|
||||
pub(super) use pack::{cancel_pack, pack_epoch, take_terminal_epoch};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::dto::{
|
||||
ContentDetail, ContentDetailRequest, ContentFailure, ContentQuery, ContentResults,
|
||||
};
|
||||
|
||||
use super::env::HostEnv;
|
||||
use super::job::{spawn, HostTask, JobSender};
|
||||
|
||||
/// 日志里只出现这两种状态,**永远不打密钥本身**。
|
||||
const KEY_UNSET: &str = "ULL_CF_API_KEY unset(CurseForge 源不可用)";
|
||||
const KEY_SET: &str = "ULL_CF_API_KEY set(CurseForge 源可用)";
|
||||
|
||||
/// CurseForge 的密钥(环境变量;空串 / 空白按「没配」处理)。
|
||||
///
|
||||
/// 为什么不放设置项:core 的 `setting` schema 不在本 mission 的 scope(已登记 finding)。
|
||||
/// 环境变量这条路至少满足「不进仓库、不进日志」两条硬要求。
|
||||
fn curseforge_key() -> Option<String> {
|
||||
std::env::var("ULL_CF_API_KEY")
|
||||
.ok()
|
||||
.map(|key| key.trim().to_string())
|
||||
.filter(|key| !key.is_empty())
|
||||
}
|
||||
|
||||
/// CF 源此刻可用吗(页面据此禁用那条来源)。
|
||||
#[must_use]
|
||||
pub fn curseforge_ready() -> bool {
|
||||
curseforge_key().is_some()
|
||||
}
|
||||
|
||||
/// 打一行「配没配」的日志(**不含**密钥内容;host 装配时调一次)。
|
||||
pub fn log_curseforge_state() {
|
||||
eprintln!(
|
||||
"[content] {}",
|
||||
if curseforge_ready() {
|
||||
KEY_SET
|
||||
} else {
|
||||
KEY_UNSET
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// 后台检索(网络在后台线程,UI 不卡)。
|
||||
pub fn spawn_search(env: HostEnv, tx: JobSender, query: ContentQuery) {
|
||||
spawn("content", move || {
|
||||
let result: Result<ContentResults, ContentFailure> = sources::search(&env, &query);
|
||||
let _ = tx.send(HostTask::Content(Box::new(result)));
|
||||
});
|
||||
}
|
||||
|
||||
/// 后台取项目详情 + 版本列表(M162;选中项目才发,一条独立通道)。
|
||||
pub fn spawn_detail(env: HostEnv, tx: JobSender, request: ContentDetailRequest) {
|
||||
spawn("content-detail", move || {
|
||||
let project = request.project.clone();
|
||||
let result: Result<ContentDetail, ContentFailure> = sources::detail(&env, &request);
|
||||
let _ = tx.send(HostTask::ContentDetail {
|
||||
project,
|
||||
result: Box::new(result),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 后台「装进指定实例」。
|
||||
pub fn spawn_install(
|
||||
env: HostEnv,
|
||||
tx: JobSender,
|
||||
request: crate::dto::ContentInstallRequest,
|
||||
cancel: Arc<AtomicBool>,
|
||||
epoch: u64,
|
||||
) {
|
||||
spawn("content-install", move || {
|
||||
install::install(&env, &tx, &request, &cancel, epoch);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//! 下载阶段的**可测那一段**:把计划里的文件逐个下下来,失败进名单而不中断。
|
||||
//!
|
||||
//! # 为什么单独一个文件
|
||||
//!
|
||||
//! 这段逻辑以前整块躺在 `install::run` 里,而 `run` 需要实例目录 + 真引擎 ⇒ 没有用例能驱动它,
|
||||
//! 于是「下载失败记进两档名单」这条改动**整段删掉也不会红一条**(M186 r2 复审实测:
|
||||
//! 整段包进 `if false` 后 `cargo test -p unlucky-presenter --lib host::addon` 仍 36 passed)。
|
||||
//! 抽成只依赖 [`ArtifactFetcher`] 的函数之后,假下载器就能驱动它。
|
||||
//!
|
||||
//! # 三条不变量(每条都有用例)
|
||||
//!
|
||||
//! 1. **失败不中断**:某个文件下载失败 ⇒ 记进 [`DependencyPlan::record_failure`],继续下一个;
|
||||
//! 2. **已装的不回滚**:成功的文件留在盘上,并出现在返回的「已装」名单里;
|
||||
//! 3. **两处取消点**:进入本阶段先查一次(`run` 解析完就调它 ⇒ 解析阶段的取消生效,
|
||||
//! 全部文件都「已存在」而 `nodes` 为空时也不会漏掉取消),循环每次迭代再查一次。
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use unlucky_core::addon::online::{
|
||||
download_verified, ArtifactFetcher, DependencyFailure, DependencyPlan, FailureKind,
|
||||
OnlineError, PlannedNode,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
|
||||
use crate::dto::{ContentFailure, InstallProgressView};
|
||||
|
||||
use super::super::job::{HostTask, JobSender};
|
||||
use super::install::{local, node_label, safe_file_name};
|
||||
|
||||
/// 下载失败 → 两档失败里的一条(`Unreachable` + 叶子成因 `cause`)。
|
||||
///
|
||||
/// 为什么归 `Unreachable`:`download_verified` 的失败就是「取不到这份字节」——没网 / 站点错 /
|
||||
/// 内容与声明不符 / 本地 IO,叶子成因留在 `cause` 上,上层照 `sources::failure_of` 映射成
|
||||
/// DTO 大类;**不是** `Incompatible`(换版本/加载器也救不了一次断网)。
|
||||
#[must_use]
|
||||
pub(super) fn download_failure(node: &PlannedNode, error: &OnlineError) -> DependencyFailure {
|
||||
DependencyFailure {
|
||||
requested_as: node.requested_as.clone(),
|
||||
required_by: node.required_by.clone(),
|
||||
depth: node.depth,
|
||||
kind: FailureKind::Unreachable,
|
||||
cause: Some(error.kind()),
|
||||
message: format!(
|
||||
"下载 {} 失败({}):{}",
|
||||
node.file.filename,
|
||||
error.kind().as_str(),
|
||||
error.message()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载阶段:先查一次取消,再逐个下(失败进名单、不中断)。
|
||||
///
|
||||
/// 返回**真落盘**的节点(失败的不在里面),调用方据此拼「装了 N 个」。
|
||||
///
|
||||
/// # Errors
|
||||
/// 用户取消时返回一条 `ContentFailure`(detail 以「已取消」开头,`install()` 据此画 `Cancelled`)。
|
||||
pub(super) fn download_stage(
|
||||
plan: &mut DependencyPlan,
|
||||
target_dir: &Path,
|
||||
fetcher: &dyn ArtifactFetcher,
|
||||
cancel: &AtomicBool,
|
||||
progress: &mut InstallProgressView,
|
||||
tx: &JobSender,
|
||||
epoch: u64,
|
||||
) -> Result<Vec<PlannedNode>, ContentFailure> {
|
||||
// 解析完立刻查一次:解析最多要跑 6 层版本请求,此时取消不该先下一份文件再说
|
||||
// (main 的 `resolve_file` 之后就有这条检查,r2 复审指出我们把它丢了)。
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return Err(local("已取消(下载开始前)"));
|
||||
}
|
||||
let mut installed: Vec<PlannedNode> = Vec::new();
|
||||
for index in 0..plan.nodes.len() {
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return Err(local("已取消(下载中途)"));
|
||||
}
|
||||
let node = plan.nodes[index].clone();
|
||||
progress.message = node_label(&node);
|
||||
let _ = tx.send(HostTask::Install {
|
||||
progress: Box::new(progress.clone()),
|
||||
epoch,
|
||||
});
|
||||
// M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核,
|
||||
// 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。
|
||||
let dest = target_dir.join(safe_file_name(&node.file.filename));
|
||||
let outcome = block_on(download_verified(
|
||||
fetcher,
|
||||
&node.file.url,
|
||||
node.file.sha1(),
|
||||
node.file.sha512(),
|
||||
(node.file.size > 0).then_some(node.file.size),
|
||||
&dest,
|
||||
));
|
||||
match outcome {
|
||||
Ok(_) => installed.push(node),
|
||||
Err(error) => {
|
||||
// 下载阶段的失败**也记进同一套两档结构**(Unreachable + 叶子成因):
|
||||
// 已下好的不回滚、失败项进「可只重试」,整份计划的上下文(缺的是谁的前置)不丢。
|
||||
plan.record_failure(download_failure(&node, &error));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
progress.files_done += 1;
|
||||
let _ = tx.send(HostTask::Install {
|
||||
progress: Box::new(progress.clone()),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
Ok(installed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use unlucky_core::addon::online::{
|
||||
ArtifactFetcher, DependencyPlan, FailureKind, FetchArgs, OnlineError, OnlineErrorKind,
|
||||
OnlineFuture, PlannedNode, ResolveRequest, VersionFile,
|
||||
};
|
||||
|
||||
use crate::dto::InstallProgressView;
|
||||
|
||||
use super::{download_failure, download_stage};
|
||||
|
||||
/// 假下载器:`fail_on` 里的文件名一律「没联网」,其余写一行字节(**不校验摘要**)。
|
||||
struct FakeFetcher {
|
||||
fail_on: Vec<&'static str>,
|
||||
attempts: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FakeFetcher {
|
||||
fn new(fail_on: &[&'static str]) -> Self {
|
||||
Self {
|
||||
fail_on: fail_on.to_vec(),
|
||||
attempts: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn attempts(&self) -> Vec<String> {
|
||||
self.attempts.lock().expect("锁没中毒").clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl ArtifactFetcher for FakeFetcher {
|
||||
fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result<u64, OnlineError>> {
|
||||
Box::pin(async move {
|
||||
let name = args
|
||||
.dest
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
self.attempts.lock().expect("锁没中毒").push(name.clone());
|
||||
if self.fail_on.contains(&name.as_str()) {
|
||||
return Err(OnlineError::offline("假装的断网"));
|
||||
}
|
||||
std::fs::write(args.dest, b"jar").map_err(|error| {
|
||||
OnlineError::new(OnlineErrorKind::Io, format!("写盘失败:{error}"))
|
||||
})?;
|
||||
Ok(4)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn node(project: &str, file: &str, required_by: &[&str]) -> PlannedNode {
|
||||
PlannedNode {
|
||||
project: project.to_string(),
|
||||
requested_as: project.to_string(),
|
||||
version_id: format!("ver-{project}"),
|
||||
version_name: project.to_string(),
|
||||
version_number: "1.0".to_string(),
|
||||
file: VersionFile {
|
||||
url: format!("https://cdn.modrinth.com/data/{project}/1.0/{file}"),
|
||||
filename: file.to_string(),
|
||||
size: 0,
|
||||
..VersionFile::default()
|
||||
},
|
||||
depth: u8::from(!required_by.is_empty()),
|
||||
required_by: required_by
|
||||
.iter()
|
||||
.map(|parent| (*parent).to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn plan(nodes: Vec<PlannedNode>) -> DependencyPlan {
|
||||
DependencyPlan {
|
||||
request: ResolveRequest::single("AANobbMI"),
|
||||
nodes,
|
||||
skipped: Vec::new(),
|
||||
failures: Vec::new(),
|
||||
truncated: Vec::new(),
|
||||
unresolved_required: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("ull-m186-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("建临时目录");
|
||||
dir
|
||||
}
|
||||
|
||||
/// 失败的那一条**进名单**、父边与叶子成因都带着(守的是 [`download_failure`] 的映射)。
|
||||
#[test]
|
||||
fn a_download_error_becomes_an_unreachable_failure_with_its_leaf_cause() {
|
||||
let node = node("P7dR8mSH", "fabric-api.jar", &["AANobbMI"]);
|
||||
let failure = download_failure(&node, &OnlineError::offline("连不上 cdn.modrinth.com"));
|
||||
|
||||
assert_eq!(failure.kind, FailureKind::Unreachable);
|
||||
assert_eq!(failure.cause, Some(OnlineErrorKind::NotConnected));
|
||||
assert_eq!(failure.requested_as, "P7dR8mSH");
|
||||
assert_eq!(failure.required_by, ["AANobbMI".to_string()]);
|
||||
assert_eq!(failure.depth, 1);
|
||||
assert!(
|
||||
failure.message.contains("fabric-api.jar"),
|
||||
"{}",
|
||||
failure.message
|
||||
);
|
||||
assert!(
|
||||
failure.message.contains("online_not_connected"),
|
||||
"成因要带 code:{}",
|
||||
failure.message
|
||||
);
|
||||
}
|
||||
|
||||
/// 第 2 个文件失败:**不中断**、已装的不回滚、失败进名单、第 3 个仍会被尝试。
|
||||
#[test]
|
||||
fn a_failed_file_does_not_stop_the_rest_and_keeps_what_already_landed() {
|
||||
let dir = temp_dir("download-partial");
|
||||
let mut plan = plan(vec![
|
||||
node("AANobbMI", "sodium.jar", &[]),
|
||||
node("P7dR8mSH", "fabric-api.jar", &["AANobbMI"]),
|
||||
node("1eAoo2KR", "yacl.jar", &["P7dR8mSH"]),
|
||||
]);
|
||||
let fetcher = FakeFetcher::new(&["fabric-api.jar"]);
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let mut progress = InstallProgressView {
|
||||
files_total: 3,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
|
||||
let installed = download_stage(
|
||||
&mut plan,
|
||||
&dir,
|
||||
&fetcher,
|
||||
&AtomicBool::new(false),
|
||||
&mut progress,
|
||||
&tx,
|
||||
7,
|
||||
)
|
||||
.expect("没取消就不该回 Err");
|
||||
|
||||
let names: Vec<&str> = installed
|
||||
.iter()
|
||||
.map(|node| node.file.filename.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["sodium.jar", "yacl.jar"],
|
||||
"成功的不回滚、后面的照下"
|
||||
);
|
||||
// 失败的那个会被 `download_verified` 重试 `RETRY_ATTEMPTS` 次(这里 3 次),
|
||||
// 关键是**它之后**还会去试第 3 个文件。
|
||||
let attempts = fetcher.attempts();
|
||||
assert_eq!(attempts.first().map(String::as_str), Some("sodium.jar"));
|
||||
assert_eq!(attempts.last().map(String::as_str), Some("yacl.jar"));
|
||||
assert_eq!(
|
||||
attempts
|
||||
.iter()
|
||||
.filter(|name| *name == "fabric-api.jar")
|
||||
.count(),
|
||||
3,
|
||||
"失败的尝试 + 重试都发生在第 3 个文件之前:{attempts:?}"
|
||||
);
|
||||
assert_eq!(plan.failures.len(), 1, "{:?}", plan.failures);
|
||||
assert_eq!(plan.failures[0].requested_as, "P7dR8mSH");
|
||||
assert!(plan.failures[0].message.contains("fabric-api.jar"));
|
||||
assert_eq!(progress.files_done, 2, "进度只数真落盘的");
|
||||
assert!(dir.join("sodium.jar").exists(), "已下好的文件不许删");
|
||||
assert!(!dir.join("fabric-api.jar").exists());
|
||||
assert!(dir.join("yacl.jar").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// 解析完就取消 ⇒ 一条文件都不下(r2 nit②:main 有这条检查,我们曾把它丢了)。
|
||||
#[test]
|
||||
fn cancelling_right_after_resolution_touches_nothing() {
|
||||
let dir = temp_dir("download-cancelled");
|
||||
let mut plan = plan(vec![
|
||||
node("AANobbMI", "sodium.jar", &[]),
|
||||
node("P7dR8mSH", "fabric-api.jar", &["AANobbMI"]),
|
||||
]);
|
||||
let fetcher = FakeFetcher::new(&[]);
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let mut progress = InstallProgressView::new();
|
||||
|
||||
let error = download_stage(
|
||||
&mut plan,
|
||||
&dir,
|
||||
&fetcher,
|
||||
&AtomicBool::new(true),
|
||||
&mut progress,
|
||||
&tx,
|
||||
7,
|
||||
)
|
||||
.expect_err("取消必须回 Err");
|
||||
|
||||
assert!(error.detail.contains("已取消"), "{}", error.detail);
|
||||
assert!(fetcher.attempts().is_empty(), "一份都不许下");
|
||||
assert!(plan.failures.is_empty(), "取消不是失败,不进名单");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
//! HTTP 取文本(内容库唯一的网络出口)。
|
||||
//!
|
||||
//! ## 两条路径,为什么不是一条
|
||||
//!
|
||||
//! | 请求 | 走哪 | 拿到什么 |
|
||||
//! |---|---|---|
|
||||
//! | 无自定义 header([`get_via_engine`],**生产零调用者**:内容库的 Modrinth 走 core 的 `EngineTransport`,同一条 `download_bytes`) | [`DownloadEngine::download_bytes`] | **信号量限流 + 源健康度 + 指数退避 + 分类错误**,全是 core 现成的 |
|
||||
//! | 带 header(只有 CurseForge 的 `X-API-KEY`) | `engine.client()` 直发 | 引擎的请求体没有 header 字段,只能自己发;代价是这条路径自己带一个 429/5xx 退避 |
|
||||
//!
|
||||
//! 两条路径用的是**同一个引擎**,而引擎是进程级共享的(M165:`host::engine::engine` →
|
||||
//! `core::download::shared::engine_for`,一个数据根一个)。所以 Modrinth 与 CurseForge 的请求、
|
||||
//! 以及它们与真正的文件下载,共用同一条连接池与同一套限流;`block_on` 也跑在同一个常驻运行时上,
|
||||
//! 池里的空闲连接不会随着某次调用结束而变成死连接。
|
||||
//!
|
||||
//! ## UA 只有一份定义(M207)
|
||||
//!
|
||||
//! `User-Agent` 定义在 core 的 `download::http::USER_AGENT`:`Unlucky-Launcher/<CARGO_PKG_VERSION>
|
||||
//! (+https://github.com/AskaEth/Unlucky-Launcher)`。本文件**不拼** UA——带 header 的路径发的是
|
||||
//! `engine.config().user_agent`,无 header 的路径发的是同一个客户端的默认 UA,所以两条路径、以及真正的
|
||||
//! 文件下载,站点看到的都是同一个字符串。
|
||||
//!
|
||||
//! M205 复审核实过的旧状:`api.modrinth.com` 收到的是引擎那份 `UnluckyLauncher/<ver>`(无连字符、
|
||||
//! 无联系方式),带联系方式的 `Unlucky-Launcher/<ver> (+repo)` 只发给 CurseForge 搜索——而 Modrinth 的
|
||||
//! UA 政策正是「带联系方式,否则限流」,也就是说那条政策在**真正会被限流的那条路上**没被满足。两份 UA
|
||||
//! 同时活着、改一处不带另一处,所以 M207 把定义收进 core 一处。
|
||||
//!
|
||||
//! 钉住它的是回环上的字节级用例 `both_download_paths_send_the_same_user_agent`:两条路径发出去的
|
||||
//! `User-Agent` 必须逐字节相同,且等于 `EngineConfig::default().user_agent`。
|
||||
//!
|
||||
//! ## 密钥纪律
|
||||
//!
|
||||
//! 带 header 的那条路径**只把 header 值喂给请求**:不进日志、不进错误串。错误串里只有 URL
|
||||
//! (CF 的 URL 不含密钥)与状态码。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::download::config::DEFAULT_MAX_ATTEMPTS_PER_SOURCE;
|
||||
use unlucky_core::download::{DownloadRequest, ResourceKind};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
|
||||
/// 最多重试次数(含首次)。
|
||||
///
|
||||
/// **不在这里写数字**:取值就是下载引擎的出厂默认
|
||||
/// (`core::download::config::DEFAULT_MAX_ATTEMPTS_PER_SOURCE`,`download.retry_limit` 的 schema
|
||||
/// 默认值同源),改工厂默认时这里跟着走。
|
||||
///
|
||||
/// 边界说清:这条路径**没读**宿主设置(次数是编译期常量),所以固定吃出厂默认——**不是拿不到**。
|
||||
/// `env` 就在手上(`get_with_headers` 第一句 `engine(env)` 已经灌过调优),用户当前值就在
|
||||
/// `engine.config().max_attempts_per_source`;要让它跟随 `download.retry_limit`,读那个字段即可,
|
||||
/// 不需要新拉一条参数链。另一条无 header 的路径([`get_via_engine`])走引擎,本来就跟随设置
|
||||
/// (源冷却中时引擎只给 1 次,那是恢复探测,见 `DownloadEngine::attempts_for`)。
|
||||
const ATTEMPTS: u32 = DEFAULT_MAX_ATTEMPTS_PER_SOURCE;
|
||||
|
||||
/// 取一段文本(JSON 原文)。
|
||||
pub(super) fn get(env: &HostEnv, url: &str, headers: &[(&str, &str)]) -> Result<String, String> {
|
||||
if headers.is_empty() {
|
||||
get_via_engine(env, url)
|
||||
} else {
|
||||
get_with_headers(env, url, headers)
|
||||
}
|
||||
}
|
||||
|
||||
/// core 的下载引擎:限流与退避都在里面,这里只负责「URL → 文本」。
|
||||
fn get_via_engine(env: &HostEnv, url: &str) -> Result<String, String> {
|
||||
let engine = super::super::engine::engine(env);
|
||||
let dir = env.layout.cache().join("content");
|
||||
std::fs::create_dir_all(&dir).map_err(|error| format!("建缓存目录失败:{error}"))?;
|
||||
let request = DownloadRequest::new(ResourceKind::Other, url, dir.join("response.json"));
|
||||
let bytes = block_on(engine.download_bytes(request))
|
||||
.map_err(|error| format!("请求 {url} 失败:{error}"))?;
|
||||
String::from_utf8(bytes).map_err(|error| format!("响应不是 UTF-8:{error}"))
|
||||
}
|
||||
|
||||
/// 带 header 的请求(只有 CF 走这条):429 / 5xx / 连接类失败按退避重试。
|
||||
fn get_with_headers(env: &HostEnv, url: &str, headers: &[(&str, &str)]) -> Result<String, String> {
|
||||
let engine = super::super::engine::engine(env);
|
||||
// UA 取自引擎配置(**不在这里拼**):定义只有 core 那一份,这条路径与引擎路径发的必须是同一个字符串。
|
||||
let user_agent = engine.config().user_agent.clone();
|
||||
let owned: Vec<(String, String)> = headers
|
||||
.iter()
|
||||
.map(|(name, value)| ((*name).to_string(), (*value).to_string()))
|
||||
.collect();
|
||||
|
||||
for attempt in 0..ATTEMPTS {
|
||||
let result = block_on(async {
|
||||
let mut builder = engine
|
||||
.client()
|
||||
.get(url)
|
||||
.header("User-Agent", user_agent.as_str());
|
||||
for (name, value) in &owned {
|
||||
builder = builder.header(name.as_str(), value.as_str());
|
||||
}
|
||||
let response = builder.send().await.map_err(|error| error.to_string())?;
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
return response.text().await.map_err(|error| error.to_string());
|
||||
}
|
||||
let retry = response
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<u64>().ok());
|
||||
Err(format!("HTTP {} (retry_after={retry:?})", status.as_u16()))
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(body) => return Ok(body),
|
||||
Err(reason) if attempt + 1 < ATTEMPTS && retryable(&reason) => {
|
||||
std::thread::sleep(retry_after(&reason, attempt));
|
||||
}
|
||||
Err(reason) => return Err(format!("请求 {url} 失败:{reason}")),
|
||||
}
|
||||
}
|
||||
Err(format!("请求 {url} 失败:重试用尽"))
|
||||
}
|
||||
|
||||
/// 这个失败值得再试一次吗(429 / 5xx / 连接类;**401/403/404 一律不重试**,重试只会更糟)。
|
||||
pub(super) fn retryable(reason: &str) -> bool {
|
||||
reason.contains("HTTP 429")
|
||||
|| reason.contains("HTTP 500")
|
||||
|| reason.contains("HTTP 502")
|
||||
|| reason.contains("HTTP 503")
|
||||
|| reason.contains("HTTP 504")
|
||||
|| reason.contains("error sending request")
|
||||
|| reason.contains("timed out")
|
||||
}
|
||||
|
||||
/// 退避时长:优先吃服务端给的 `Retry-After`(封顶 30s),否则指数退避(1s、2s、4s)。
|
||||
pub(super) fn retry_after(reason: &str, attempt: u32) -> Duration {
|
||||
let parsed = reason
|
||||
.split("retry_after=Some(")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split(')').next())
|
||||
.and_then(|value| value.parse::<u64>().ok());
|
||||
match parsed {
|
||||
Some(secs) => Duration::from_secs(secs.min(30)),
|
||||
None => Duration::from_secs(1u64 << attempt.min(4)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 最小百分号编码(query 串用;不为一件小事引第三方 crate)。
|
||||
pub(super) 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 std::io::{Read as _, Write as _};
|
||||
use std::net::TcpListener;
|
||||
use std::time::Duration;
|
||||
|
||||
use unlucky_core::download::shared;
|
||||
|
||||
use super::get;
|
||||
use crate::host::env::HostEnv;
|
||||
|
||||
/// 离线回环 HTTP 服务:只答固定 JSON,把每条请求的**原始请求头**送回主线程。
|
||||
///
|
||||
/// 用 127.0.0.1 而不是真站点:**单测不许打公网**(CI 没网,也不该有)。回传原始头部是为了抓
|
||||
/// 「站点实际收到的 `User-Agent`」——那是唯一能证明两条路径发同一个字符串的地方。
|
||||
fn loopback(expected_requests: usize) -> (String, std::sync::mpsc::Receiver<Vec<String>>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("绑定回环端口");
|
||||
let addr = listener.local_addr().expect("取端口");
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut heads: Vec<String> = Vec::new();
|
||||
while heads.len() < expected_requests {
|
||||
let Ok((mut socket, _)) = listener.accept() else {
|
||||
break;
|
||||
};
|
||||
let _ = socket.set_read_timeout(Some(Duration::from_secs(5)));
|
||||
let mut seen = Vec::new();
|
||||
loop {
|
||||
let mut chunk = [0u8; 1024];
|
||||
match socket.read(&mut chunk) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(read) => seen.extend_from_slice(&chunk[..read]),
|
||||
}
|
||||
if !seen.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
continue;
|
||||
}
|
||||
heads.push(String::from_utf8_lossy(&seen).into_owned());
|
||||
let body = br#"{"ok":true}"#;
|
||||
let reply = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
if socket.write_all(reply.as_bytes()).is_err()
|
||||
|| socket.write_all(body).is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
seen.clear();
|
||||
if heads.len() >= expected_requests {
|
||||
let _ = tx.send(heads);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = tx.send(heads);
|
||||
});
|
||||
(format!("http://{addr}/v2/search"), rx)
|
||||
}
|
||||
|
||||
/// 一条原始请求头里 `User-Agent` 的值(大小写不敏感:reqwest 发的是小写字段名)。
|
||||
fn user_agent_of(head: &str) -> Option<String> {
|
||||
head.lines().find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.trim()
|
||||
.eq_ignore_ascii_case("user-agent")
|
||||
.then(|| value.trim().to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// 一个只用来放缓存根的临时数据根(`<root>/.ULL/cache`)。
|
||||
fn data_root(tag: &str) -> std::path::PathBuf {
|
||||
static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let dir = std::env::temp_dir().join(format!("ull-http-{tag}-{}-{seq}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join(".ULL").join("cache")).expect("建缓存根");
|
||||
dir
|
||||
}
|
||||
|
||||
/// 两条下载路径发出去的 `User-Agent` 必须是**同一个字符串**,且就是引擎配置里那一个(M207)。
|
||||
///
|
||||
/// 为什么读回环上的字节、而不是断言常量相等:`header("User-Agent", …)` 到底传了什么,只有抓包才算
|
||||
/// 「站点看到的事实」;断言两个常量相等只说明「借了同一个名字」,谁在发送处又拼了一份照旧全绿。
|
||||
///
|
||||
/// 三段内容的正面断言在 core(`download::http` 的用例
|
||||
/// `the_user_agent_carries_the_package_version_and_the_repo_url`:版本 == `CARGO_PKG_VERSION`、
|
||||
/// 联系方式段 == 仓库 URL、不含邮箱)。这里钉的是**两条路径同源**:无 header 那条发引擎 reqwest
|
||||
/// 客户端的默认 UA,带 header 那条发 `engine.config().user_agent`,两者都必须对
|
||||
/// `EngineConfig::default()` 逐字节相等。
|
||||
///
|
||||
/// 抓得住:presenter 又自己拼一份 UA(哪怕只差产品名的连字符)⇒ 红;发送处把 header 删掉或写成空串 ⇒ 红;
|
||||
/// 客户端的默认 UA 与配置字段分叉(`build_client` 不再读 `cfg.user_agent`)⇒ 红。
|
||||
/// 抓不住:真实 Modrinth / CurseForge 收到这条 UA 之后怎么限流(单测不出网);也抓不住「联系方式
|
||||
/// 那一串是不是站点认可的格式」——那要真发一次才知道。
|
||||
#[test]
|
||||
fn both_download_paths_send_the_same_user_agent() {
|
||||
let dir = data_root("ua");
|
||||
let env = HostEnv::at_root(&dir);
|
||||
let (url, seen) = loopback(2);
|
||||
|
||||
get(&env, &url, &[]).expect("无 header 路径(引擎 `download_bytes`)");
|
||||
get(&env, &url, &[("x-api-key", "test-key")])
|
||||
.expect("带 header 路径(`engine.client()` 直发)");
|
||||
|
||||
let heads = seen
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("回环服务应回传请求头");
|
||||
let sent: Vec<String> = heads
|
||||
.iter()
|
||||
.filter_map(|head| user_agent_of(head))
|
||||
.collect();
|
||||
assert_eq!(sent.len(), 2, "两条请求都要带 User-Agent:{heads:?}");
|
||||
assert_eq!(sent[0], sent[1], "两条路径的 UA 必须是同一个字符串");
|
||||
|
||||
let defined = unlucky_core::download::config::EngineConfig::default().user_agent;
|
||||
assert_eq!(
|
||||
sent[0], defined,
|
||||
"发出去的 UA 必须就是引擎配置里那一个(定义只有 core 一份)"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// **端到端守卫(M165 复审备注 2)**:连走三条真实宿主请求路径,只该构造一个引擎。
|
||||
///
|
||||
/// 为什么不满足于「再断言一次 `engine()` 复用」:那种断言只钉漏斗本身——将来谁在调用点
|
||||
/// 自己 `DownloadEngine::new`,它照旧全绿。这里数的是 `shared::constructions`,而记账卡在
|
||||
/// `DownloadEngine::new` 这个卡口上(与谁造的无关),所以只要有一条路径另造引擎就会红。
|
||||
///
|
||||
/// 三条路径:
|
||||
/// 1. [`get`] 无 header = [`get_via_engine`](走 `engine.download_bytes`)。**生产零调用者**:内容库的
|
||||
/// Modrinth 元数据 GET 走 core 的 `EngineTransport`(同一条 `download_bytes`),不经本文件;
|
||||
/// 2. [`get`] 带 header = CurseForge 那条(走 `engine.client()`,另一条机制);
|
||||
/// 3. `host::java::vendors` = Java 页渲染(走 `domestic()`,非 HTTP 的漏斗消费者)。
|
||||
///
|
||||
/// 没走 `versions` 清单:它的地址是写死的 launchermeta,单测里要么打真网、要么吃满连接超时
|
||||
/// —— 那正是「测试里不该发生的事」,所以选了同样真实但完全离线的三条。
|
||||
#[test]
|
||||
fn three_host_request_paths_share_one_engine() {
|
||||
let dir = data_root("paths");
|
||||
let env = HostEnv::at_root(&dir);
|
||||
let cache_root = env.layout.cache();
|
||||
let (url, seen) = loopback(2);
|
||||
|
||||
let without_headers = get(&env, &url, &[]).expect("无 header 路径(`get_via_engine`)");
|
||||
let with_headers =
|
||||
get(&env, &url, &[("x-api-key", "test-key")]).expect("带 header 路径(CF)");
|
||||
let vendors = crate::host::java::vendors(&env, &[]);
|
||||
|
||||
assert_eq!(
|
||||
without_headers, with_headers,
|
||||
"两条路径读的是同一份回环响应"
|
||||
);
|
||||
assert!(!vendors.is_empty(), "Java 页应有发行版行");
|
||||
let heads = seen
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("回环服务应回传请求头");
|
||||
assert_eq!(heads.len(), 2, "两条 HTTP 路径各发一条请求");
|
||||
assert_eq!(
|
||||
shared::constructions(&cache_root),
|
||||
1,
|
||||
"三条宿主请求路径必须共用同一个引擎(谁绕过漏斗另造一个,这条就红)"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
//! 「一键装入指定实例」(M109;M186 起把前置一起装上)。
|
||||
//!
|
||||
//! ## 网络只走 code 的在线内容源(M162 r1)
|
||||
//!
|
||||
//! 解析「哪个文件」用 `core::addon::online`(同一个引擎传输层 ⇒ 信号量、源健康度、退避都在
|
||||
//! 引擎里),下载用 `online::download_verified`(sha1 + sha512 双检)。本文件**不再**自己拼
|
||||
//! `api.modrinth.com` 的 URL,也不再自己 `serde_json::from_str`。
|
||||
//!
|
||||
//! ## 它到底做了什么(诚实版)
|
||||
//!
|
||||
//! | 内容类型 | 做什么 | 状态 |
|
||||
//! |---|---|---|
|
||||
//! | Mod / 资源包 / 光影 | 解析「该项目 + 它的 `required` 前置」在目标实例(版本 + 加载器)下的文件 → core 下载引擎下到 `mods/` `resourcepacks/` `shaderpacks/` → 进度经 `InstallProgressView` 回流 | 已接(M186 起含前置) |
|
||||
//! | 整合包 | 一律 `Failed` + 一句原因 | **未做**:core 没有「远程整合包装进实例」的管线(M111) |
|
||||
//! | CurseForge 文件解析 | 一律 `Failed` + 一句原因 | **未做**:需要带 key 的文件端点,本轮只做了 CF 的**检索** |
|
||||
//!
|
||||
//! ## 前置的两档失败与重试名单(M186 / M191)
|
||||
//!
|
||||
//! 解析在 [`super::plan`] + `core::addon::online::resolve`:某个前置「不兼容所选版本/加载器」
|
||||
//! 与「取不到(网络 / 项目不存在)」是**两条**记录,本文件把两档分开写进失败文案,并把失败项
|
||||
//! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户点(M191 的界面入口)。
|
||||
//!
|
||||
//! ## 「只重试这些」真的只重试这些(M191)
|
||||
//!
|
||||
//! 上一轮失败时,那份 `DependencyPlan` 被 [`plan::remember`] 记下来;`ContentInstallRequest.retry`
|
||||
//! 为真的第二次请求走 [`run`] 的重试入口——它调 core 的 `DependencyPlan::retry_request()`,
|
||||
//! 于是重解**只请求失败的那几个项目**(`present` 重新扫一遍目录,已装好的不会被重下)。
|
||||
//! 没有记录时回一条失败,**不**退化成「整单重装」:那会把已装好的再下一遍、请求数放大。
|
||||
//!
|
||||
//! 下载阶段的失败**也进同一套名单**(M186 r1):以前 `download_verified` 一失败就整单中断,
|
||||
//! 用户既看不到「主文件已装、缺 fabric-api」,也拿不到可重试的项目。现在逐个文件下、失败记进
|
||||
//! `DependencyPlan::record_failure`(`Unreachable` + 叶子成因),已下好的不回滚,最后一起收口。
|
||||
//!
|
||||
//! ## 光影与数据包:支持到哪一步(M186 核对过,别写成「都支持」)
|
||||
//!
|
||||
//! | 内容 | 检索 | 装入 | 差在哪 |
|
||||
//! |---|---|---|---|
|
||||
//! | 光影 | 支持(`project_type:shader`);命中项的加载器标签现在认得 `iris` / `optifine`(`KNOWN_LOADERS`) | 支持(落 `shaderpacks/`) | 加载器**过滤**仍筛不了:`LoaderKind::OptiFine` 没有 facet(`loader_facet` 返回 `None`),所以「只看 Iris 光影」只能靠标签目测 |
|
||||
//! | 数据包 | core 的 `ContentType::Datapack` 有 `datapack` facet | **不支持** | DTO 的 `ContentKind` 没有这一项;数据包还要落到「某个世界的 `datapacks/`」,现在没有选择世界这一步 |
|
||||
//!
|
||||
//! ## 为什么进度只有阶段级,没有字节条
|
||||
//!
|
||||
//! 字节级进度要走 `ProgressEmitter`(core 的 tokio 广播),而 presenter 的依赖表里没有
|
||||
//! `tokio` / `futures-util`(`Cargo.toml` 不在本 mission 的 scope),拿不到接收端的 `recv()`。
|
||||
//! 所以这里给的是**阶段 + 文件名**(`Planning` → `Downloading` → `Done`/`Failed`),
|
||||
//! 面板在「进度未知」时本来就只画一行说明、不画条(`download/install.rs` 的既定口径)。
|
||||
//! 已登记为后续项:presenter 补 `futures-util` 之后换成字节级。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use unlucky_core::addon::online::{
|
||||
resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::game::install::instance::read_instance_config;
|
||||
use unlucky_core::game::isolation::derive_game_dir;
|
||||
|
||||
use crate::dto::{
|
||||
loader_facet, ContentFailure, ContentFailureKind, ContentInstallRequest, ContentSource,
|
||||
InstallProgressView, InstallStage, InstanceSummary,
|
||||
};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::super::job::{HostTask, JobSender};
|
||||
mod collect;
|
||||
|
||||
pub(super) use collect::{collect, Placement, Ports};
|
||||
|
||||
use super::{downloads, plan};
|
||||
|
||||
/// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。
|
||||
pub(super) fn install(
|
||||
env: &HostEnv,
|
||||
tx: &JobSender,
|
||||
request: &ContentInstallRequest,
|
||||
cancel: &AtomicBool,
|
||||
epoch: u64,
|
||||
) {
|
||||
let mut progress = InstallProgressView {
|
||||
version: request.title.clone(),
|
||||
instance: request.instance.clone(),
|
||||
loader: request.loader.label().to_string(),
|
||||
// 文件数要等解析完才知道(含前置),这里留 0 = 未知(面板画不确定态)。
|
||||
files_total: 0,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
|
||||
// 整合包:core 没有这条管线(M111),**不**假装能装。UI 侧按钮本来就是禁用的,
|
||||
// 这里再挡一次是为了「绕过 UI 直接发指令」也不会静默做半件事。
|
||||
// 重试模式不看 `kind`:那种模式下落盘目录来自上一次的计划(见 `run`)。
|
||||
if !request.retry && request.kind.dir().is_none() {
|
||||
fail(
|
||||
tx,
|
||||
progress,
|
||||
ContentFailure::unsupported("整合包安装尚未开放;内容库对整合包只做检索"),
|
||||
epoch,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = tx.send(HostTask::Install {
|
||||
progress: Box::new(progress.clone()),
|
||||
epoch,
|
||||
});
|
||||
match run(env, request, cancel, &mut progress, tx, epoch) {
|
||||
Ok(done) => {
|
||||
finish(&mut progress);
|
||||
progress.message = done;
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Ok(request.title.clone()),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
Err(failure) => {
|
||||
progress.stage = if cancel.load(Ordering::SeqCst) {
|
||||
InstallStage::Cancelled
|
||||
} else {
|
||||
InstallStage::Failed
|
||||
};
|
||||
progress.message = failure.detail.clone();
|
||||
progress.failure_kind = Some(failure.kind);
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Err(failure),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(tx: &JobSender, mut progress: InstallProgressView, failure: ContentFailure, epoch: u64) {
|
||||
progress.stage = InstallStage::Failed;
|
||||
progress.message = failure.detail.clone();
|
||||
progress.failure_kind = Some(failure.kind);
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Err(failure),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
|
||||
/// 真正干活的那一段:解析(含前置)→ 逐个下载 → 汇总。
|
||||
///
|
||||
/// 返回「装了哪些 / 跳过了哪些」的人读描述;解析阶段的两档失败由
|
||||
/// [`plan::failure_of_plan`] 变成一条带大类的失败(**前置缺了不算装成**)。
|
||||
///
|
||||
/// 两条解析入口,别混:
|
||||
///
|
||||
/// | 入口 | 请求从哪来 | 发几次 |
|
||||
/// |---|---|---|
|
||||
/// | 首次装入 | 根项目 + 过滤条件([`plan::request`]) | 一次,从根开始 BFS |
|
||||
/// | **只重试失败项** | 上一轮计划的 `retry_request()`([`plan::retry_plan`]) | 一次,**只含失败的那几个项目** |
|
||||
fn run(
|
||||
env: &HostEnv,
|
||||
request: &ContentInstallRequest,
|
||||
cancel: &AtomicBool,
|
||||
progress: &mut InstallProgressView,
|
||||
tx: &JobSender,
|
||||
epoch: u64,
|
||||
) -> Result<String, ContentFailure> {
|
||||
let instance = find_instance(env, &request.instance)?;
|
||||
|
||||
// 「只重试这些」:输入是上一轮那份计划,**不**重新发起根项目安装。
|
||||
// 没有记录时(换了实例 / 进程重启过)回一条失败,而不是退化成「整单重装」。
|
||||
if request.retry {
|
||||
let Some(saved) = plan::remembered(env, &request.instance) else {
|
||||
return Err(local(RETRY_LOST));
|
||||
};
|
||||
// 面板头继续显示那次的显示名(不是项目 id:r1 复审 N5)
|
||||
progress.version = saved.title.clone();
|
||||
let present = plan::present_files(&saved.dir);
|
||||
let core_engine = super::super::engine::engine(env);
|
||||
let dependency_plan = plan::retry_plan(&saved.plan, present, |retry| {
|
||||
let transport = EngineTransport::new(&core_engine);
|
||||
let client = Modrinth::new(&transport);
|
||||
block_on(resolve(&client, retry))
|
||||
});
|
||||
let fetcher = EngineArtifactFetcher::new(&core_engine);
|
||||
let title = |id: &str| plan::fetch_title(env, id);
|
||||
let ports = Ports {
|
||||
env,
|
||||
fetcher: &fetcher,
|
||||
title: &title,
|
||||
};
|
||||
return collect(
|
||||
&ports,
|
||||
request,
|
||||
dependency_plan,
|
||||
Placement {
|
||||
dir: &saved.dir,
|
||||
root: plan::Root {
|
||||
id: &saved.root,
|
||||
title: &saved.title,
|
||||
},
|
||||
},
|
||||
cancel,
|
||||
progress,
|
||||
tx,
|
||||
epoch,
|
||||
);
|
||||
}
|
||||
|
||||
let game_dir = game_dir_of(env, &instance)?;
|
||||
let dir = request
|
||||
.kind
|
||||
.dir()
|
||||
.ok_or_else(|| local("该内容类型不支持单文件装入"))?;
|
||||
let target_dir = game_dir.join(dir);
|
||||
std::fs::create_dir_all(&target_dir)
|
||||
.map_err(|error| local(format!("建目录 {} 失败:{error}", target_dir.display())))?;
|
||||
if request.source != ContentSource::Modrinth {
|
||||
return Err(ContentFailure::unsupported(
|
||||
"CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)",
|
||||
));
|
||||
}
|
||||
|
||||
// 解析:根项目 + 只跟 `required` 的前置(顺序与父子关系由 core 给,这里不重排)。
|
||||
let resolve_request = plan::request(
|
||||
&request.project,
|
||||
&request.game_version,
|
||||
loader_facet(request.loader),
|
||||
plan::present_files(&target_dir),
|
||||
);
|
||||
let core_engine = super::super::engine::engine(env);
|
||||
let dependency_plan = {
|
||||
let transport = EngineTransport::new(&core_engine);
|
||||
let client = Modrinth::new(&transport);
|
||||
block_on(resolve(&client, &resolve_request))
|
||||
};
|
||||
let fetcher = EngineArtifactFetcher::new(&core_engine);
|
||||
let title = |id: &str| plan::fetch_title(env, id);
|
||||
let ports = Ports {
|
||||
env,
|
||||
fetcher: &fetcher,
|
||||
title: &title,
|
||||
};
|
||||
collect(
|
||||
&ports,
|
||||
request,
|
||||
dependency_plan,
|
||||
Placement {
|
||||
dir: &target_dir,
|
||||
root: plan::Root {
|
||||
id: &request.project,
|
||||
title: &request.title,
|
||||
},
|
||||
},
|
||||
cancel,
|
||||
progress,
|
||||
tx,
|
||||
epoch,
|
||||
)
|
||||
}
|
||||
|
||||
/// 没有可重试的记录时那句话(**不**退化成「整单重装」)。
|
||||
const RETRY_LOST: &str =
|
||||
"没有可重试的记录:这次失败发生在别的实例上、或进程重启过。请重新装入那一次。";
|
||||
|
||||
/// 完成态:`files_done` 对齐 `files_total`。
|
||||
///
|
||||
/// 别在这里另写数字:以前写死 `1`,装 3 个文件后终态是 `1/3`,下载页只要「已知」就画确定态
|
||||
/// 进度条 ⇒ 装完了条还停在 1/3(全部跳过时更糟,`1/0`)。
|
||||
pub(super) fn finish(progress: &mut InstallProgressView) {
|
||||
progress.stage = InstallStage::Done;
|
||||
progress.files_done = progress.files_total;
|
||||
}
|
||||
|
||||
/// 进度面板上的那一行文件名(前置标出来,用户才知道多出来的文件是什么)。
|
||||
pub(super) fn node_label(node: &PlannedNode) -> String {
|
||||
if node.is_root() {
|
||||
node.file.filename.clone()
|
||||
} else {
|
||||
format!("{}(前置)", node.file.filename)
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地失败(找实例 / 建目录 / 取消)——不是网络问题,归到 `Other`。
|
||||
pub(super) fn local(reason: impl Into<String>) -> ContentFailure {
|
||||
ContentFailure::new(ContentFailureKind::Other, reason)
|
||||
}
|
||||
|
||||
fn find_instance(env: &HostEnv, id: &str) -> Result<InstanceSummary, ContentFailure> {
|
||||
super::super::game::instances(env)
|
||||
.into_iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.ok_or_else(|| local(format!("找不到实例 {id}")))
|
||||
}
|
||||
|
||||
/// 实例的运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
|
||||
fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, ContentFailure> {
|
||||
let global = super::super::game::global_mode(env);
|
||||
let config = read_instance_config(&env.layout, &instance.id)
|
||||
.map_err(|error| local(format!("读实例设置失败:{error}")))?;
|
||||
derive_game_dir(
|
||||
&env.game,
|
||||
&instance.version_id,
|
||||
global,
|
||||
config.as_ref(),
|
||||
None,
|
||||
)
|
||||
.map(|derivation| derivation.game_dir)
|
||||
.map_err(|error| local(format!("隔离目录派生失败:{error}")))
|
||||
}
|
||||
|
||||
/// 只留文件名本身:站点给的名字可能带路径分隔符,落盘前必须砍掉
|
||||
/// (与 `core::addon::pack::path` 的穿越双检同一条纪律)。
|
||||
pub(super) fn safe_file_name(raw: &str) -> String {
|
||||
let name = raw
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(raw)
|
||||
.trim()
|
||||
.to_string();
|
||||
if name.is_empty() || name == "." || name == ".." {
|
||||
"download.bin".to_string()
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! 解析之后的公共尾巴:进度 → 逐个下载 → 收口(失败名单 / 计划留存)。
|
||||
//!
|
||||
//! 从 `install.rs` 拆出(r3 复审的接线守卫加完 `Ports` 之后父文件到 415 行,越过 400 行硬线)。
|
||||
//! 拆法同既有 `-p1/-p2`:**纯搬移**,调用方路径不变(`install.rs` 仍 `pub(super) use` 这三个名字)。
|
||||
//!
|
||||
//! 为什么不把 `Ports` 再往里藏:`collect` 是**要能在用例里真跑**的那一段(r3 复审 p2:
|
||||
//! 「失败路径真的去取了名字」原来零覆盖)。下载器与取名字器从参数进来,假实现就能驱动整条
|
||||
//! 收口路径,不碰网络。
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use unlucky_core::addon::online::{ArtifactFetcher, DependencyPlan};
|
||||
|
||||
use crate::dto::{ContentFailure, ContentInstallRequest, InstallProgressView, InstallStage};
|
||||
|
||||
use crate::host::env::HostEnv;
|
||||
use crate::host::job::{HostTask, JobSender};
|
||||
|
||||
use super::{downloads, plan};
|
||||
|
||||
/// `collect` 要用的「外部世界」:宿主环境 + 下载器 + 取名字器。
|
||||
///
|
||||
/// 三样都从这里进,是为了让**收口那段能在用例里真跑**:假下载器 + 假取名字器就能驱动
|
||||
/// 「下载 → 收口 → 失败名单」整条路,于是「失败路径真的去取了不兼容档的名字」这件事有用例
|
||||
/// 钉住(r3 复审 p2:那一行原来删掉也全绿)。
|
||||
pub(in crate::host::addon) struct Ports<'a> {
|
||||
/// 宿主环境(记「上一轮的计划」要用它的数据根)
|
||||
pub(in crate::host::addon) env: &'a HostEnv,
|
||||
/// 下载器(生产 = 引擎;用例 = 假实现,不碰网络)
|
||||
pub(in crate::host::addon) fetcher: &'a dyn ArtifactFetcher,
|
||||
/// 取项目显示名(生产 = [`plan::fetch_title`];用例 = 假实现)
|
||||
pub(in crate::host::addon) title: &'a dyn Fn(&str) -> Option<String>,
|
||||
}
|
||||
|
||||
/// 「装到哪 + 那次装入的根是谁」——总是一起出现,捆成一个参数(否则 `collect` 顶破 8 个)。
|
||||
pub(in crate::host::addon) struct Placement<'a> {
|
||||
/// 落盘目录(`mods/` / `resourcepacks/` / `shaderpacks/`)
|
||||
pub(in crate::host::addon) dir: &'a Path,
|
||||
/// 当初那次装入的根项目(id + 显示名);重试轮里由 `plan::remembered` 提供
|
||||
///
|
||||
/// **必须**带 id:重试那一轮 `plan.request.roots` 是上一轮的失败名单,不能拿它判「是不是根」
|
||||
/// (r2 复审 P2-2 就是这条:第一前置被贴上了根的标题)。
|
||||
pub(in crate::host::addon) root: plan::Root<'a>,
|
||||
}
|
||||
|
||||
/// 解析之后**两条入口共用**的尾巴:进度 → 逐个下载 → 收口。
|
||||
///
|
||||
/// 收口的两件事在同一个地方做,是因为它们必须同时发生:
|
||||
///
|
||||
/// | 结果 | 计划 | 可重试名单 |
|
||||
/// |---|---|---|
|
||||
/// | 有失败 | 记下(`plan::remember`,重试的输入) | 填(界面据此画「只重试这 N 个」) |
|
||||
/// | 全成功 | 忘掉(`plan::forget`) | 清空(界面**不渲染**那条入口) |
|
||||
pub(in crate::host::addon) fn collect(
|
||||
ports: &Ports<'_>,
|
||||
request: &ContentInstallRequest,
|
||||
mut dependency_plan: DependencyPlan,
|
||||
placement: Placement<'_>,
|
||||
cancel: &AtomicBool,
|
||||
progress: &mut InstallProgressView,
|
||||
tx: &JobSender,
|
||||
epoch: u64,
|
||||
) -> Result<String, ContentFailure> {
|
||||
progress.stage = InstallStage::Downloading;
|
||||
progress.files_total = dependency_plan.nodes.len() as u64;
|
||||
progress.files_done = 0;
|
||||
progress.message = if dependency_plan.nodes.is_empty() {
|
||||
"没有要下载的文件".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{} 个文件(含 {} 个前置)",
|
||||
dependency_plan.nodes.len(),
|
||||
dependency_plan
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| !node.is_root())
|
||||
.count()
|
||||
)
|
||||
};
|
||||
let _ = tx.send(HostTask::Install {
|
||||
progress: Box::new(progress.clone()),
|
||||
epoch,
|
||||
});
|
||||
|
||||
// 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。
|
||||
let installed = downloads::download_stage(
|
||||
&mut dependency_plan,
|
||||
placement.dir,
|
||||
ports.fetcher,
|
||||
cancel,
|
||||
progress,
|
||||
tx,
|
||||
epoch,
|
||||
)?;
|
||||
|
||||
// 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」),
|
||||
// 并列出「可只重试这 N 个」。
|
||||
if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) {
|
||||
// 「不兼容」那一档项目**存在**(站点只是没有匹配目标版本/加载器的条目),所以在这里
|
||||
// 现取一次名字——只在失败路径上发、一个项目一次 GET,取不到就退回 id(见 `names_for_incompatible`)。
|
||||
let names = plan::names_for_incompatible(&dependency_plan, ports.title);
|
||||
progress.retry = plan::retry_items(&dependency_plan, placement.root, &names);
|
||||
plan::remember(
|
||||
ports.env,
|
||||
&request.instance,
|
||||
placement.dir,
|
||||
placement.root.id,
|
||||
placement.root.title,
|
||||
&dependency_plan,
|
||||
);
|
||||
return Err(failure);
|
||||
}
|
||||
progress.retry.clear();
|
||||
plan::forget(ports.env, &request.instance);
|
||||
Ok(plan::summary(&dependency_plan, &installed))
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! 整合包**导入 / 导出**(M113 `PACKUI`):把 M111/M112 的管线接到内容库页上。
|
||||
//!
|
||||
//! ## 这里只做「解析目标 + 翻译 + 转发」
|
||||
//!
|
||||
//! 真正的活全在 core:`addon::import::import_pack`(三种格式 → 实例目录)与
|
||||
//! `addon::export::{scan_instance, export_instance}`(实例目录 → `.mrpack` / CF 清单)。
|
||||
//! 本模块不重新判定任何东西——三态、逐项原因、三分类全部**逐字段照搬** core 的报告
|
||||
//! ([`convert`]),于是「UI 说的」与「管线干的」是同一份事实。
|
||||
//!
|
||||
//! ## 文件分工(守 400 行)
|
||||
//!
|
||||
//! | 文件 | 职责 |
|
||||
//! |---|---|
|
||||
//! | 本文件 | `CoreHost::pack` 落点 + 取消标记 + 三条 spawn 共用的目标解析 |
|
||||
//! | [`import`] | 后台导入(探测 → 目标目录 → 管线 → 报告) |
|
||||
//! | [`export`] | 后台预检(不写盘)与后台写盘 |
|
||||
//! | [`convert`] | core 报告 → DTO(逐字段照搬)+ 小工具 |
|
||||
//! | [`diagnose`] | 探测诊断:「看起来像 X,但缺 Y」 |
|
||||
//!
|
||||
//! ## 导出预检与写盘用同一套判据
|
||||
//!
|
||||
//! core 的 `export_instance` 没有 dry-run 入口(已报 finding)。预检用 `scan_instance` +
|
||||
//! 「离线目录 ⇒ 一律内联」这条事实拼出来:`RemoteCatalog` 目前只有 `OfflineCatalog`,
|
||||
//! 所以每个可扫文件都证明不了可被远程替代 ⇒ 内联;`SkippedLocal` 就是「无法导出」。
|
||||
//! `tests::preview_counts_match_the_written_report` 用真管线钉住这个等式。
|
||||
//!
|
||||
//! ## 取消是**管线级**的(M195 起)
|
||||
//!
|
||||
//! [`cancel_pack`] 置模块级标记 + `dispatch` 里的 `CancelInstall` 把代次 +1,而**同一个标记**
|
||||
//! 从 M195 起就是 core 的取消令牌(`ImportRequest::with_cancel`):
|
||||
//!
|
||||
//! 1. 面板立刻收回(不是等后台回一句话);
|
||||
//! 2. 这一轮之后回来的事件因代次不符被 `handle` 丢掉;
|
||||
//! 3. **后台那次导入真的停手**:core 在阶段边界与长循环内检查令牌,停下就回滚(暂存目录删掉、
|
||||
//! 落盘过的撤回、实例不登记),结果是 `ImportStatus::Cancelled` 而**不是**失败。响应延迟的
|
||||
//! 底线是「当前这一条文件读完/下完」,见 `core::addon::import::cancel` 的逐段表;
|
||||
//!
|
||||
//! **收尾永远把真实结局送到界面**(M195 r3):取消可能落在最后一个检查点之后 —— 那时管线
|
||||
//! 已经跑完(用户的同名文件被覆盖、实例已登记),也可能回滚没做完。这两种结果以前被
|
||||
//! `cancel` 的早退 + 代次过滤一起丢掉,界面只剩一句无条件的"已停手并回滚"。现在
|
||||
//! `import::finish` 无条件发 `PackEvent::Report`,`HostPackState::terminal_epochs` 给
|
||||
//! "刚被取消的那一次"留一条**只放行 Report** 的一次性通道([`take_terminal_epoch`]),
|
||||
//! DTO 的 `ImportReportView::cancel` 三档把区别带到界面;
|
||||
//! 4. **提示语不能借用安装那条**(复审 r1 P1-2):`cancel_pack()` 回一个「刚才有没有导入在跑」,
|
||||
//! `dispatch` 据此改发 [`crate::dto::PackEvent::Cancelled`]。取消**通常**意味着实例没建
|
||||
//! (管线回滚了),但取消也可能恰好在两个检查点之间落下(那时已经装完并登记)——所以文案
|
||||
//! 只承诺「本次结果作废 + 目标目录已回滚到导入前」,不承诺「一条命令都没执行」。
|
||||
|
||||
mod convert;
|
||||
mod diagnose;
|
||||
mod export;
|
||||
mod import;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use unlucky_core::addon::export::{read_instance_snapshot, InstanceSnapshot};
|
||||
use unlucky_core::addon::pack::PackFormat;
|
||||
use unlucky_core::game::install::instance::read_instance_config;
|
||||
use unlucky_core::game::isolation::derive_game_dir;
|
||||
|
||||
use crate::dto::{
|
||||
CoreEvent, ExportRequestView, ImportProgressView, InstanceId, PackCommand, PackEvent,
|
||||
PackFormatView,
|
||||
};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::super::job::{HostTask, JobSender};
|
||||
|
||||
/// pack 的**每 host** 状态:代次 + 在跑计数 + 本次运行的取消标记。
|
||||
///
|
||||
/// ## 为什么不是裸静态量(复审 r2 P3-6 要的那句回答)
|
||||
///
|
||||
/// 裸静态量是**跨 host 共享**的。生产路径只有一个 host 所以看不出来,但测试里两个 host 会互相
|
||||
/// 干扰 —— 本轮实测撞到过:`host/cancel_tests` 派一次 `CancelInstall` 就把我这条用例在飞的导入
|
||||
/// 事件按代次丢了。键取 `LauncherLayout::home()`(每个测试的临时根都不同),于是各自独立:
|
||||
///
|
||||
/// - **代次**:每个 host 一条,`handle` 只跟**自己这个 host** 的当前值比;
|
||||
/// - **在跑计数**:一张计数表(每个 host 一格),由 [`InFlight`] 的 `Drop` 维护;
|
||||
/// - **取消标记**:每个 host 一份 `Arc<AtomicBool>`,导入线程拿克隆,`cancel_pack` 置位。
|
||||
///
|
||||
/// 为什么不挪进 `CoreHost`:那要给 `host/mod.rs` 加字段(不在本 mission 的 scope)。这张表等价,
|
||||
/// 只是把「每 host 的字段」放在模块里;`host/mod.rs` 一旦进 scope,搬过去只是把这张表删掉。
|
||||
#[derive(Default)]
|
||||
struct HostPackState {
|
||||
epoch: u64,
|
||||
in_flight: usize,
|
||||
/// 「刚被取消、还欠界面一份终态报告」的那几个代次(M195 r3)。
|
||||
///
|
||||
/// `cancel_pack` 会把代次 +1,于是被取消那次运行交上来的 `PackEvent::Report` 会被 `handle`
|
||||
/// 按代次丢掉——可那份报告才是**真实结局**(停手并回滚 / 回滚没做完 / 取消太晚其实已装完)。
|
||||
/// 记在这里的代次允许**一条** `Report` 穿透代次过滤,取走即销;新一轮导入(`fresh_cancel`)
|
||||
/// 清空整表,旧运行的报告不会污染新面板。
|
||||
terminal_epochs: Vec<u64>,
|
||||
}
|
||||
|
||||
static PACK_STATE: Mutex<Vec<(PathBuf, HostPackState)>> = Mutex::new(Vec::new());
|
||||
static PACK_CANCEL: Mutex<Vec<(PathBuf, Arc<AtomicBool>)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// 这张表的键(数据根;`HostEnv::at_root` 的每个测试根都不同)。
|
||||
fn host_key(env: &HostEnv) -> PathBuf {
|
||||
env.layout.home().to_path_buf()
|
||||
}
|
||||
|
||||
fn with_state<T>(env: &HostEnv, body: impl FnOnce(&mut HostPackState) -> T) -> T {
|
||||
let key = host_key(env);
|
||||
let mut table = PACK_STATE
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
if let Some(index) = table.iter().position(|(name, _)| *name == key) {
|
||||
return body(&mut table[index].1);
|
||||
}
|
||||
table.push((key, HostPackState::default()));
|
||||
body(&mut table.last_mut().expect("刚插入").1)
|
||||
}
|
||||
|
||||
/// 这个 host 当前的 pack 代次(`handle` 用它判断事件是否已被取消/关面板作废)。
|
||||
pub(in crate::host) fn pack_epoch(env: &HostEnv) -> u64 {
|
||||
with_state(env, |state| state.epoch)
|
||||
}
|
||||
|
||||
/// 作废这个 host 在飞的一切 pack 事件(取消 / 关面板都走它)。
|
||||
pub(in crate::host) fn bump_pack_epoch(env: &HostEnv) -> u64 {
|
||||
with_state(env, |state| {
|
||||
state.epoch = state.epoch.wrapping_add(1);
|
||||
state.epoch
|
||||
})
|
||||
}
|
||||
|
||||
/// 这个 host 现在有几条导入在跑。
|
||||
pub(in crate::host) fn in_flight(env: &HostEnv) -> usize {
|
||||
with_state(env, |state| state.in_flight)
|
||||
}
|
||||
|
||||
/// 「这次导入还在跑」的 **RAII 句柄**。
|
||||
///
|
||||
/// 复审 r2 P2-1 的教训:手工 `finished()` 只在 `block_on(import_pack(..))` 之后调用,
|
||||
/// 而失败早退(探测失败 / 目标目录不可用)都在它之前 ⇒ 计数永远留在「在跑」,于是下一次
|
||||
/// **下载页**的「取消安装」不再发 `InstallCancelled`,还给内容库画一句「导入还在跑」的假话。
|
||||
///
|
||||
/// 现在计数由句柄的 `Drop` 负责:**任何**退出路径(早退、报错、`?`、panic 展开)都会减一。
|
||||
pub(super) struct InFlight {
|
||||
env: HostEnv,
|
||||
}
|
||||
|
||||
impl InFlight {
|
||||
/// 计一次「在跑」。句柄活多久,这次导入就算跑多久。
|
||||
pub(super) fn begin(env: &HostEnv) -> Self {
|
||||
with_state(env, |state| state.in_flight += 1);
|
||||
Self { env: env.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InFlight {
|
||||
fn drop(&mut self) {
|
||||
with_state(&self.env, |state| {
|
||||
state.in_flight = state.in_flight.saturating_sub(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消这个 host 正在跑的导入(`CancelInstall` 与「关面板」都走它;没在跑时是 no-op)。
|
||||
///
|
||||
/// 返回值 = 「刚才确实有一次导入在跑」。调用方据此决定提示语:**导入的取消不是 install 的取消**
|
||||
/// (M195 起 core 真的停手并回滚,见模块文档),所以不能复用下载页那条「安装已取消、实例未创建」。
|
||||
pub(in crate::host) fn cancel_pack(env: &HostEnv) -> bool {
|
||||
let key = host_key(env);
|
||||
{
|
||||
let table = PACK_CANCEL
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
if let Some((_, cancel)) = table.iter().find(|(name, _)| *name == key) {
|
||||
cancel.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let running = in_flight(env) > 0;
|
||||
if running {
|
||||
// 记下**马上要被作废的那个代次**:那次运行随后交上来的报告是它的真实结局,必须能穿透
|
||||
// 代次过滤(否则"取消太晚、其实已经装完"在界面上永远不成立)。重复取消只记一次。
|
||||
let stale = pack_epoch(env);
|
||||
with_state(env, |state| {
|
||||
if !state.terminal_epochs.contains(&stale) {
|
||||
state.terminal_epochs.push(stale);
|
||||
}
|
||||
});
|
||||
}
|
||||
bump_pack_epoch(env);
|
||||
running
|
||||
}
|
||||
|
||||
/// 这个代次是不是「刚被取消、还欠一份终态报告」的那一次(**取走即销**:只放行一条)。
|
||||
///
|
||||
/// 只给 `PackEvent::Report` 用:取消之后的**进度**事件仍旧一律按代次丢掉(面板早就收回了,
|
||||
/// 再点亮它就是「点了取消没反应」的旧病)。
|
||||
pub(in crate::host) fn take_terminal_epoch(env: &HostEnv, epoch: u64) -> bool {
|
||||
with_state(env, |state| {
|
||||
match state.terminal_epochs.iter().position(|seen| *seen == epoch) {
|
||||
Some(index) => {
|
||||
state.terminal_epochs.remove(index);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 这个 host 新一次导入的取消标记(挂进表里供 [`cancel_pack`] 用)。
|
||||
///
|
||||
/// 顺手清掉「还欠终态报告」的代次:新一次运行占住面板,上一次的结局不该再冒出来。
|
||||
fn fresh_cancel(env: &HostEnv) -> Arc<AtomicBool> {
|
||||
with_state(env, |state| state.terminal_epochs.clear());
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let key = host_key(env);
|
||||
let mut table = PACK_CANCEL
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
match table.iter_mut().find(|(name, _)| *name == key) {
|
||||
Some((_, slot)) => *slot = Arc::clone(&cancel),
|
||||
None => table.push((key, Arc::clone(&cancel))),
|
||||
}
|
||||
cancel
|
||||
}
|
||||
|
||||
/// `AppCommand::Pack` 的落点(M113)。
|
||||
impl super::super::CoreHost {
|
||||
pub(crate) fn pack(&mut self, command: PackCommand) {
|
||||
let epoch = pack_epoch(&self.env);
|
||||
match command {
|
||||
PackCommand::Scan(request) => {
|
||||
// 预检只读:不点亮导入面板(那会让用户以为已经在装了),也不计"在跑导入"。
|
||||
let cancel = fresh_cancel(&self.env);
|
||||
import::spawn_scan(self.env.clone(), self.tx.clone(), *request, epoch, cancel);
|
||||
}
|
||||
PackCommand::Import(request) => {
|
||||
let cancel = fresh_cancel(&self.env);
|
||||
// 句柄在**派发点**就计上,随线程一起结束(含早退与 panic)
|
||||
let guard = InFlight::begin(&self.env);
|
||||
// 先把面板点亮:用户点了就有反应,不等后台回第一条进度
|
||||
let name = request
|
||||
.instance_name
|
||||
.clone()
|
||||
.or_else(|| request.instance.clone())
|
||||
.unwrap_or_default();
|
||||
self.send(CoreEvent::Pack(Box::new(PackEvent::Progress(Some(
|
||||
Box::new(ImportProgressView::new(name)),
|
||||
)))));
|
||||
import::spawn_import(
|
||||
self.env.clone(),
|
||||
self.tx.clone(),
|
||||
*request,
|
||||
epoch,
|
||||
cancel,
|
||||
guard,
|
||||
);
|
||||
}
|
||||
PackCommand::Export(request) => {
|
||||
export::spawn_preview(self.env.clone(), self.tx.clone(), *request, epoch);
|
||||
}
|
||||
PackCommand::Confirm(request) => {
|
||||
export::spawn_write(self.env.clone(), self.tx.clone(), *request, epoch);
|
||||
}
|
||||
PackCommand::Dismiss => {
|
||||
// 只作废 pack 自己的代次:**不碰** `install_epoch`(那是下载页安装的命门)
|
||||
cancel_pack(&self.env);
|
||||
self.send(CoreEvent::Pack(Box::new(PackEvent::Cleared)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台任务 → host 的一跳(三个 spawn 共用)。
|
||||
pub(super) fn send(tx: &JobSender, epoch: u64, event: PackEvent) {
|
||||
let _ = tx.send(HostTask::Pack {
|
||||
event: Box::new(event),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
|
||||
/// 实例 → 运行目录(与 `addon/install.rs` 同一条派生:实例隔离档 > 全局档)。
|
||||
pub(super) fn instance_root(env: &HostEnv, id: &InstanceId) -> Result<PathBuf, String> {
|
||||
let instance = super::super::game::instances(env)
|
||||
.into_iter()
|
||||
.find(|instance| &instance.id == id)
|
||||
.ok_or_else(|| format!("instance not found: {id}"))?;
|
||||
let global = super::super::game::global_mode(env);
|
||||
let config = read_instance_config(&env.layout, &instance.id)
|
||||
.map_err(|error| format!("read instance config failed: {error}"))?;
|
||||
derive_game_dir(
|
||||
&env.game,
|
||||
&instance.version_id,
|
||||
global,
|
||||
config.as_ref(),
|
||||
None,
|
||||
)
|
||||
.map(|derivation| derivation.game_dir)
|
||||
.map_err(|error| format!("derive game dir failed: {error}"))
|
||||
}
|
||||
|
||||
/// 实例快照(清单的 `game` + `dependencies` 从它来)。读不出来就返回 `None`,
|
||||
/// 由 core 自己按 `root/versions/` 再试一次(失败会给具名错误)。
|
||||
pub(super) fn snapshot_of(env: &HostEnv, id: &InstanceId) -> Option<InstanceSnapshot> {
|
||||
let instance = super::super::game::instances(env)
|
||||
.into_iter()
|
||||
.find(|instance| &instance.id == id)?;
|
||||
// 隔离实例的 `versions/` 在全局根下,所以试全局根
|
||||
read_instance_snapshot(env.game.root(), Some(&instance.version_id)).ok()
|
||||
}
|
||||
|
||||
/// 格式视图 → core 的静态格式(MultiMC 只读,不能当导出目标)。
|
||||
pub(super) fn format_of(format: PackFormatView) -> Result<&'static dyn PackFormat, String> {
|
||||
use unlucky_core::addon::pack::{curseforge, modrinth};
|
||||
match format {
|
||||
PackFormatView::Modrinth => Ok(&modrinth::MODRINTH),
|
||||
PackFormatView::Curseforge => Ok(&curseforge::CURSEFORGE),
|
||||
PackFormatView::Multimc => {
|
||||
Err("MultiMC / Prism can only be imported, not exported".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 缺省落盘路径:`<data>/exports/<name>-<version>.<ext>`。
|
||||
pub(super) fn default_export_path(env: &HostEnv, request: &ExportRequestView) -> PathBuf {
|
||||
let ext = match request.format {
|
||||
PackFormatView::Curseforge => "zip",
|
||||
_ => "mrpack",
|
||||
};
|
||||
let name = format!(
|
||||
"{}-{}",
|
||||
convert::slug(&request.name),
|
||||
convert::slug(&request.version)
|
||||
);
|
||||
env.layout
|
||||
.data()
|
||||
.join("exports")
|
||||
.join(format!("{name}.{ext}"))
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! core 报告 → DTO(M113):**逐字段照搬,不重算任何结论**。
|
||||
//!
|
||||
//! 这一层的唯一职责是翻译:三态、五阶段、四种处置、三种冲突成因、十一种导出原因,
|
||||
//! 全部一一对应。任何一个「顺手换算一下」都会让 UI 说的和管线干的不是一回事,
|
||||
//! 所以这里没有一处 re-derive(唯一例外是把 `&'static str` 的稳定码换成人读串)。
|
||||
//!
|
||||
//! 翻译用的是 core 的稳定 slug(`as_str()`)+ DTO 的 `from_slug()`,
|
||||
//! 于是「翻译表」本身就是一份可审计的清单:core 加了新变体,`from_slug` 返回 `None`
|
||||
//! 时这里会落到一个有名字的兜底,而不是悄悄画成别的意思。
|
||||
|
||||
mod cancel;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::addon::export::{ExportReport, FileClass, FileReason};
|
||||
use unlucky_core::addon::import::report::ImportStatus;
|
||||
use unlucky_core::addon::import::{
|
||||
ConflictChoice, ConflictDecisions, ConflictFile, ConflictReason, FileAction, FileSource,
|
||||
ImportError, ImportProgress, ImportReport, ImportScan, LoaderTarget, MissingReason,
|
||||
};
|
||||
|
||||
use crate::dto::{
|
||||
ConflictChoiceView, ConflictDecisionsView, ConflictFileView, ConflictReasonView,
|
||||
ConflictSourceView, ExportClassView, ExportDoneView, ExportReasonView, FileActionView,
|
||||
ImportConflictView, ImportErrorView, ImportFailureStageView, ImportProgressView,
|
||||
ImportReportView, ImportScanView, ImportStageView, ImportStatusView, ImportedFileView,
|
||||
MissingFileView, MissingReasonView, PackFormatView,
|
||||
};
|
||||
|
||||
/// 一次导入**预检** → DTO(M160:冲突清单)。
|
||||
pub(super) fn scan_view(scan: &ImportScan) -> ImportScanView {
|
||||
ImportScanView {
|
||||
target: scan.target.clone(),
|
||||
format: scan.format.as_deref().and_then(PackFormatView::from_slug),
|
||||
pack_name: scan.pack_name.clone(),
|
||||
pack_version: scan.pack_version.clone(),
|
||||
game_version: scan.game_version.clone(),
|
||||
conflicts: scan
|
||||
.conflicts
|
||||
.iter()
|
||||
.map(|conflict| ImportConflictView {
|
||||
path: conflict.path.clone(),
|
||||
source: source_view(conflict.source),
|
||||
reason: reason_of(conflict.reason),
|
||||
disabled: conflict.disabled,
|
||||
pack_sha1: conflict.pack_sha1.clone(),
|
||||
pack_size: conflict.pack_size,
|
||||
disk_sha1: conflict.disk_sha1.clone(),
|
||||
disk_size: conflict.disk_size,
|
||||
base_sha1: conflict.base_sha1.clone(),
|
||||
})
|
||||
.collect(),
|
||||
unknown_remote: scan.unknown_remote,
|
||||
lost_bytes: scan.lost_bytes(),
|
||||
incoming_bytes: scan.incoming_bytes(),
|
||||
notes: scan
|
||||
.notes
|
||||
.iter()
|
||||
.map(|issue| issue.message.clone())
|
||||
.collect(),
|
||||
error: scan.error.as_ref().map(error_view),
|
||||
}
|
||||
}
|
||||
|
||||
/// DTO 的决策 → core 的决策表(**形状一换,语义不换**)。
|
||||
///
|
||||
/// 三条入口一一对应;没点名的路径在 core 侧落到"保留"(`ConflictDecisions` 的缺省档),
|
||||
/// 所以"页面少传一条"永远不会变成"覆盖"。
|
||||
pub(super) fn decisions_of(view: &ConflictDecisionsView) -> ConflictDecisions {
|
||||
let mut decisions = match view.default_choice() {
|
||||
ConflictChoiceView::KeepUser => ConflictDecisions::keep_all(),
|
||||
ConflictChoiceView::Overwrite => ConflictDecisions::overwrite_all(),
|
||||
};
|
||||
for item in view.items() {
|
||||
decisions.set(item.path.clone(), choice_of(item.choice));
|
||||
}
|
||||
decisions
|
||||
}
|
||||
|
||||
const fn choice_of(choice: ConflictChoiceView) -> ConflictChoice {
|
||||
match choice {
|
||||
ConflictChoiceView::KeepUser => ConflictChoice::KeepUser,
|
||||
ConflictChoiceView::Overwrite => ConflictChoice::Overwrite,
|
||||
}
|
||||
}
|
||||
|
||||
const fn source_view(source: FileSource) -> ConflictSourceView {
|
||||
match source {
|
||||
FileSource::Overrides => ConflictSourceView::Overrides,
|
||||
FileSource::ClientOverrides => ConflictSourceView::ClientOverrides,
|
||||
FileSource::CurseForge => ConflictSourceView::CurseForge,
|
||||
FileSource::Modrinth => ConflictSourceView::Modrinth,
|
||||
}
|
||||
}
|
||||
|
||||
const fn reason_of(reason: ConflictReason) -> ConflictReasonView {
|
||||
match reason {
|
||||
ConflictReason::UserModified => ConflictReasonView::UserModified,
|
||||
ConflictReason::UserDeleted => ConflictReasonView::UserDeleted,
|
||||
ConflictReason::PreExisting => ConflictReasonView::PreExisting,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次导入结果 → DTO。
|
||||
///
|
||||
/// `cancel_requested` = 这次运行**被点过取消**(presenter 手里的那个开关)。它只用来区分
|
||||
/// "取消太晚"那一档:管线已经跑完时报告是 `Success`/`Partial`,但那不是一次正常导入——
|
||||
/// 用户的取消没赶上,实例会保留、同名文件可能已被覆盖(M195 r3)。
|
||||
pub(super) fn report_view(
|
||||
report: &ImportReport,
|
||||
target: &Path,
|
||||
cancel_requested: bool,
|
||||
) -> ImportReportView {
|
||||
ImportReportView {
|
||||
status: ImportStatusView::from_slug(report.status.as_str())
|
||||
.unwrap_or(ImportStatusView::Failed),
|
||||
cancel: cancel::cancel_view(report, cancel_requested),
|
||||
format: report.format.as_deref().and_then(PackFormatView::from_slug),
|
||||
pack_name: report.pack_name.clone(),
|
||||
pack_version: report.pack_version.clone(),
|
||||
game_version: report.game_version.clone(),
|
||||
loader: report.loader.as_ref().map(loader_label),
|
||||
version_id: report.version_id.clone(),
|
||||
target: target.to_path_buf(),
|
||||
files: report
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| ImportedFileView {
|
||||
path: file.path.clone(),
|
||||
action: action_view(file.action),
|
||||
bytes: file.bytes,
|
||||
})
|
||||
.collect(),
|
||||
conflicts: report.conflicts.iter().map(conflict_view).collect(),
|
||||
missing: report
|
||||
.missing
|
||||
.iter()
|
||||
.map(|file| MissingFileView {
|
||||
path: file.path.clone(),
|
||||
display: file.display.clone(),
|
||||
reason: missing_view(file.reason),
|
||||
detail: file.detail.clone(),
|
||||
})
|
||||
.collect(),
|
||||
rejected: report
|
||||
.rejected_components
|
||||
.iter()
|
||||
.map(|component| match &component.version {
|
||||
Some(version) => format!("{} {version}", component.uid),
|
||||
None => component.uid.clone(),
|
||||
})
|
||||
.collect(),
|
||||
warnings: report
|
||||
.warnings
|
||||
.iter()
|
||||
.map(|issue| issue.message.clone())
|
||||
.collect(),
|
||||
error: report.error.as_ref().map(error_view),
|
||||
registered: report
|
||||
.registered
|
||||
.as_ref()
|
||||
.map(|registered| registered.name.clone()),
|
||||
bytes_written: report.bytes_written,
|
||||
rolled_back: report.rolled_back,
|
||||
failure_stage: failure_stage(report),
|
||||
}
|
||||
}
|
||||
|
||||
/// 致命失败发生在哪一步(M161)。
|
||||
///
|
||||
/// 判据只用 core 的两个机器位,**不看 warnings 的措辞**:
|
||||
/// - `rollback: Some(outcome)` ⇒ 真的跑过回滚(写过了),完整与否看 `is_complete()`;
|
||||
/// - `rollback: None` ⇒ 没跑过回滚。core 的约定是「失败发生在碰目标目录之前」(`abandoned`),
|
||||
/// 此时 `rolled_back == true`;若 `rolled_back` 也是 `false`(既没回滚也没宣称干净),
|
||||
/// 那就如实报「可能不一致」,不替它圆场。
|
||||
fn failure_stage(report: &ImportReport) -> Option<ImportFailureStageView> {
|
||||
if report.status != ImportStatus::Failed {
|
||||
return None;
|
||||
}
|
||||
Some(match &report.rollback {
|
||||
Some(outcome) if outcome.is_complete() => ImportFailureStageView::RolledBack,
|
||||
Some(_) => ImportFailureStageView::RollbackIncomplete,
|
||||
None if report.rolled_back => ImportFailureStageView::NotStarted,
|
||||
None => ImportFailureStageView::RollbackIncomplete,
|
||||
})
|
||||
}
|
||||
|
||||
/// 一次导出结果 → DTO。
|
||||
pub(super) fn done_view(report: &ExportReport) -> ExportDoneView {
|
||||
ExportDoneView {
|
||||
path: report.path.clone(),
|
||||
bytes: report.bytes,
|
||||
remote: u32::try_from(report.remote_count()).unwrap_or(u32::MAX),
|
||||
inline: u32::try_from(report.inline_count()).unwrap_or(u32::MAX),
|
||||
skipped: u32::try_from(report.skipped_count()).unwrap_or(u32::MAX),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条导入进度 → DTO。
|
||||
pub(super) fn progress_view(progress: &ImportProgress, pack: &str) -> ImportProgressView {
|
||||
ImportProgressView {
|
||||
stage: ImportStageView::from_slug(progress.stage.as_str())
|
||||
.unwrap_or(ImportStageView::Probe),
|
||||
done: u32::try_from(progress.done).unwrap_or(u32::MAX),
|
||||
total: u32::try_from(progress.total).unwrap_or(u32::MAX),
|
||||
current: progress.current.clone(),
|
||||
pack: pack.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 致命失败 → DTO(稳定码原样带走,日志与 `--json` 用的是同一串)。
|
||||
fn error_view(error: &ImportError) -> ImportErrorView {
|
||||
ImportErrorView {
|
||||
code: error.kind().as_str().to_string(),
|
||||
label: error.kind().label().to_string(),
|
||||
message: error.message().to_string(),
|
||||
hint: error.hint().map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
fn conflict_view(file: &ConflictFile) -> ConflictFileView {
|
||||
ConflictFileView {
|
||||
path: file.path.clone(),
|
||||
reason: reason_of(file.reason),
|
||||
choice: choice_view(file.choice),
|
||||
pack_sha1: file.pack_sha1.clone(),
|
||||
base_sha1: file.base_sha1.clone(),
|
||||
disk_sha1: file.disk_sha1.clone(),
|
||||
pack_size: file.pack_size,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条 `ConflictChoice` → DTO(预检与事后账共用一张翻译表)。
|
||||
const fn choice_view(choice: ConflictChoice) -> ConflictChoiceView {
|
||||
match choice {
|
||||
ConflictChoice::KeepUser => ConflictChoiceView::KeepUser,
|
||||
ConflictChoice::Overwrite => ConflictChoiceView::Overwrite,
|
||||
}
|
||||
}
|
||||
|
||||
const fn action_view(action: FileAction) -> FileActionView {
|
||||
match action {
|
||||
FileAction::Installed => FileActionView::Installed,
|
||||
FileAction::Replaced => FileActionView::Replaced,
|
||||
FileAction::SkippedIdentical => FileActionView::SkippedIdentical,
|
||||
FileAction::KeptUser => FileActionView::KeptUser,
|
||||
}
|
||||
}
|
||||
|
||||
const fn missing_view(reason: MissingReason) -> MissingReasonView {
|
||||
match reason {
|
||||
MissingReason::Offline => MissingReasonView::Offline,
|
||||
MissingReason::NoSource => MissingReasonView::NoSource,
|
||||
MissingReason::FetchFailed => MissingReasonView::FetchFailed,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reason_view(reason: FileReason) -> ExportReasonView {
|
||||
ExportReasonView::from_slug(reason.as_str()).unwrap_or(ExportReasonView::Unreadable)
|
||||
}
|
||||
|
||||
pub(super) fn class_view(class: FileClass) -> ExportClassView {
|
||||
match class {
|
||||
FileClass::Remote => ExportClassView::Remote,
|
||||
FileClass::Inline => ExportClassView::Inline,
|
||||
FileClass::Skipped => ExportClassView::Skipped,
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器 → 人读串(`fabric-loader 0.15.11` / `vanilla 1.20.1`)。
|
||||
fn loader_label(target: &LoaderTarget) -> String {
|
||||
format!("{} {}", target.kind.as_str(), target.loader_version)
|
||||
}
|
||||
|
||||
/// 「命令跑起来了,但目标实例不可用」这类**host 侧**失败的报告。
|
||||
///
|
||||
/// 仍然走三态里的 `Failed`:界面只有一种失败形状,用户不用学第二套。
|
||||
/// `target` 是**目标实例目录**(不知道就给空路径),不是包路径(复审 r1 P2-9 顺手纠的 DTO 语义)。
|
||||
pub(super) fn failed_report(
|
||||
target: PathBuf,
|
||||
pack: &Path,
|
||||
code: &str,
|
||||
label: &str,
|
||||
message: &str,
|
||||
) -> ImportReportView {
|
||||
failed_report_with(
|
||||
target,
|
||||
pack,
|
||||
ImportErrorView {
|
||||
code: code.to_string(),
|
||||
label: label.to_string(),
|
||||
message: message.to_string(),
|
||||
hint: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// 一条 core 的 [`ImportError`] → 失败报告(**逐字段照搬**,含 `hint`)。
|
||||
///
|
||||
/// 预检失败(`scan.error`)走它:那些错本来就是 core 判出来的(`pack_traversal` /
|
||||
/// `pack_limits` / `pack_metadata` …),没必要在宿主层再解释一遍,更不能把 `hint`
|
||||
/// ("先装好游戏再导出"这种可执行下一步)丢掉——丢掉的话用户看到的就是"失败了,然后呢"。
|
||||
pub(super) fn failed_report_of(
|
||||
target: PathBuf,
|
||||
pack: &Path,
|
||||
error: &ImportError,
|
||||
) -> ImportReportView {
|
||||
failed_report_with(target, pack, error_view(error))
|
||||
}
|
||||
|
||||
/// 组装:形状只此一处,文案由调用方给。
|
||||
fn failed_report_with(target: PathBuf, pack: &Path, error: ImportErrorView) -> ImportReportView {
|
||||
ImportReportView {
|
||||
status: ImportStatusView::Failed,
|
||||
format: None,
|
||||
pack_name: Some(pack_stem(pack)),
|
||||
pack_version: None,
|
||||
game_version: None,
|
||||
loader: None,
|
||||
version_id: None,
|
||||
target,
|
||||
files: Vec::new(),
|
||||
conflicts: Vec::new(),
|
||||
missing: Vec::new(),
|
||||
rejected: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
error: Some(error),
|
||||
registered: None,
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
// 预检/装配阶段就失败了:目标目录一个字节都没写(不是「写完又回滚」)。
|
||||
failure_stage: Some(ImportFailureStageView::NotStarted),
|
||||
// 这一路根本没进管线,谈不上取消。
|
||||
cancel: crate::dto::ImportCancelView::NotRequested,
|
||||
}
|
||||
}
|
||||
|
||||
/// 包文件名(`Better MC.mrpack` → `Better MC`)。
|
||||
///
|
||||
/// **M216 ②**:函数体一行委托 core(`game::install::pack_dir`)——CLI 调的是同一个函数,
|
||||
/// 于是「CLI 缺省落点」与「GUI 落点」不只目录相同,连目录名归一化也只有一份实现。
|
||||
pub(super) fn pack_stem(pack: &Path) -> String {
|
||||
unlucky_core::game::install::pack_dir::pack_stem(pack)
|
||||
}
|
||||
|
||||
/// **实例目录名**(判据见 core 的 [`pack_dir::instance_dir_name`]):本函数只有一行委托。
|
||||
///
|
||||
/// **只剩 presenter 自己的用例在调它**(生产侧落点已改成 `pack_dir::instance_dir`,见 M216
|
||||
/// P3-2),所以挂 `cfg(test)`:它是那组既有判据(空格折 `-`、中文补 `-<hash8>`、两个中文名
|
||||
/// 不塌成同一个目录)继续钉住归一化规则的入口,生产构建里不需要它。
|
||||
///
|
||||
/// [`pack_dir::instance_dir_name`]: unlucky_core::game::install::pack_dir::instance_dir_name
|
||||
#[cfg(test)]
|
||||
pub(super) fn dir_name(name: &str) -> String {
|
||||
unlucky_core::game::install::pack_dir::instance_dir_name(name)
|
||||
}
|
||||
|
||||
/// 目录/文件名用的 slug(导出用的文件名也走它;实现同样收在 core `pack_dir`)。
|
||||
pub(super) fn slug(text: &str) -> String {
|
||||
unlucky_core::game::install::pack_dir::slug(text)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "convert/stage_tests.rs"]
|
||||
mod stage_tests;
|
||||
@@ -0,0 +1,122 @@
|
||||
//! 「取消的**真实结局**」→ DTO 三档(M195 r3)。
|
||||
//!
|
||||
//! 从父模块拆出来只为行数(父文件已到 400 硬限附近),判据与文案口径原样。
|
||||
|
||||
use unlucky_core::addon::import::report::ImportStatus;
|
||||
use unlucky_core::addon::import::ImportReport;
|
||||
|
||||
use crate::dto::ImportCancelView;
|
||||
|
||||
/// core 的「取消真实结局」→ DTO 的三档(M195 r3:**不许**把三档混成一句)。
|
||||
///
|
||||
/// - core 说 `Cancelled` ⇒ 管线真的停手了;回滚完整与否看 `rolled_back`(两档分开说);
|
||||
/// - core 说 `Success`/`Partial` 而这次被点过取消 ⇒ **取消太晚**:报告是真结局,但它不是
|
||||
/// "停下来了",界面必须照实说「实例会保留、文件可能已被覆盖」;
|
||||
/// - core 说 `Failed` ⇒ **一律 `NotRequested`**(复审 r3 P2-1):失败卡自己已经把真相说全了
|
||||
/// (`failure_stage` 分「没写过 / 回滚干净 / 回滚没做完」),再贴一句「取消太晚、已经跑完、
|
||||
/// 文件可能被覆盖」就是与红卡**直接对立**的第二句假话——失败的导入没有"跑完"这一说。
|
||||
pub(super) fn cancel_view(report: &ImportReport, cancel_requested: bool) -> ImportCancelView {
|
||||
match report.status {
|
||||
ImportStatus::Cancelled => {
|
||||
if report.rolled_back {
|
||||
ImportCancelView::RolledBack
|
||||
} else {
|
||||
ImportCancelView::RollbackIncomplete
|
||||
}
|
||||
}
|
||||
ImportStatus::Success | ImportStatus::Partial if cancel_requested => {
|
||||
ImportCancelView::TooLate
|
||||
}
|
||||
ImportStatus::Success | ImportStatus::Partial | ImportStatus::Failed => {
|
||||
ImportCancelView::NotRequested
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! 三档的**推导**(不是"给定这一档、文案怎么画"):什么输入会推出哪一档。
|
||||
//!
|
||||
//! 这一族用例是复审 r3 P2-1/P2-2 的直接产物:此前只有下半段(文案)有用例,把推导删掉
|
||||
//! 也全绿。
|
||||
|
||||
use unlucky_core::addon::import::report::ImportStatus;
|
||||
use unlucky_core::addon::import::ImportReport;
|
||||
|
||||
use crate::dto::ImportCancelView;
|
||||
|
||||
use super::super::report_view;
|
||||
|
||||
fn core_report(status: ImportStatus, rolled_back: bool) -> ImportReport {
|
||||
let mut report = ImportReport::empty(std::path::PathBuf::from("instance"));
|
||||
report.status = status;
|
||||
report.rolled_back = rolled_back;
|
||||
report
|
||||
}
|
||||
|
||||
fn cancel_of(
|
||||
status: ImportStatus,
|
||||
rolled_back: bool,
|
||||
cancel_requested: bool,
|
||||
) -> ImportCancelView {
|
||||
report_view(
|
||||
&core_report(status, rolled_back),
|
||||
std::path::Path::new("instance"),
|
||||
cancel_requested,
|
||||
)
|
||||
.cancel
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_incomplete_rollback_is_its_own_bucket() {
|
||||
// 推导:`Cancelled` + `rolled_back == false` ⇒ **回滚没做完**(不是"已回滚")。
|
||||
// 把这条推导删掉(一律按已回滚)这里就红——那正是复审 r3 P2-2 说的零覆盖。
|
||||
assert_eq!(
|
||||
cancel_of(ImportStatus::Cancelled, false, true),
|
||||
ImportCancelView::RollbackIncomplete
|
||||
);
|
||||
// 对照:回滚完整才是「已回滚」——两档不许并成一个。
|
||||
assert_eq!(
|
||||
cancel_of(ImportStatus::Cancelled, true, true),
|
||||
ImportCancelView::RolledBack
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_run_is_never_dressed_up_as_a_late_cancel() {
|
||||
// P2-1:失败 + 点过取消(例如"点取消的同时摘要不符")⇒ 失败卡自己说全了真相,
|
||||
// 不许再贴「取消太晚、已经跑完、文件可能被覆盖」——那两句直接对立。
|
||||
assert_eq!(
|
||||
cancel_of(ImportStatus::Failed, false, true),
|
||||
ImportCancelView::NotRequested,
|
||||
"失败的导入没有「跑完」这一说"
|
||||
);
|
||||
assert_eq!(
|
||||
cancel_of(ImportStatus::Failed, true, true),
|
||||
ImportCancelView::NotRequested
|
||||
);
|
||||
// 对照:成功/部分成功 + 点过取消,才是「太晚」。
|
||||
for status in [ImportStatus::Success, ImportStatus::Partial] {
|
||||
assert_eq!(
|
||||
cancel_of(status, false, true),
|
||||
ImportCancelView::TooLate,
|
||||
"{status:?} + 取消 ⇒ 太晚"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untouched_run_carries_no_cancel_verdict() {
|
||||
for status in [
|
||||
ImportStatus::Success,
|
||||
ImportStatus::Partial,
|
||||
ImportStatus::Failed,
|
||||
] {
|
||||
assert_eq!(
|
||||
cancel_of(status, false, false),
|
||||
ImportCancelView::NotRequested,
|
||||
"没点过取消就不该有取消结论"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! M161 复审 P2-1:`failure_stage` 是⑥三档的**唯一**判定点,此前零覆盖(把它改成恒
|
||||
//! `RolledBack`,presenter 254 条全绿)。这里直接喂 core 的机器位,断言的是**判定**,
|
||||
//! 不是渲染(渲染在 app-iced 的 `packui/model/tests/stage.rs`)。
|
||||
//!
|
||||
//! 抓得住:`None if report.rolled_back` 被写反(预检失败 → 「已回滚」,正是⑥要消灭的谎);
|
||||
//! `is_complete()` 被丢掉(回滚没做完也说成零残留);`status != Failed` 也发阶段。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::addon::import::{ImportError, ImportReport, ImportStatus, RollbackOutcome};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 一份「失败」的空报告(还没决定回滚结果)。
|
||||
fn failed() -> ImportReport {
|
||||
let mut report = ImportReport::empty(PathBuf::from("C:/tmp/.minecraft"));
|
||||
report.fail(ImportError::integrity("sha1 mismatch"));
|
||||
report
|
||||
}
|
||||
|
||||
/// 预检就失败(**从未写过目标目录**)⇒ `NotStarted`。
|
||||
///
|
||||
/// core 的 `abandoned()` 就是这一形:`rollback == None` + `rolled_back == true`。
|
||||
#[test]
|
||||
fn preflight_failure_is_not_started() {
|
||||
let mut report = failed();
|
||||
report.rolled_back = true;
|
||||
assert_eq!(
|
||||
failure_stage(&report),
|
||||
Some(ImportFailureStageView::NotStarted),
|
||||
"没写过目标目录就是「根本没开始」,不许说成「已回滚」"
|
||||
);
|
||||
assert_eq!(failure_stage(&report).unwrap().slug(), "not_started");
|
||||
// 渲染层同一份判据(`failed_report_with` 走的是同一条)。
|
||||
assert_eq!(
|
||||
failed_report_with(
|
||||
PathBuf::from("C:/tmp"),
|
||||
std::path::Path::new("Better MC.mrpack"),
|
||||
ImportErrorView {
|
||||
code: "pack_traversal".to_string(),
|
||||
label: "Path traversal".to_string(),
|
||||
message: "../etc/passwd".to_string(),
|
||||
hint: None,
|
||||
},
|
||||
)
|
||||
.failure_stage,
|
||||
Some(ImportFailureStageView::NotStarted),
|
||||
"宿主侧失败(目标实例不可用)也是「没开始写」,不是「已回滚」"
|
||||
);
|
||||
}
|
||||
|
||||
/// 跑过回滚且完整 ⇒ `RolledBack`。
|
||||
#[test]
|
||||
fn a_complete_rollback_is_rolled_back() {
|
||||
let mut report = failed();
|
||||
report.rolled_back = true;
|
||||
report.rollback = Some(RollbackOutcome {
|
||||
attempted: 3,
|
||||
failed: Vec::new(),
|
||||
});
|
||||
assert_eq!(
|
||||
failure_stage(&report),
|
||||
Some(ImportFailureStageView::RolledBack)
|
||||
);
|
||||
assert_eq!(failure_stage(&report).unwrap().slug(), "rolled_back");
|
||||
}
|
||||
|
||||
/// 跑过回滚但有失败项 ⇒ `RollbackIncomplete`(目标目录可能与导入前不同)。
|
||||
#[test]
|
||||
fn a_partial_rollback_is_incomplete() {
|
||||
let mut report = failed();
|
||||
report.rolled_back = false;
|
||||
report.rollback = Some(RollbackOutcome {
|
||||
attempted: 3,
|
||||
failed: vec!["mods/a.jar: 占用中".to_string()],
|
||||
});
|
||||
assert_eq!(
|
||||
failure_stage(&report),
|
||||
Some(ImportFailureStageView::RollbackIncomplete)
|
||||
);
|
||||
assert_eq!(
|
||||
failure_stage(&report).unwrap().slug(),
|
||||
"rollback_incomplete"
|
||||
);
|
||||
}
|
||||
|
||||
/// 既没回滚也没宣称干净(core 的 `failed()` 单独一支)⇒ 只能如实说「可能不一致」。
|
||||
#[test]
|
||||
fn a_failure_without_any_claim_is_incomplete_not_not_started() {
|
||||
let mut report = failed();
|
||||
report.rolled_back = false;
|
||||
report.rollback = None;
|
||||
assert_eq!(
|
||||
failure_stage(&report),
|
||||
Some(ImportFailureStageView::RollbackIncomplete),
|
||||
"没跑回滚也不宣称干净:不许说「没开始写」"
|
||||
);
|
||||
}
|
||||
|
||||
/// 不是致命失败 ⇒ 没有「失败阶段」可言(成功/部分成功都不该发这一段)。
|
||||
#[test]
|
||||
fn a_non_fatal_report_has_no_stage() {
|
||||
let mut report = ImportReport::empty(PathBuf::from("C:/tmp"));
|
||||
report.rolled_back = false;
|
||||
assert_eq!(failure_stage(&report), None, "Success 不该有失败阶段");
|
||||
report.status = ImportStatus::Partial;
|
||||
assert_eq!(failure_stage(&report), None, "Partial 不该有失败阶段");
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! 探测诊断(M113):「这个包**看起来像** X,但**缺** Y」。
|
||||
//!
|
||||
//! ## 为什么由 host 判,不引 core 改动
|
||||
//!
|
||||
//! core 的 `PackRegistry::detect` 只回答「谁认领」,一个都不认领时只给一句
|
||||
//! `pack_unsupported` + 「试过哪些格式」。而用户手里那个包往往**差一点点**就是某个已知格式
|
||||
//! (CF 导出漏了 `manifest.json`、mrpack 被浏览器改名成 `.zip` 后解压丢了 index)。
|
||||
//! 判断「像哪个」要看包内到底有哪些标记文件,这是宿主层拿着 `PackArchive` 才看得见的事。
|
||||
//!
|
||||
//! ## 判据只有标记文件的有无(不看文件名)
|
||||
//!
|
||||
//! | 格式 | 标记 | 邻居(有它但没标记 ⇒ 「像它」) |
|
||||
//! |---|---|---|
|
||||
//! | CurseForge | `manifest.json` | `overrides/` · `mods/` |
|
||||
//! | Modrinth | `modrinth.index.json` | `overrides/` |
|
||||
//! | MultiMC / Prism | `mmc-pack.json` | `instance.cfg` · `.minecraft/` |
|
||||
//!
|
||||
//! `saw` 只留前 5 条有代表性的标记:长路径往界面里倒会把版面撑破(塔台复审提过)。
|
||||
//! 一个标记都没认出来时 `saw` 为空,由界面显示「nothing recognisable」而不是一行空白。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use unlucky_core::addon::pack::{PackArchive, PackLimits, PackRegistry};
|
||||
|
||||
use crate::dto::{PackFormatView, ProbeView};
|
||||
|
||||
/// 诊断一个包(`None` = 有个格式认领它,正常走管线)。
|
||||
pub(super) fn of(pack: &Path) -> Option<ProbeView> {
|
||||
let archive = match PackArchive::open(pack, PackLimits::default()) {
|
||||
Ok(archive) => archive,
|
||||
Err(error) => {
|
||||
// 连 zip 都打不开(选错文件 / 下了一半):也要说清楚是哪一步失败的
|
||||
return Some(ProbeView {
|
||||
looks_like: None,
|
||||
missing: Vec::new(),
|
||||
saw: Vec::new(),
|
||||
detail: format!("could not open the archive: {error}"),
|
||||
});
|
||||
}
|
||||
};
|
||||
let registry = PackRegistry::builtin();
|
||||
if registry.detect(&archive).is_ok() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// (格式, 标记文件, 邻居标记)
|
||||
let rules: [(PackFormatView, &str, &[&str]); 3] = [
|
||||
(
|
||||
PackFormatView::Curseforge,
|
||||
"manifest.json",
|
||||
&["overrides/", "mods/"],
|
||||
),
|
||||
(
|
||||
PackFormatView::Modrinth,
|
||||
"modrinth.index.json",
|
||||
&["overrides/"],
|
||||
),
|
||||
(
|
||||
PackFormatView::Multimc,
|
||||
"mmc-pack.json",
|
||||
&["instance.cfg", ".minecraft/"],
|
||||
),
|
||||
];
|
||||
|
||||
// 目录输入的条目名**没有尾斜杠**(`overrides` 而不是 `overrides/`),zip 里目录条目也可能缺;
|
||||
// 所以匹配同时认「精确 / 前缀 / 去掉尾斜杠」三种写法,判据仍然只有标记文件的有无。
|
||||
let names: Vec<&str> = archive.entries().iter().map(|entry| entry.name()).collect();
|
||||
let has = |needle: &str| {
|
||||
names.iter().any(|name| {
|
||||
*name == needle || name.starts_with(needle) || *name == needle.trim_end_matches('/')
|
||||
})
|
||||
};
|
||||
|
||||
let mut saw: Vec<String> = Vec::new();
|
||||
let mut best: Option<(PackFormatView, String)> = None;
|
||||
for (format, marker, neighbours) in rules {
|
||||
let hits: Vec<&str> = neighbours
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|entry| has(entry))
|
||||
.collect();
|
||||
if hits.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !has(marker) {
|
||||
// 有邻居、没标记 ⇒ 「像它,但缺那个标记」
|
||||
saw.extend(hits.iter().map(|entry| (*entry).to_string()));
|
||||
if best.is_none() {
|
||||
best = Some((format, marker.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
saw.sort();
|
||||
saw.dedup();
|
||||
saw.truncate(5);
|
||||
|
||||
let detail = format!("tried: {}", registry.ids().join(" / "));
|
||||
Some(ProbeView {
|
||||
looks_like: best.as_ref().map(|(format, _)| *format),
|
||||
missing: best.map(|(_, marker)| vec![marker]).unwrap_or_default(),
|
||||
saw,
|
||||
detail,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! 导出(M113):**预检不写盘**,写盘是另一次调用,两次用同一套判据。
|
||||
//!
|
||||
//! 预检的全部依据就是 core 的 `scan_instance` + 「没有远程目录 ⇒ 一律内联」:
|
||||
//! `RemoteCatalog` 目前只有 `OfflineCatalog`,任何文件都证明不了可被远程替代。所以
|
||||
//! 「将内联 N / 无法导出 M」里的 N 是扫到的文件数、M 是 `SkippedLocal`(符号链接 / 路径不安全 /
|
||||
//! 读不出来)。写盘走 `export_instance`(同样注入离线目录),
|
||||
//! `tests::preview_counts_match_the_written_report` 用真管线钉住两边相等。
|
||||
|
||||
use unlucky_core::addon::export::{
|
||||
export_instance, scan_instance, ExportRequest, OfflineCatalog, DEFAULT_CONTENT_ROOTS,
|
||||
};
|
||||
|
||||
use crate::dto::{
|
||||
ExportClassView, ExportFileView, ExportPreviewView, ExportReasonView, ExportRequestView,
|
||||
PackEvent,
|
||||
};
|
||||
|
||||
use super::super::super::env::HostEnv;
|
||||
use super::super::super::job::{spawn, JobSender};
|
||||
use super::convert::{class_view, done_view, reason_view};
|
||||
use super::send;
|
||||
|
||||
/// 起一个后台预检线程(**不写盘**)。
|
||||
pub(super) fn spawn_preview(env: HostEnv, tx: JobSender, request: ExportRequestView, epoch: u64) {
|
||||
spawn("pack-export-preview", move || {
|
||||
let root = match super::instance_root(&env, &request.instance) {
|
||||
Ok(root) => root,
|
||||
Err(reason) => {
|
||||
send(&tx, epoch, PackEvent::ExportFailed(reason));
|
||||
return;
|
||||
}
|
||||
};
|
||||
send(
|
||||
&tx,
|
||||
epoch,
|
||||
PackEvent::Preview(Box::new(preview_of(&root, &env, &request))),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// 预检的**纯函数部分**(不起线程、不写盘):扫描 → 三分类计数。
|
||||
///
|
||||
/// 抽出来是为了能测:`tests::preview_counts_match_the_written_report` 用它和
|
||||
/// `export_instance` 的真实报告对数字,等式不成立就红。
|
||||
pub(super) fn preview_of(
|
||||
root: &std::path::Path,
|
||||
env: &HostEnv,
|
||||
request: &ExportRequestView,
|
||||
) -> ExportPreviewView {
|
||||
let target = request
|
||||
.target
|
||||
.clone()
|
||||
.unwrap_or_else(|| super::default_export_path(env, request));
|
||||
let roots: Vec<String> = DEFAULT_CONTENT_ROOTS
|
||||
.iter()
|
||||
.map(|entry| (*entry).to_string())
|
||||
.collect();
|
||||
let (files, skipped) = scan_instance(root, &roots);
|
||||
|
||||
let mut rows: Vec<ExportFileView> = files
|
||||
.iter()
|
||||
.map(|file| ExportFileView {
|
||||
path: file.path.clone(),
|
||||
class: ExportClassView::Inline,
|
||||
reason: ExportReasonView::NoRemoteMatch,
|
||||
bytes: file.bytes,
|
||||
disabled: file.disabled,
|
||||
})
|
||||
.collect();
|
||||
rows.extend(skipped.iter().map(|file| ExportFileView {
|
||||
path: file.path.clone(),
|
||||
class: class_view(file.reason.class()),
|
||||
reason: reason_view(file.reason),
|
||||
bytes: 0,
|
||||
disabled: false,
|
||||
}));
|
||||
// 与 `ExportReport::files` 同一条顺序(按路径)
|
||||
rows.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
|
||||
ExportPreviewView {
|
||||
format: request.format,
|
||||
instance: request.instance.clone(),
|
||||
name: request.name.clone(),
|
||||
version: request.version.clone(),
|
||||
target,
|
||||
files: rows,
|
||||
notes: vec![
|
||||
"No remote catalogue yet: every file has to be inlined into overrides/".to_string(),
|
||||
"Unreadable files, symlinks and unsafe paths are listed below, one by one".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// 起一个后台写盘线程。
|
||||
pub(super) fn spawn_write(env: HostEnv, tx: JobSender, request: ExportRequestView, epoch: u64) {
|
||||
spawn("pack-export", move || {
|
||||
let root = match super::instance_root(&env, &request.instance) {
|
||||
Ok(root) => root,
|
||||
Err(reason) => {
|
||||
send(&tx, epoch, PackEvent::ExportFailed(reason));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let format = match super::format_of(request.format) {
|
||||
Ok(format) => format,
|
||||
Err(reason) => {
|
||||
send(&tx, epoch, PackEvent::ExportFailed(reason));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let target = request
|
||||
.target
|
||||
.clone()
|
||||
.unwrap_or_else(|| super::default_export_path(&env, &request));
|
||||
let snapshot = super::snapshot_of(&env, &request.instance);
|
||||
let mut core_request = ExportRequest::new(&root, &target, format)
|
||||
.with_name(request.name.clone())
|
||||
.with_version(request.version.clone())
|
||||
.with_catalog(&OfflineCatalog);
|
||||
if let Some(snapshot) = &snapshot {
|
||||
core_request = core_request.with_snapshot(snapshot);
|
||||
}
|
||||
match export_instance(&core_request) {
|
||||
Ok(report) => send(
|
||||
&tx,
|
||||
epoch,
|
||||
PackEvent::Exported(Box::new(done_view(&report))),
|
||||
),
|
||||
Err(error) => send(&tx, epoch, PackEvent::ExportFailed(format!("{error}"))),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//! 后台导入(M113/M160):探测 → **预检(冲突清单)** → 目标目录 → core 的 `import_pack` → 报告。
|
||||
//!
|
||||
//! 四条纪律:
|
||||
//!
|
||||
//! 1. **探测失败不空跑管线**:先做具名诊断([`super::diagnose`]),失败就只回 `Probe` 事件;
|
||||
//! 2. **先预检再导入**(M160):预检只读,把"会盖掉用户哪几个文件"算清楚。没有冲突就直接
|
||||
//! 往下导(不打扰用户);有冲突就回 [`PackEvent::Scan`] 让页面画清单,等用户点完再导;
|
||||
//! 3. **缺省档永远 `KeepAll`**:决策从 DTO 逐字段搬([`convert::decisions_of`]),页面没点过的
|
||||
//! 路径在 core 侧落到"保留";
|
||||
//! 4. **取消是真取消**(M195):`cancel` 标记既挡界面事件、又**交给 core 当取消令牌**——管线
|
||||
//! 在检查点停手并回滚(逐段检查点见 `core::addon::import::cancel`)。取消之后的**进度**不再
|
||||
//! 打扰界面(回调与代次都挡),但**终态报告一定发出**:取消可能太晚(管线已跑完)或回滚
|
||||
//! 没做完,那两种结局必须让用户看见(见 [`finish`])。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_core::addon::import::report::ImportIssue;
|
||||
use unlucky_core::addon::import::{
|
||||
import_pack, scan_pack_online, DownloadEngineFetcher, ImportCancel, ImportProgress,
|
||||
ImportProgressSink, ImportRequest,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::game::install::pack_dir;
|
||||
|
||||
use crate::dto::{ImportRequestView, PackEvent};
|
||||
use unlucky_core::addon::import::ImportStatus;
|
||||
|
||||
use super::super::super::env::HostEnv;
|
||||
use super::super::super::job::{spawn, JobSender};
|
||||
use super::convert;
|
||||
use super::send;
|
||||
|
||||
/// 起一个后台导入线程。
|
||||
pub(super) fn spawn_import(
|
||||
env: HostEnv,
|
||||
tx: JobSender,
|
||||
request: ImportRequestView,
|
||||
epoch: u64,
|
||||
cancel: Arc<AtomicBool>,
|
||||
inflight: super::InFlight,
|
||||
) {
|
||||
spawn("pack-import", move || {
|
||||
// 句柄跟着这次运行走到**出口**:正常收尾、两条早退、panic 展开都会 drop ⇒ 计数减一。
|
||||
// 以前是手工 `finished()` 放在 `block_on()` 之后,两条早退路径直接漏掉(复审 r2 P2-1)。
|
||||
let _inflight = inflight;
|
||||
run(&env, &tx, &request, epoch, cancel);
|
||||
});
|
||||
}
|
||||
|
||||
/// 起一个后台**预检**线程(M160:只读,不写盘)。
|
||||
pub(super) fn spawn_scan(
|
||||
env: HostEnv,
|
||||
tx: JobSender,
|
||||
request: ImportRequestView,
|
||||
epoch: u64,
|
||||
cancel: Arc<AtomicBool>,
|
||||
) {
|
||||
spawn("pack-scan", move || {
|
||||
// 探测先过一遍:失败就只给具名诊断,不空跑一遍预检。
|
||||
if let Some(probe) = super::diagnose::of(&request.pack) {
|
||||
send(&tx, epoch, PackEvent::Probe(Box::new(probe)));
|
||||
return;
|
||||
}
|
||||
let target = match target_of(&env, &request) {
|
||||
Ok((target, _)) => target,
|
||||
Err(reason) => {
|
||||
// 目标实例不可用:与导入路径同一形状的失败报告(界面不用学第二套)。
|
||||
send(
|
||||
&tx,
|
||||
epoch,
|
||||
PackEvent::Report(Box::new(convert::failed_report(
|
||||
PathBuf::new(),
|
||||
&request.pack,
|
||||
"import_host",
|
||||
"Target instance is not usable",
|
||||
&reason,
|
||||
))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
// 预检本身只读(不写盘),但 M162 起会**先补全再判**:缺 `hashes.sha1` 的 mrpack 条目
|
||||
// 按 sha512 反查 Modrinth。补不出仍按 M160 的口径计数,所以冷启动没网也不影响判定。
|
||||
let engine = super::super::super::engine::engine(&env);
|
||||
let fetcher = DownloadEngineFetcher::new(&engine);
|
||||
let core_request = ImportRequest::new(&request.pack, &target).with_fetcher(&fetcher);
|
||||
let scan = block_on(scan_pack_online(&core_request));
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
// 先发预检结果(`error` 也照原样带出去:调用方按"有没有冲突"分流要用它)。
|
||||
send(
|
||||
&tx,
|
||||
epoch,
|
||||
PackEvent::Scan(Box::new(convert::scan_view(&scan))),
|
||||
);
|
||||
// **预检自己没算出来 ⇒ 必须再发一条结果事件**(复审 r1 P2-1)。
|
||||
//
|
||||
// 只发 `Scan(error)` 的话,页面那边 `pick` 为空(它只由 `conflicts` 生成)、进度与报告
|
||||
// 又被 `Scan` 清空 ⇒ 落回入口态,用户点完「导入」屏幕上一个字都不变(静默空转)。
|
||||
// 走 `Report` 就回到 M160 之前那条既有形状:结果块的「致命 —— 已回滚」把原因画出来,
|
||||
// UI 一行不用改;`code`/`label`/`message`/`hint` 全部从 `scan.error` 搬。
|
||||
if let Some(error) = &scan.error {
|
||||
send(
|
||||
&tx,
|
||||
epoch,
|
||||
PackEvent::Report(Box::new(convert::failed_report_of(
|
||||
target,
|
||||
&request.pack,
|
||||
error,
|
||||
))),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn run(
|
||||
env: &HostEnv,
|
||||
tx: &JobSender,
|
||||
request: &ImportRequestView,
|
||||
epoch: u64,
|
||||
cancel: Arc<AtomicBool>,
|
||||
) {
|
||||
// 探测先过一遍:失败就只给具名诊断(「看起来像 X,但缺 Y」),不空跑一遍管线
|
||||
if let Some(probe) = super::diagnose::of(&request.pack) {
|
||||
send(tx, epoch, PackEvent::Probe(Box::new(probe)));
|
||||
return;
|
||||
}
|
||||
let (target, register) = match target_of(env, request) {
|
||||
Ok(target) => target,
|
||||
Err(reason) => {
|
||||
send(
|
||||
tx,
|
||||
epoch,
|
||||
PackEvent::Report(Box::new(convert::failed_report(
|
||||
// 目标目录不可用 ⇒ 如实给空路径(不把包路径冒充成实例目录)
|
||||
PathBuf::new(),
|
||||
&request.pack,
|
||||
"import_host",
|
||||
"Target instance is not usable",
|
||||
&reason,
|
||||
))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if register {
|
||||
if let Err(reason) = prepare_target(&target) {
|
||||
send(
|
||||
tx,
|
||||
epoch,
|
||||
PackEvent::Report(Box::new(convert::failed_report(
|
||||
target,
|
||||
&request.pack,
|
||||
"import_host",
|
||||
"Target instance is not usable",
|
||||
&reason,
|
||||
))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let pack_name = request
|
||||
.instance_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| convert::pack_stem(&request.pack));
|
||||
let progress_tx = tx.clone();
|
||||
let progress_name = pack_name.clone();
|
||||
let cancel_flag = Arc::clone(&cancel);
|
||||
let sink: ImportProgressSink = Arc::new(move |progress: &ImportProgress| {
|
||||
if cancel_flag.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
send(
|
||||
&progress_tx,
|
||||
epoch,
|
||||
PackEvent::Progress(Some(Box::new(convert::progress_view(
|
||||
progress,
|
||||
&progress_name,
|
||||
)))),
|
||||
);
|
||||
});
|
||||
|
||||
let engine = super::super::super::engine::engine(env);
|
||||
let fetcher = DownloadEngineFetcher::new(&engine);
|
||||
// 这个开关从 M195 起是**真令牌**:core 的每个检查点都看它(不只是挡界面事件)。
|
||||
let token = ImportCancel::from_flag(Arc::clone(&cancel));
|
||||
let mut core_request = ImportRequest::new(&request.pack, &target)
|
||||
.with_fetcher(&fetcher)
|
||||
.with_cancel(&token)
|
||||
.with_progress(sink)
|
||||
// 决策逐字段搬过来:`KeepAll`(缺省)/ `OverwriteAll` / 逐项,语义在 core 那一侧落地。
|
||||
.with_decisions(convert::decisions_of(&request.decisions));
|
||||
if register {
|
||||
core_request = core_request.with_registration(&env.layout, Some(&pack_name));
|
||||
}
|
||||
|
||||
let mut report = block_on(import_pack(&core_request));
|
||||
// **§5**:导入建出来的实例要绑到它的内容目录(`isolation=custom` + `gameDir`),否则运行
|
||||
// 目录由全局档派生成 `<game>/versions/<version_id>`,这批 mods/config 永远不会被启动用到。
|
||||
// 判据与文案在 core(与 CLI 同一份);失败不把整次导入判失败,但要让用户看见。
|
||||
match pack_dir::bind_created_instance(&env.layout, &target, register, &report) {
|
||||
Some(Ok(note)) => report
|
||||
.warnings
|
||||
.push(ImportIssue::new("instance_bound", note)),
|
||||
Some(Err(note)) => report
|
||||
.warnings
|
||||
.push(ImportIssue::new("instance_binding_failed", note)),
|
||||
None => {}
|
||||
}
|
||||
// 取消标记要**在管线返回之后**再读一次:取消可能来得太晚(管线已经跑完),这一读就是
|
||||
// "太晚"的唯一证据(`finish` 用它把报告标成 `TooLate`)。
|
||||
let cancel_requested = cancel.load(Ordering::SeqCst);
|
||||
finish(tx, &target, register, epoch, cancel_requested, &report);
|
||||
}
|
||||
|
||||
/// 收尾:清掉我们自建的空目录 + 把 core 的报告变成事件。
|
||||
///
|
||||
/// # 取消也照发(M195 r3)
|
||||
///
|
||||
/// 以前这里 `cancel.load()` 是对**所有**事件的早退,于是"取消太晚、后台其实已经装完并登记"
|
||||
/// 这件事永远到不了界面,界面只能照着旧文案说「已停手并回滚」。现在报告无条件发出:它就是
|
||||
/// 真实结局——core 说 `Cancelled`(停手并回滚 / 回滚没做完),或者说 `Success`/`Partial`
|
||||
/// (取消没赶上),由 DTO 的 `cancel` 三档把区别带到界面。
|
||||
///
|
||||
/// 代次仍用**出发时**那个(`epoch`):`handle` 给"刚被取消的那一次"留了一次性终态通道
|
||||
/// ([`super::super::addon::pack::take_terminal_epoch`]),进度事件照旧按代次丢。
|
||||
pub(super) fn finish(
|
||||
tx: &JobSender,
|
||||
target: &std::path::Path,
|
||||
register: bool,
|
||||
epoch: u64,
|
||||
cancel_requested: bool,
|
||||
report: &unlucky_core::addon::import::ImportReport,
|
||||
) {
|
||||
if register
|
||||
&& matches!(
|
||||
report.status,
|
||||
ImportStatus::Failed | ImportStatus::Cancelled
|
||||
)
|
||||
{
|
||||
// 失败 / 取消都已回滚,但**目录是我们建的**(`prepare_target`)、core 只清文件不清
|
||||
// 目录 ⇒ 不清掉的话下一次重试会撞「directory already exists」(复审 r1 P2-9)。
|
||||
remove_if_empty(target);
|
||||
}
|
||||
send(
|
||||
tx,
|
||||
epoch,
|
||||
PackEvent::Report(Box::new(convert::report_view(
|
||||
report,
|
||||
target,
|
||||
cancel_requested,
|
||||
))),
|
||||
);
|
||||
}
|
||||
|
||||
/// 目录里没有东西就删掉(失败回滚后的收尾;非空一律不动)。
|
||||
fn remove_if_empty(dir: &std::path::Path) {
|
||||
let empty = std::fs::read_dir(dir)
|
||||
.map(|mut entries| entries.next().is_none())
|
||||
.unwrap_or(false);
|
||||
if empty {
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// 导入目标:`(目录, 是否登记新实例)` —— **只算路径,不建目录**(预检要能只读地用它)。
|
||||
///
|
||||
/// - 选了实例 ⇒ 它的运行目录,**不登记**(已经是一条实例记录);
|
||||
/// - 没选实例 ⇒ `<data>/instances/<dir_name>` 新目录 + 登记(实例名 = 用户给的名字或包名)。
|
||||
/// 注意 M111 的既有边界:导入**不装加载器**,所以新实例报告的 `版本 id` 还要另外装。
|
||||
pub(super) fn target_of(
|
||||
env: &HostEnv,
|
||||
request: &ImportRequestView,
|
||||
) -> Result<(PathBuf, bool), String> {
|
||||
match &request.instance {
|
||||
Some(id) => super::instance_root(env, id).map(|root| (root, false)),
|
||||
None => {
|
||||
let name = request
|
||||
.instance_name
|
||||
.clone()
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or_else(|| convert::pack_stem(&request.pack));
|
||||
// **M216 P3-2**:落点也委托 core(`pack_dir::instance_dir`)——此前这里自己拼
|
||||
// `<data>/instances/<dir_name>`,core 改了目录语义时 GUI 不会跟着走。
|
||||
let target = pack_dir::instance_dir(&env.layout, &name);
|
||||
if target.exists() {
|
||||
// 空目录(多半是上一次失败的残留)**可以复用**,否则重试永远报「已存在」
|
||||
let mut entries = std::fs::read_dir(&target)
|
||||
.map_err(|error| format!("read instance dir failed: {error}"))?;
|
||||
if entries.next().is_some() {
|
||||
return Err(format!(
|
||||
"directory already exists and is not empty: {}",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok((target, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 真要写盘之前建目录(**预检不调它**:预检是只读的,不建任何东西)。
|
||||
pub(super) fn prepare_target(target: &std::path::Path) -> Result<(), String> {
|
||||
std::fs::create_dir_all(target).map_err(|error| format!("create dir failed: {error}"))
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
//! M113 的证据(都跑真管线 / 真 host,不用假数据):
|
||||
//!
|
||||
//! | 用例 | 钉住什么 |
|
||||
//! |---|---|
|
||||
//! | [`bad_packs_get_a_named_diagnosis`] | 坏包必须有具名失败,不是静默、不是 panic |
|
||||
//! | [`preview_counts_match_the_written_report`] | 预检的 N/M 与最终报告**逐项相等** |
|
||||
//! | [`a_real_pack_runs_through_the_pipeline_end_to_end`] | 正常包能走完管线并落盘 |
|
||||
//! | [`chinese_instance_names_get_distinct_directories`] | 中文实例名不会塌成同一个目录 |
|
||||
//! | [`a_failed_new_instance_directory_can_be_reused_for_retry`] | 失败残留的空目录不挡重试 |
|
||||
//! | [`pack_epoch_is_per_host_and_is_its_own_counter`] | 代次按 host 分开,不再跨 host 串扰 |
|
||||
//! | [`a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact`] | 早退路径放回「在跑」计数,取消安装仍是安装的取消 |
|
||||
//! | [`cancel::a_pre_cancelled_flag_really_stops_the_core_pipeline`] | 取消标记**真的**交给 core(盘上零残留、档案零实例) |
|
||||
//!
|
||||
//! 夹具(临时目录、写文件、泵 host)在 [`harness`]。
|
||||
|
||||
mod cancel;
|
||||
mod harness;
|
||||
mod scan;
|
||||
|
||||
use harness::{pack_events, pump, pump_until, temp_dir, write};
|
||||
|
||||
use unlucky_core::addon::export::{
|
||||
export_instance, scan_instance, ExportRequest, InstanceSnapshot, OfflineCatalog,
|
||||
DEFAULT_CONTENT_ROOTS,
|
||||
};
|
||||
|
||||
use crate::dto::{ConflictDecisionsView, ExportClassView, ExportRequestView, PackFormatView};
|
||||
|
||||
#[test]
|
||||
fn bad_packs_get_a_named_diagnosis() {
|
||||
let dir = temp_dir("badpack");
|
||||
|
||||
// ① 把文本文件改名成 .mrpack:连 zip 都不是
|
||||
let fake = dir.join("fake.mrpack");
|
||||
write(&fake, b"this is not a zip");
|
||||
let probe = super::diagnose::of(&fake).expect("打不开的包必须有诊断");
|
||||
assert!(
|
||||
!probe.detail.is_empty(),
|
||||
"诊断要给一句具体原因(不是空字符串)"
|
||||
);
|
||||
assert!(
|
||||
probe.detail.to_lowercase().contains("archive") || probe.detail.contains("zip"),
|
||||
"诊断要说清是哪一步失败:{}",
|
||||
probe.detail
|
||||
);
|
||||
|
||||
// ② 目录形态:有 overrides/、没有 manifest.json ⇒ 「像 CF,缺 manifest.json」
|
||||
let dir_pack = dir.join("half");
|
||||
write(&dir_pack.join("overrides").join("options.txt"), b"fov=90\n");
|
||||
if let Some(probe) = super::diagnose::of(&dir_pack) {
|
||||
assert!(
|
||||
!probe.saw.is_empty(),
|
||||
"看到了 overrides/ 就该说出来(saw 不能为空)"
|
||||
);
|
||||
if probe.looks_like.is_some() {
|
||||
assert!(
|
||||
!probe.missing.is_empty(),
|
||||
"说「像某个格式」就必须说出缺哪个标记文件"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_counts_match_the_written_report() {
|
||||
let dir = temp_dir("export-eq");
|
||||
let root = dir.join("instance");
|
||||
write(&root.join("mods").join("sodium.jar"), &[0x11; 4096]);
|
||||
write(&root.join("mods").join("lithium.jar"), &[0x22; 2048]);
|
||||
write(&root.join("mods").join("old.jar.disabled"), &[0x33; 512]);
|
||||
write(&root.join("config").join("options.txt"), b"fov=90\n");
|
||||
// 白名单之外的目录不该被扫(否则「将内联 N」会虚高)
|
||||
write(&root.join("logs").join("latest.log"), b"noise\n");
|
||||
|
||||
let request = ExportRequestView {
|
||||
instance: "survival".to_string(),
|
||||
format: PackFormatView::Modrinth,
|
||||
name: "Survival".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
target: Some(dir.join("out.mrpack")),
|
||||
};
|
||||
let env_root = dir.join("data");
|
||||
std::fs::create_dir_all(&env_root).expect("env");
|
||||
let env = crate::host::HostEnv::at_root(&env_root);
|
||||
|
||||
// 预检(不写盘)
|
||||
let preview = super::export::preview_of(&root, &env, &request);
|
||||
|
||||
// 真写盘:用同一套判据(离线目录 + 默认内容目录 + 直接给的快照)
|
||||
let snapshot = InstanceSnapshot {
|
||||
game_version: "1.20.1".to_string(),
|
||||
loaders: Vec::new(),
|
||||
};
|
||||
let target = dir.join("out.mrpack");
|
||||
let core_request = ExportRequest::new(
|
||||
&root,
|
||||
&target,
|
||||
&unlucky_core::addon::pack::modrinth::MODRINTH,
|
||||
)
|
||||
.with_name("Survival")
|
||||
.with_version("1.0.0")
|
||||
.with_catalog(&OfflineCatalog)
|
||||
.with_snapshot(&snapshot);
|
||||
let report = export_instance(&core_request).expect("导出应当成功");
|
||||
|
||||
// 三个计数逐项相等(塔台要求:不是「大致对」)
|
||||
assert_eq!(
|
||||
preview.inline_count(),
|
||||
report.inline_count(),
|
||||
"内联条数不一致:预检 {} vs 报告 {}",
|
||||
preview.inline_count(),
|
||||
report.inline_count()
|
||||
);
|
||||
assert_eq!(
|
||||
preview.skipped_count(),
|
||||
report.skipped_count(),
|
||||
"无法导出条数不一致"
|
||||
);
|
||||
assert_eq!(
|
||||
preview.remote_count(),
|
||||
report.remote_count(),
|
||||
"远程引用条数不一致"
|
||||
);
|
||||
|
||||
// 逐条:同一个路径在两边必须是同一个类
|
||||
for file in &preview.files {
|
||||
let written = report
|
||||
.files
|
||||
.iter()
|
||||
.find(|candidate| candidate.path == file.path)
|
||||
.unwrap_or_else(|| panic!("预检多出一条:{}", file.path));
|
||||
let expected = match file.class {
|
||||
ExportClassView::Remote => unlucky_core::addon::export::FileClass::Remote,
|
||||
ExportClassView::Inline => unlucky_core::addon::export::FileClass::Inline,
|
||||
ExportClassView::Skipped => unlucky_core::addon::export::FileClass::Skipped,
|
||||
};
|
||||
assert_eq!(written.class, expected, "{} 的归类不一致", file.path);
|
||||
}
|
||||
|
||||
// 白名单外的 logs/ 不许进包(防「扫太宽」把预检数字弄虚)
|
||||
assert!(
|
||||
!report
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path.starts_with("logs/")),
|
||||
"logs/ 不该被扫进整合包"
|
||||
);
|
||||
|
||||
// 同时钉一条:`scan_instance` 的口径就是预览用的那一套
|
||||
let roots: Vec<String> = DEFAULT_CONTENT_ROOTS
|
||||
.iter()
|
||||
.map(|entry| (*entry).to_string())
|
||||
.collect();
|
||||
let (files, skipped) = scan_instance(&root, &roots);
|
||||
assert_eq!(files.len(), preview.inline_count());
|
||||
assert_eq!(skipped.len(), preview.skipped_count());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_pack_runs_through_the_pipeline_end_to_end() {
|
||||
// 真管线端到端:造一个 MultiMC 实例目录当「包」(目录形态不需要 zip 写入器),
|
||||
// 走 `import::run` 的**真**路径,看事件流里有没有「进度 + 结果」、文件有没有落地。
|
||||
let dir = temp_dir("import-e2e");
|
||||
let source = dir.join("Better MC");
|
||||
write(
|
||||
&source.join("mmc-pack.json"),
|
||||
br#"{"formatVersion": 1, "components": [
|
||||
{"uid": "net.minecraft", "version": "1.20.1"},
|
||||
{"uid": "net.fabricmc.fabric-loader", "version": "0.15.11"}]}"#,
|
||||
);
|
||||
write(&source.join(".minecraft/mods/sodium.jar"), &[0x11; 1024]);
|
||||
write(&source.join(".minecraft/config/options.txt"), b"fov=90\n");
|
||||
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).expect("data root");
|
||||
let env = crate::host::HostEnv::at_root(&data);
|
||||
|
||||
let (tx, rx) = crate::host::job::channel();
|
||||
let request = crate::dto::ImportRequestView {
|
||||
pack: source,
|
||||
instance: None,
|
||||
instance_name: Some("Imported Pack".to_string()),
|
||||
decisions: ConflictDecisionsView::default(),
|
||||
};
|
||||
super::import::run(
|
||||
&env,
|
||||
&tx,
|
||||
&request,
|
||||
7,
|
||||
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
);
|
||||
drop(tx);
|
||||
|
||||
let mut progress = 0usize;
|
||||
let mut report = None;
|
||||
for task in rx.iter() {
|
||||
match task {
|
||||
crate::host::job::HostTask::Pack { event, epoch } => {
|
||||
assert_eq!(epoch, 7, "代次必须原样带回(取消判定靠它)");
|
||||
match *event {
|
||||
crate::dto::PackEvent::Progress(Some(_)) => progress += 1,
|
||||
crate::dto::PackEvent::Report(view) => report = Some(*view),
|
||||
other => panic!("意料之外的事件:{other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("意料之外的任务:{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
assert!(progress > 0, "至少要有一条进度事件");
|
||||
let report = report.expect("必须有结果事件");
|
||||
assert_ne!(
|
||||
report.status,
|
||||
crate::dto::ImportStatusView::Failed,
|
||||
"正常包不该失败:{:?}",
|
||||
report.error
|
||||
);
|
||||
assert_eq!(
|
||||
report.format,
|
||||
Some(crate::dto::PackFormatView::Multimc),
|
||||
"应当认成 MultiMC 格式"
|
||||
);
|
||||
assert!(
|
||||
report.written() > 0,
|
||||
"报告里要至少有写盘的文件:{:?}",
|
||||
report.files
|
||||
);
|
||||
// 落盘位置以**报告里的 target** 为准(那是 host 真算出来的目录,界面显示的也是它)
|
||||
let landed = &report.target;
|
||||
assert!(
|
||||
landed.join("mods/sodium.jar").is_file(),
|
||||
"文件没有落到实例目录:{}",
|
||||
landed.display()
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_instance_names_get_distinct_directories() {
|
||||
// 复审 r1 P2-8:slug() 只留 ASCII,中文名会全塌成 `pack`,第二个就撞目录
|
||||
let first = super::convert::dir_name("我的整合包");
|
||||
let second = super::convert::dir_name("另一个整合包");
|
||||
assert_ne!(first, second, "两个不同的中文名必须得到不同目录");
|
||||
assert_eq!(
|
||||
first,
|
||||
super::convert::dir_name("我的整合包"),
|
||||
"同一个名字要稳定(不然重试会漂到新目录)"
|
||||
);
|
||||
assert!(first.starts_with("pack-"), "退化时要留可读前缀:{first}");
|
||||
|
||||
// 判据与注释对齐(复审 r2 P2-2):只含 ASCII 字母数字/空格/`-`/`_` ⇒ 保持原样
|
||||
assert_eq!(super::convert::dir_name("Better-MC"), "Better-MC");
|
||||
assert_eq!(
|
||||
super::convert::dir_name("Better MC"),
|
||||
"Better-MC",
|
||||
"空格折成 `-`、**不加**哈希"
|
||||
);
|
||||
assert_eq!(
|
||||
super::convert::dir_name("pack"),
|
||||
"pack",
|
||||
"名字就叫 pack 也保留"
|
||||
);
|
||||
|
||||
// 折过**别的**字符(这里是 `.`)就必须补哈希,否则不同名字会撞同一个目录
|
||||
let versioned = super::convert::dir_name("Survival 1.20.1");
|
||||
assert!(versioned.starts_with("Survival-1-20-1-"), "{versioned}");
|
||||
assert_ne!(versioned, "Survival-1-20-1", "折过 `.` 就要补哈希");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_new_instance_directory_can_be_reused_for_retry() {
|
||||
// 复审 r1 P2-9:失败回滚留下空目录时会挡住重试
|
||||
let dir = temp_dir("retry");
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).expect("data root");
|
||||
let env = crate::host::HostEnv::at_root(&data);
|
||||
|
||||
let request = crate::dto::ImportRequestView {
|
||||
pack: dir.join("whatever.mrpack"),
|
||||
instance: None,
|
||||
instance_name: Some("Retry Me".to_string()),
|
||||
decisions: ConflictDecisionsView::default(),
|
||||
};
|
||||
|
||||
// 第一次:`target_of` 只算路径(预检要能只读地用它),真要写盘前才 `prepare_target`。
|
||||
let (first, register) = super::import::target_of(&env, &request).expect("首次应当算得出目录");
|
||||
assert!(register);
|
||||
assert!(
|
||||
!first.exists(),
|
||||
"`target_of` 不许建目录(M160:预检是只读的)"
|
||||
);
|
||||
super::import::prepare_target(&first).expect("写盘前建目录");
|
||||
assert!(first.is_dir());
|
||||
|
||||
// 第二次(模拟上一次失败留下的空目录)⇒ 复用,而不是报「已存在」
|
||||
let (second, _) = super::import::target_of(&env, &request).expect("空目录应当可复用");
|
||||
assert_eq!(first, second);
|
||||
|
||||
// 目录里已经有东西 ⇒ 拒绝(不能往别人的实例里倒)
|
||||
std::fs::write(second.join("marker.txt"), b"x").expect("write");
|
||||
assert!(
|
||||
super::import::target_of(&env, &request).is_err(),
|
||||
"非空目录必须拒绝"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_epoch_is_per_host_and_is_its_own_counter() {
|
||||
// 复审 r2 P3-6:代次**按 host** 分开(不再是跨 host 共享的裸静态量)。
|
||||
// 两个 host(两个临时根)各 bump 一次,互不影响 —— 这正是并行测试串扰的根因。
|
||||
let one = temp_dir("epoch-one");
|
||||
let two = temp_dir("epoch-two");
|
||||
let env_one = crate::host::HostEnv::at_root(one.join("data"));
|
||||
let env_two = crate::host::HostEnv::at_root(two.join("data"));
|
||||
|
||||
let before_one = super::pack_epoch(&env_one);
|
||||
let before_two = super::pack_epoch(&env_two);
|
||||
super::bump_pack_epoch(&env_one);
|
||||
assert_eq!(
|
||||
super::pack_epoch(&env_one),
|
||||
before_one.wrapping_add(1),
|
||||
"自己这个 host 的代次 +1"
|
||||
);
|
||||
assert_eq!(
|
||||
super::pack_epoch(&env_two),
|
||||
before_two,
|
||||
"**另一个** host 的代次不受影响(P3-6 的根因)"
|
||||
);
|
||||
assert_eq!(super::in_flight(&env_two), 0, "在跑计数也是每 host 一份");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&one);
|
||||
let _ = std::fs::remove_dir_all(&two);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact() {
|
||||
// 复审 r2 P2-1 的护栏:早退路径必须把「在跑」计数放回去,否则之后**下载页**的
|
||||
// 「取消安装」会被当成导入取消(不发 `InstallCancelled`,还给内容库画一句假话)。
|
||||
use crate::dto::{
|
||||
AppCommand, AppEvent, Backend, CoreEvent, ImportRequestView, PackCommand, PackEvent,
|
||||
};
|
||||
|
||||
let dir = temp_dir("inflight-guard");
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).expect("data root");
|
||||
let host_env = crate::host::HostEnv::at_root(&data);
|
||||
let mut host = crate::host::CoreHost::headless(host_env.clone());
|
||||
|
||||
// ① 派一次**必然早退**的导入:包文件不存在 ⇒ 探测当场失败,根本不进管线
|
||||
host.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
ImportRequestView {
|
||||
pack: dir.join("missing.mrpack"),
|
||||
instance: None,
|
||||
instance_name: Some("Guess".to_string()),
|
||||
decisions: ConflictDecisionsView::default(),
|
||||
},
|
||||
)))));
|
||||
let events = pump_until(&mut host, |all| {
|
||||
pack_events(all)
|
||||
.iter()
|
||||
.any(|event| matches!(event, PackEvent::Probe(_)))
|
||||
});
|
||||
assert!(
|
||||
pack_events(&events)
|
||||
.iter()
|
||||
.any(|event| matches!(event, PackEvent::Probe(_))),
|
||||
"坏包必须给出探测诊断(而不是静默);实际收到 {events:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
super::in_flight(&host_env),
|
||||
0,
|
||||
"早退之后「在跑」计数必须归零(RAII guard 负责,不靠手工 finished)"
|
||||
);
|
||||
|
||||
// ② 此后下载页的「取消安装」必须照常发 `InstallCancelled`,且**不能**冒出 pack 的取消
|
||||
host.dispatch(AppCommand::CancelInstall);
|
||||
let events = pump(&mut host);
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|event| matches!(event, AppEvent::Core(CoreEvent::InstallCancelled))),
|
||||
"没有导入在跑时,取消安装必须照常给回执(否则用户点了取消毫无反应)"
|
||||
);
|
||||
assert!(
|
||||
!pack_events(&events)
|
||||
.iter()
|
||||
.any(|event| matches!(event, PackEvent::Cancelled)),
|
||||
"此刻没有导入在跑,不该给内容库发「导入已取消」"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! presenter 的**真令牌**接线(M195 复审 P2-2):取消标记必须真的交给 core。
|
||||
//!
|
||||
//! # 为什么判据只能是盘上的事实
|
||||
//!
|
||||
//! 只断言"没收到 `PackEvent::Report`"是**两条路都成立**的伪证明:`import::run` 的早退条件是
|
||||
//! `report.status == Cancelled || cancel.load()`,后者会把"根本没接线"完全遮住——core 照旧跑完
|
||||
//! 并登记实例,界面只看到面板收回。那正是本 mission 要消灭的形态,所以本用例盯的是**目标目录
|
||||
//! 与 `profiles.json`**:标记一进来就是 true ⇒ 目录里一个文件都不该有、实例一个都不该登记。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::dto::{ConflictDecisionsView, ImportRequestView};
|
||||
|
||||
use super::harness::{temp_dir, write};
|
||||
|
||||
/// 一个能走完管线的最小 MultiMC 包(目录形态:不需要 zip 写入器)。
|
||||
fn multimc_pack(dir: &std::path::Path) -> std::path::PathBuf {
|
||||
let source = dir.join("Better MC");
|
||||
write(
|
||||
&source.join("mmc-pack.json"),
|
||||
br#"{"formatVersion": 1, "components": [
|
||||
{"uid": "net.minecraft", "version": "1.20.1"}]}"#,
|
||||
);
|
||||
write(&source.join(".minecraft/mods/sodium.jar"), &[0x11; 1024]);
|
||||
write(&source.join(".minecraft/config/options.txt"), b"fov=90\n");
|
||||
source
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pre_cancelled_flag_really_stops_the_core_pipeline() {
|
||||
let dir = temp_dir("cancel-wired");
|
||||
let source = multimc_pack(&dir);
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).expect("data root");
|
||||
let env = crate::host::HostEnv::at_root(&data);
|
||||
let request = ImportRequestView {
|
||||
pack: source,
|
||||
instance: None,
|
||||
instance_name: Some("Cancelled Pack".to_string()),
|
||||
decisions: ConflictDecisionsView::default(),
|
||||
};
|
||||
// 目标目录与"是否登记"都按 host 的真口径算(判据不能自己另算一份)。
|
||||
let (target, register) = super::super::import::target_of(&env, &request).expect("目标目录");
|
||||
assert!(register, "走新实例这条路才覆盖「登记」这一环");
|
||||
|
||||
let (tx, _rx) = crate::host::job::channel();
|
||||
let cancel = Arc::new(AtomicBool::new(true));
|
||||
super::super::import::run(&env, &tx, &request, 11, Arc::clone(&cancel));
|
||||
drop(tx);
|
||||
assert!(cancel.load(Ordering::SeqCst), "用例前提:标记一直是 true");
|
||||
|
||||
assert!(
|
||||
!target.exists(),
|
||||
"取消后目标目录不该存在(core 在入口检查点就停手,暂存目录也删了):{}",
|
||||
target.display()
|
||||
);
|
||||
let profiles =
|
||||
unlucky_core::game::install::instance::read_profiles(&env.layout).expect("读档案");
|
||||
assert!(
|
||||
profiles.profiles.is_empty(),
|
||||
"取消后不许登记实例:{:?}",
|
||||
profiles.profiles
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// **路径 A:取消来得太晚**(M195 r3 的核心用例)。
|
||||
///
|
||||
/// 三段拼起来,全部确定性、没有 sleep:
|
||||
/// 1. **core 真管线**跑一次合并态导入,把取消置在**最后一条** `ImportStage::Commit` 进度上——
|
||||
/// 之后没有任何检查点,于是管线一路跑完(用户的 `mods/a.jar` 被覆盖),报告是
|
||||
/// `Success` + `rolled_back == false`(core 一直是诚实的);
|
||||
/// 2. `cancel_pack` 把代次 +1(这就是"取消"),并记下被作废的那个代次;
|
||||
/// 3. `finish` 照发 `PackEvent::Report`,`handle` 让它**穿透代次**——界面因此拿到「已完成 /
|
||||
/// 未回滚 / 取消太晚」,而不是旧行为里那句无条件的「已停手并回滚」。
|
||||
///
|
||||
/// 变异:把终态通道去掉(`handle` 恢复"代次不符一律丢")⇒ 本用例立刻红(报告到不了界面)。
|
||||
#[test]
|
||||
fn a_late_cancel_still_delivers_the_real_outcome_to_the_ui() {
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
use unlucky_core::addon::import::{
|
||||
import_pack, ConflictPolicy, ImportCancel, ImportProgress, ImportProgressSink,
|
||||
ImportRequest, ImportStage, ImportStatus,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
|
||||
use crate::dto::{AppEvent, Backend, CoreEvent, PackEvent};
|
||||
|
||||
let dir = temp_dir("cancel-late");
|
||||
// 包里有 `mods/a.jar`,目标目录里也有(内容不同)⇒ 合并态"被覆盖"是真的会发生。
|
||||
let source = dir.join("Path A Pack");
|
||||
write(
|
||||
&source.join("mmc-pack.json"),
|
||||
br#"{"formatVersion": 1, "components": [
|
||||
{"uid": "net.minecraft", "version": "1.20.1"}]}"#,
|
||||
);
|
||||
write(&source.join(".minecraft/mods/a.jar"), b"pack a");
|
||||
let target = dir.join("instance");
|
||||
write(&target.join("mods/a.jar"), b"user a");
|
||||
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).expect("data root");
|
||||
let env = crate::host::HostEnv::at_root(&data);
|
||||
|
||||
// ① core:取消置在最后一条逐文件进度上 ⇒ 之后没有检查点 ⇒ 管线跑完。
|
||||
let token = ImportCancel::new();
|
||||
let sink_token = token.clone();
|
||||
let seen = Arc::new(AtomicUsize::new(0));
|
||||
let seen_by_sink = Arc::clone(&seen);
|
||||
let sink: ImportProgressSink = Arc::new(move |progress: &ImportProgress| {
|
||||
if progress.stage != ImportStage::Commit || progress.total == 0 {
|
||||
return;
|
||||
}
|
||||
seen_by_sink.fetch_add(1, Ordering::SeqCst);
|
||||
if progress.done + 1 == progress.total {
|
||||
sink_token.cancel();
|
||||
}
|
||||
});
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&source, &target)
|
||||
.with_cancel(&token)
|
||||
// 覆盖档:让用户的 `mods/a.jar` 真的被覆盖(缺省是"保留用户那一侧")。
|
||||
.with_conflict_policy(ConflictPolicy::Overwrite)
|
||||
.with_progress(sink),
|
||||
));
|
||||
assert!(token.is_cancelled(), "用例前提:取消确实置位了");
|
||||
assert!(
|
||||
seen.load(Ordering::SeqCst) > 1,
|
||||
"确实逐文件走到了底(合并态)"
|
||||
);
|
||||
assert_eq!(
|
||||
report.status,
|
||||
ImportStatus::Success,
|
||||
"取消置在最后一条之后 ⇒ 管线已经跑完:{:?}",
|
||||
report.error
|
||||
);
|
||||
assert!(
|
||||
!report.rolled_back,
|
||||
"没有回滚就别说回滚过(core 的机器位是诚实的)"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(target.join("mods/a.jar")).expect("读被覆盖的文件"),
|
||||
b"pack a",
|
||||
"路径 A 的前提:用户的同名文件**已经被覆盖**"
|
||||
);
|
||||
|
||||
// ② 用户点取消:代次 +1,被作废的那个代次进了终态通道。
|
||||
let epoch = super::super::pack_epoch(&env);
|
||||
let _in_flight = super::super::InFlight::begin(&env);
|
||||
assert!(
|
||||
super::super::cancel_pack(&env),
|
||||
"取消应当看到一条在跑的导入"
|
||||
);
|
||||
assert_ne!(
|
||||
super::super::pack_epoch(&env),
|
||||
epoch,
|
||||
"取消会作废本次代次(这正是旧实现丢报告的成因)"
|
||||
);
|
||||
|
||||
// ③ 收尾照发报告;同代次上的**进度**事件仍按代次丢掉。
|
||||
let mut host = crate::host::CoreHost::headless(env.clone());
|
||||
let (tx, rx) = crate::host::job::channel();
|
||||
super::super::import::finish(&tx, &target, false, epoch, true, &report);
|
||||
tx.send(crate::host::job::HostTask::Pack {
|
||||
event: Box::new(PackEvent::Progress(None)),
|
||||
epoch,
|
||||
})
|
||||
.expect("送一条同代次的进度事件");
|
||||
drop(tx);
|
||||
for task in rx.iter() {
|
||||
host.handle(task);
|
||||
}
|
||||
let events = host.drain_events();
|
||||
|
||||
let delivered: Vec<&crate::dto::ImportReportView> = events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::Core(CoreEvent::Pack(inner)) => match inner.as_ref() {
|
||||
PackEvent::Report(view) => Some(view.as_ref()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let view = delivered
|
||||
.first()
|
||||
.expect("取消太晚时,真实结果必须到达界面(丢掉就是路径 A 的假话)");
|
||||
assert_eq!(
|
||||
view.status,
|
||||
crate::dto::ImportStatusView::Success,
|
||||
"界面拿到的结局是「已完成」"
|
||||
);
|
||||
assert!(
|
||||
!view.rolled_back,
|
||||
"界面拿到的结局是「未回滚」,不是「已回滚」"
|
||||
);
|
||||
assert_eq!(
|
||||
view.cancel,
|
||||
crate::dto::ImportCancelView::TooLate,
|
||||
"必须标成「取消太晚」,不能标成「已回滚」"
|
||||
);
|
||||
assert!(
|
||||
!events.iter().any(|event| match event {
|
||||
AppEvent::Core(CoreEvent::Pack(inner)) =>
|
||||
matches!(inner.as_ref(), PackEvent::Progress(_)),
|
||||
_ => false,
|
||||
}),
|
||||
"终态通道只放行报告:取消后的进度事件仍按代次丢掉"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! pack 用例的共用夹具:临时目录、文件写入、host 泵。
|
||||
//!
|
||||
//! 拆出去只为一件事:`tests.rs` 已经 429 行(超 400 硬线),而这些 helper 与任何一条断言无关。
|
||||
//! `tests.rs` 里各条用例的「抓得住什么」写在用例自己的注释里,这里只放机械动作。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 每个用例一个独立临时目录(用完删)。
|
||||
pub(super) fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("ull-m113-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
pub(super) fn write(path: &std::path::Path, bytes: &[u8]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("parent");
|
||||
}
|
||||
std::fs::write(path, bytes).expect("write");
|
||||
}
|
||||
|
||||
/// 泵一次 host:把后台任务收成事件并取走。
|
||||
pub(super) fn pump(host: &mut crate::host::CoreHost) -> Vec<crate::dto::AppEvent> {
|
||||
use crate::dto::Backend;
|
||||
host.tick(std::time::Duration::from_millis(1));
|
||||
host.drain_events()
|
||||
}
|
||||
|
||||
/// 泵到 `done` 为真(最多 ~3 s),返回期间收到的全部事件。
|
||||
pub(super) fn pump_until(
|
||||
host: &mut crate::host::CoreHost,
|
||||
done: impl Fn(&[crate::dto::AppEvent]) -> bool,
|
||||
) -> Vec<crate::dto::AppEvent> {
|
||||
let mut all = Vec::new();
|
||||
for _ in 0..600 {
|
||||
all.extend(pump(host));
|
||||
if done(&all) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
pub(super) fn pack_events(events: &[crate::dto::AppEvent]) -> Vec<crate::dto::PackEvent> {
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
crate::dto::AppEvent::Core(crate::dto::CoreEvent::Pack(inner)) => {
|
||||
Some((**inner).clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! 预检与决策通道(M160):DTO ↔ core 的翻译、以及"缺省不覆盖"在 presenter 这一侧的表现。
|
||||
//!
|
||||
//! 这里**不重算**任何判定(判定在 core):只钉两件事——
|
||||
//!
|
||||
//! 1. 三条入口(全部保留 / 全部覆盖 / 逐项)翻成 core 的 `ConflictDecisions` 之后语义不变,
|
||||
//! 尤其"没点名的路径 = 保留";
|
||||
//! 2. 预检清单逐字段搬进 DTO(来源、成因、两侧体积与哈希、计数摘要)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::harness::{temp_dir, write};
|
||||
use crate::dto::{
|
||||
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictReasonView,
|
||||
ConflictSourceView,
|
||||
};
|
||||
use unlucky_core::addon::import::{
|
||||
ConflictChoice, ConflictReason, FileSource, ImportConflict, ImportScan,
|
||||
};
|
||||
|
||||
/// 造一份 core 侧的预检结果(只填这条用例要看的字段)。
|
||||
fn scan_fixture() -> ImportScan {
|
||||
ImportScan {
|
||||
target: PathBuf::from("instances/survival/.minecraft"),
|
||||
format: Some("modrinth".to_string()),
|
||||
pack_name: Some("Better MC".to_string()),
|
||||
pack_version: Some("1.2.0".to_string()),
|
||||
game_version: Some("1.20.1".to_string()),
|
||||
loader: None,
|
||||
conflicts: vec![ImportConflict {
|
||||
path: "config/sodium-options.json".to_string(),
|
||||
source: FileSource::ClientOverrides,
|
||||
reason: ConflictReason::UserModified,
|
||||
disabled: false,
|
||||
pack_sha1: "aa".repeat(20),
|
||||
pack_size: 4_200,
|
||||
disk_sha1: "bb".repeat(20),
|
||||
disk_size: 5_100,
|
||||
base_sha1: Some("cc".repeat(20)),
|
||||
}],
|
||||
unknown_remote: 2,
|
||||
notes: Vec::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_view_carries_every_field_and_the_counts() {
|
||||
let scan = scan_fixture();
|
||||
let view = super::super::convert::scan_view(&scan);
|
||||
|
||||
assert_eq!(view.format, Some(crate::dto::PackFormatView::Modrinth));
|
||||
assert_eq!(view.pack_name.as_deref(), Some("Better MC"));
|
||||
assert_eq!(view.unknown_remote, 2, "判不了的远端条目要如实计数");
|
||||
assert!(view.needs_decision(), "有一条冲突就要问用户");
|
||||
let conflict = &view.conflicts[0];
|
||||
assert_eq!(conflict.path, "config/sodium-options.json");
|
||||
assert_eq!(
|
||||
conflict.source,
|
||||
ConflictSourceView::ClientOverrides,
|
||||
"叠层胜者那一侧的来源"
|
||||
);
|
||||
assert_eq!(conflict.reason, ConflictReasonView::UserModified);
|
||||
assert_eq!(conflict.pack_size, 4_200);
|
||||
assert_eq!(conflict.disk_size, 5_100, "目标现有体积要出现在清单里");
|
||||
assert_eq!(conflict.disk_sha1.len(), 40, "目标现有哈希也要有");
|
||||
assert_eq!(view.lost_bytes, 5_100, "覆盖会丢掉的字节");
|
||||
assert_eq!(view.incoming_bytes, 4_200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_scan_is_not_a_reason_to_bother_the_user() {
|
||||
let mut scan = scan_fixture();
|
||||
scan.conflicts.clear();
|
||||
let view = super::super::convert::scan_view(&scan);
|
||||
assert!(!view.needs_decision());
|
||||
assert!(view.is_clean(), "没有冲突也没有错误 ⇒ 可以直接导入");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decisions_are_keep_all_by_default() {
|
||||
// 缺省 = 全部保留:页面不发决策、或发默认值,结果都必须是"一个文件都不动"。
|
||||
let view = ConflictDecisionsView::default();
|
||||
assert_eq!(view, ConflictDecisionsView::KeepAll);
|
||||
assert!(!view.overwrites_everything());
|
||||
let core = super::super::convert::decisions_of(&view);
|
||||
assert_eq!(core.choice_for("config/a.toml"), ConflictChoice::KeepUser);
|
||||
assert_eq!(core.default_choice(), ConflictChoice::KeepUser);
|
||||
assert!(core.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_three_channels_map_one_to_one() {
|
||||
// 全部覆盖
|
||||
let all = super::super::convert::decisions_of(&ConflictDecisionsView::OverwriteAll);
|
||||
assert_eq!(all.choice_for("anything.toml"), ConflictChoice::Overwrite);
|
||||
assert_eq!(all.default_choice(), ConflictChoice::Overwrite);
|
||||
|
||||
// 逐项:点名的照点名走,没点名的落到"保留"。
|
||||
let view = ConflictDecisionsView::per_item(
|
||||
ConflictChoiceView::KeepUser,
|
||||
vec![
|
||||
ConflictDecisionView {
|
||||
path: "mods/pin.jar".to_string(),
|
||||
choice: ConflictChoiceView::Overwrite,
|
||||
},
|
||||
ConflictDecisionView {
|
||||
path: "config/keep.toml".to_string(),
|
||||
choice: ConflictChoiceView::KeepUser,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert_eq!(view.overwrite_count(), 1);
|
||||
assert_eq!(view.keep_count(), 1);
|
||||
assert_eq!(view.len(), 2);
|
||||
assert!(!view.overwrites_everything(), "逐项不等于整批覆盖");
|
||||
|
||||
let core = super::super::convert::decisions_of(&view);
|
||||
assert_eq!(core.choice_for("mods/pin.jar"), ConflictChoice::Overwrite);
|
||||
assert_eq!(
|
||||
core.choice_for("config/keep.toml"),
|
||||
ConflictChoice::KeepUser
|
||||
);
|
||||
assert_eq!(
|
||||
core.choice_for("mods/unlisted.jar"),
|
||||
ConflictChoice::KeepUser,
|
||||
"没点名的路径不许被逐项表顺手覆盖"
|
||||
);
|
||||
assert_eq!(core.overwrite_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scan_runs_without_writing_and_says_nothing_when_there_is_no_conflict() {
|
||||
// 真跑一次后台预检:全新目标(什么都没有)⇒ 没有冲突,且**目标目录没被建出来**。
|
||||
let dir = temp_dir("scan-fresh");
|
||||
let source = dir.join("Better MC");
|
||||
write(
|
||||
&source.join("mmc-pack.json"),
|
||||
br#"{"formatVersion": 1, "components": [
|
||||
{"uid": "net.minecraft", "version": "1.20.1"}]}"#,
|
||||
);
|
||||
write(&source.join(".minecraft/mods/sodium.jar"), &[0x11; 64]);
|
||||
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).expect("data root");
|
||||
let env = crate::host::HostEnv::at_root(&data);
|
||||
let (tx, rx) = crate::host::job::channel();
|
||||
let request = crate::dto::ImportRequestView {
|
||||
pack: source,
|
||||
instance: None,
|
||||
instance_name: Some("Scan Only".to_string()),
|
||||
decisions: ConflictDecisionsView::default(),
|
||||
};
|
||||
super::super::import::spawn_scan(
|
||||
env,
|
||||
tx,
|
||||
request,
|
||||
9,
|
||||
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
);
|
||||
|
||||
let mut scan = None;
|
||||
for task in rx.iter() {
|
||||
if let crate::host::job::HostTask::Pack { event, epoch } = task {
|
||||
assert_eq!(epoch, 9, "代次必须原样带回");
|
||||
if let crate::dto::PackEvent::Scan(view) = *event {
|
||||
scan = Some(*view);
|
||||
}
|
||||
}
|
||||
}
|
||||
let scan = scan.expect("必须回一条预检事件");
|
||||
assert!(scan.error.is_none(), "{:?}", scan.error);
|
||||
assert!(
|
||||
!scan.needs_decision(),
|
||||
"空目标上不该有冲突:{:?}",
|
||||
scan.conflicts
|
||||
);
|
||||
assert_eq!(scan.format, Some(crate::dto::PackFormatView::Multimc));
|
||||
assert!(
|
||||
!scan.target.exists(),
|
||||
"预检只读:目标目录不许被建出来({})",
|
||||
scan.target.display()
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// 事件序列的名字(断言"用户到底能不能看见"用)。
|
||||
fn kind_of(event: &crate::dto::PackEvent) -> &'static str {
|
||||
match event {
|
||||
crate::dto::PackEvent::Progress(_) => "progress",
|
||||
crate::dto::PackEvent::Scan(scan) if scan.error.is_some() => "scan-with-error",
|
||||
crate::dto::PackEvent::Scan(_) => "scan",
|
||||
crate::dto::PackEvent::Report(report) if report.error.is_some() => "report-failed",
|
||||
crate::dto::PackEvent::Report(_) => "report",
|
||||
crate::dto::PackEvent::Probe(_) => "probe",
|
||||
crate::dto::PackEvent::Preview(_) => "preview",
|
||||
crate::dto::PackEvent::Exported(_) => "exported",
|
||||
crate::dto::PackEvent::ExportFailed(_) => "export-failed",
|
||||
crate::dto::PackEvent::Cancelled => "cancelled",
|
||||
crate::dto::PackEvent::Cleared => "cleared",
|
||||
}
|
||||
}
|
||||
|
||||
/// 跑一次 `spawn_scan`,把事件按顺序收回来。
|
||||
fn run_scan(source: std::path::PathBuf, data: &std::path::Path) -> Vec<crate::dto::PackEvent> {
|
||||
let env = crate::host::HostEnv::at_root(data);
|
||||
let (tx, rx) = crate::host::job::channel();
|
||||
let request = crate::dto::ImportRequestView {
|
||||
pack: source,
|
||||
instance: None,
|
||||
instance_name: Some("Scan Rework".to_string()),
|
||||
decisions: ConflictDecisionsView::default(),
|
||||
};
|
||||
super::super::import::spawn_scan(
|
||||
env,
|
||||
tx,
|
||||
request,
|
||||
3,
|
||||
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
);
|
||||
let mut events = Vec::new();
|
||||
for task in rx.iter() {
|
||||
if let crate::host::job::HostTask::Pack { event, epoch } = task {
|
||||
assert_eq!(epoch, 3, "代次必须原样带回");
|
||||
events.push(*event);
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_scan_still_reaches_the_screen_as_a_report() {
|
||||
// 复审 r1 P2-1 的回归钉:**这条用例验的是"用户看得见",不是"字段被填了"**。
|
||||
//
|
||||
// 达标形状(旧行为 / M160 之前):`Import` 失败 → `PackEvent::Report` → 结果块画「已回滚」+ 原因。
|
||||
// 出错形状(本 diff 引入的回归):只发 `Scan(error)` ⇒ 页面 pick 为空、report 被清空 ⇒ 入口态,
|
||||
// 屏幕上什么都不发生。所以这里逐一断言**事件序列**,而不只看 `scan.error`。
|
||||
//
|
||||
// 可达面:detect 认得、read 才失败的包。这里用最普通的一种——MultiMC 的
|
||||
// `formatVersion: 2`(Prism 自己也会整份丢弃,不是刻意构造的畸形包)。
|
||||
let dir = temp_dir("scan-error");
|
||||
let source = dir.join("Prism 2");
|
||||
write(
|
||||
&source.join("mmc-pack.json"),
|
||||
br#"{"formatVersion": 2, "components": [
|
||||
{"uid": "net.minecraft", "version": "1.20.1"}]}"#,
|
||||
);
|
||||
write(&source.join(".minecraft/mods/sodium.jar"), &[0x11; 32]);
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).expect("data root");
|
||||
|
||||
let events = run_scan(source, &data);
|
||||
let kinds: Vec<&str> = events.iter().map(kind_of).collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
vec!["scan-with-error", "report-failed"],
|
||||
"预检自己失败时必须再发一条结果事件(否则页面静默空转)"
|
||||
);
|
||||
|
||||
let crate::dto::PackEvent::Scan(scan) = &events[0] else {
|
||||
panic!("第一条必须是预检结果");
|
||||
};
|
||||
let error = scan.error.as_ref().expect("预检要带出错误");
|
||||
assert_eq!(
|
||||
error.code, "pack_metadata",
|
||||
"formatVersion != 1 是清单自身非法"
|
||||
);
|
||||
assert!(
|
||||
error.message.contains("formatVersion"),
|
||||
"原因要能读:{}",
|
||||
error.message
|
||||
);
|
||||
|
||||
// 结果事件:三态是 Failed、原因与预检那份同源(code/label/message 都搬过来)。
|
||||
let crate::dto::PackEvent::Report(report) = &events[1] else {
|
||||
panic!("第二条必须是结果");
|
||||
};
|
||||
assert_eq!(report.status, crate::dto::ImportStatusView::Failed);
|
||||
let reported = report.error.as_ref().expect("报告要带出原因");
|
||||
assert_eq!(reported.code, error.code);
|
||||
assert_eq!(reported.message, error.message);
|
||||
assert!(!reported.label.is_empty(), "人读大类名也要有");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_scan_does_not_emit_a_report() {
|
||||
// 对照:干净预检只发一条 Scan(多发 Report 会把页面从"清单"顶到"结果")。
|
||||
let dir = temp_dir("scan-clean");
|
||||
let source = dir.join("Vanilla");
|
||||
write(
|
||||
&source.join("mmc-pack.json"),
|
||||
br#"{"formatVersion": 1, "components": [{"uid": "net.minecraft", "version": "1.20.1"}]}"#,
|
||||
);
|
||||
write(&source.join(".minecraft/mods/sodium.jar"), &[0x11; 32]);
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).expect("data root");
|
||||
|
||||
let kinds: Vec<&str> = run_scan(source, &data).iter().map(kind_of).collect();
|
||||
assert_eq!(kinds, vec!["scan"], "干净预检只发一条");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! 依赖计划的**宿主侧那一半**:扫目录、把两档失败映成 DTO 大类、拼给用户看的那句话。
|
||||
//!
|
||||
//! # 为什么判定在 core、说话在 presenter
|
||||
//!
|
||||
//! 「哪个前置不兼容、哪个前置取不到」由 `unlucky_core::addon::online::resolve` 判定并可离线
|
||||
//! 测试(录制 JSON 夹具)。本文件只做三件宿主才知道的事:
|
||||
//!
|
||||
//! | 事 | 为什么只能在这里 |
|
||||
//! |---|---|
|
||||
//! | 目标目录里有什么 | core 不读盘(`PresentFile` 由调用方扫) |
|
||||
//! | 失败大类怎么映 | DTO 在 presenter(`ContentFailureKind`) |
|
||||
//! | 文案怎么说 | 界面文案不归 core |
|
||||
//!
|
||||
//! # 两档失败**分开说**
|
||||
//!
|
||||
//! 「不兼容所选版本/加载器」与「取不到(网络 / 项目不存在)」是两句话、两种用户动作,
|
||||
//! 所以这里既不合并计数也不合并文案;DTO 大类按「用户接下来该做什么」选:离线优先说
|
||||
//! 检查网络,其次查无此项,只有**全是**不兼容时才说「这条路走不通」(换版本/加载器才有救)。
|
||||
//!
|
||||
//! # 「可只重试这些」的名单也在这里(M191)
|
||||
//!
|
||||
//! [`retry_items`] 把计划里的失败项摊成 DTO 的 [`InstallFailureView`](项目键 + 可读标签 +
|
||||
//! 档位 + 原话),界面据此分组画两段;[`retry_plan`] 是**真正重试**那一步——它调 core 的
|
||||
//! `DependencyPlan::retry_request()`,于是重试只请求失败的那几个项目,**不是**重新发起根项目安装。
|
||||
//!
|
||||
//! 可读标签由 [`label_of`] 定(根项目用请求标题、下载失败用文件名、解析失败留空),
|
||||
//! **不许**拿 `failure.message` 顶上——那是 core 的中文散文,界面照画会踩 i18n R2。
|
||||
//!
|
||||
//! # 不兼容有自己的一档(M191 起)
|
||||
//!
|
||||
//! M186 把它映成 `Unsupported`(当时 DTO 没有这一档,与「缺密钥」共用),于是界面没法单独说
|
||||
//! 「换个版本/加载器」。M191 给 `ContentFailureKind` 加了 `Incompatible`,[`kind_of`] 直接映射,
|
||||
//! **不再借 `Unsupported`**。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use unlucky_core::addon::online::{
|
||||
DependencyFailure, DependencyPlan, FailureKind, OnlineError, PlannedNode, PresentFile,
|
||||
ResolveRequest,
|
||||
};
|
||||
|
||||
use crate::dto::{ContentFailureKind, InstallFailureKind, InstallFailureView};
|
||||
|
||||
use super::sources::failure_of;
|
||||
|
||||
mod labels;
|
||||
mod remembered;
|
||||
|
||||
pub(super) use labels::{fetch_title, names_for_incompatible, Root};
|
||||
pub(super) use remembered::{forget, remember, remembered};
|
||||
|
||||
/// 扫目标目录(不递归)里已存在的文件名。
|
||||
///
|
||||
/// 只给名字,不算摘要:为了「跳过」把整个 `mods/` 哈希一遍不划算,同名就是同一份 jar 的
|
||||
/// 最常见形态(我们落盘用站点给的文件名,不重命名)。
|
||||
pub(super) fn present_files(dir: &Path) -> Vec<PresentFile> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|entry| entry.path().is_file())
|
||||
.map(|entry| PresentFile::new(entry.file_name().to_string_lossy()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 一次装入要用的解析请求(过滤条件与落盘目录一致)。
|
||||
pub(super) fn request(
|
||||
project: &str,
|
||||
game_version: &str,
|
||||
loader: Option<&str>,
|
||||
present: Vec<PresentFile>,
|
||||
) -> ResolveRequest {
|
||||
ResolveRequest {
|
||||
roots: vec![project.to_string()],
|
||||
game_version: Some(game_version.to_string()),
|
||||
loader: loader.map(str::to_string),
|
||||
present,
|
||||
..ResolveRequest::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 计划里有失败吗 ⇒ 一条带大类的失败(`None` = 干净)。
|
||||
///
|
||||
/// `installed` = **真落盘的**文件数(下载失败的不算):失败文案里要能说清「主文件已装、
|
||||
/// 缺的是谁的前置」,所以不能拿 `plan.nodes.len()` 冒充。
|
||||
pub(super) fn failure_of_plan(
|
||||
plan: &DependencyPlan,
|
||||
installed: usize,
|
||||
) -> Option<crate::dto::ContentFailure> {
|
||||
if plan.is_clean() {
|
||||
return None;
|
||||
}
|
||||
Some(crate::dto::ContentFailure::new(
|
||||
overall_kind(plan),
|
||||
report(plan, installed),
|
||||
))
|
||||
}
|
||||
|
||||
/// 整体大类:按「用户接下来该做什么」排序,离线 > 查无此项 > 站点错 > 其它 > 不兼容。
|
||||
fn overall_kind(plan: &DependencyPlan) -> ContentFailureKind {
|
||||
let kinds: Vec<ContentFailureKind> = plan.failures.iter().map(kind_of).collect();
|
||||
for wanted in [
|
||||
ContentFailureKind::Offline,
|
||||
ContentFailureKind::NotFound,
|
||||
ContentFailureKind::Server,
|
||||
ContentFailureKind::Other,
|
||||
ContentFailureKind::Unsupported,
|
||||
] {
|
||||
if kinds.contains(&wanted) {
|
||||
return wanted;
|
||||
}
|
||||
}
|
||||
// 只剩「不兼容」这一档:换版本/加载器才有救(M191 起它有自己的大类,不再借 `Unsupported`)
|
||||
ContentFailureKind::Incompatible
|
||||
}
|
||||
|
||||
/// 单条失败的 DTO 大类(**复用** `sources::failure_of` 的映射表,不另写一份判据)。
|
||||
fn kind_of(failure: &DependencyFailure) -> ContentFailureKind {
|
||||
match failure.cause {
|
||||
Some(cause) => failure_of(OnlineError::new(cause, failure.message.clone())).kind,
|
||||
// 不兼容那一档没有网络成因:它要用户换版本/加载器,不是「再试一次」
|
||||
None => ContentFailureKind::Incompatible,
|
||||
}
|
||||
}
|
||||
|
||||
/// 可只重试的失败项 → DTO 名单(M191;界面按档分组画的那几条)。
|
||||
///
|
||||
/// 档位**一一映射** core 的 [`FailureKind`]:两档在 DTO 上分开,界面才有两段文案。
|
||||
/// 顺序沿用计划里的顺序(`record_failure` 已按项目键去重,同一项目只出现一次)。
|
||||
#[must_use]
|
||||
pub(super) fn retry_items(
|
||||
plan: &DependencyPlan,
|
||||
root: labels::Root<'_>,
|
||||
names: &labels::Names,
|
||||
) -> Vec<InstallFailureView> {
|
||||
plan.failures
|
||||
.iter()
|
||||
.map(|failure| {
|
||||
InstallFailureView::new(
|
||||
failure.requested_as.clone(),
|
||||
labels::label_of(plan, failure, root, names),
|
||||
failure_kind_of(failure.kind),
|
||||
failure.message.clone(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// core 的档位 → DTO 的档位(**穷尽 match**:谁把两档合并,这条会报错或让用例红)。
|
||||
const fn failure_kind_of(kind: FailureKind) -> InstallFailureKind {
|
||||
match kind {
|
||||
FailureKind::Incompatible => InstallFailureKind::Incompatible,
|
||||
FailureKind::Unreachable => InstallFailureKind::Unreachable,
|
||||
}
|
||||
}
|
||||
|
||||
/// 下一次解析的请求:**只含失败项**(M191)。
|
||||
///
|
||||
/// 这就是「只重试这些」的全部实现——请求来自 core 的
|
||||
/// `DependencyPlan::retry_request()`(它只把失败项列进 `roots`),过滤条件沿用上次那份。
|
||||
/// `present` 必须**重新扫**:这一轮新落盘的文件不在旧名单里,不重扫会被当成「还没有」再下一份
|
||||
/// (core 的 `retry_request` 文档明确要求)。
|
||||
///
|
||||
/// 拿不到计划时(换进程 / 那次是别的实例)调用方**不许**退回「整单重装」:那会把已经装好的
|
||||
/// 文件再下一遍,且请求数放大——正是本函数要避免的行为。
|
||||
#[must_use]
|
||||
pub(super) fn retry_request(plan: &DependencyPlan, present: Vec<PresentFile>) -> ResolveRequest {
|
||||
ResolveRequest {
|
||||
present,
|
||||
..plan.retry_request()
|
||||
}
|
||||
}
|
||||
|
||||
/// 用「只含失败项」的请求重新解析(**生产**用它;用例注入假解析器断言只请求了哪几个项目)。
|
||||
///
|
||||
/// 解析器用闭包注入而不是直接调 core:这样「重试请求里到底有哪几个项目、发了几次」在没有网络
|
||||
/// 的用例里也能钉住(M191 的变异守门:把 `retry_request()` 换成根项目请求,用例必须红)。
|
||||
pub(super) fn retry_plan(
|
||||
plan: &DependencyPlan,
|
||||
present: Vec<PresentFile>,
|
||||
resolve: impl FnOnce(&ResolveRequest) -> DependencyPlan,
|
||||
) -> DependencyPlan {
|
||||
let request = retry_request(plan, present);
|
||||
resolve(&request)
|
||||
}
|
||||
|
||||
/// 失败清单(两档各起一段,各自的条数分开数)。
|
||||
fn report(plan: &DependencyPlan, installed: usize) -> String {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let incompatible: Vec<String> = plan
|
||||
.failures_of(FailureKind::Incompatible)
|
||||
.map(describe)
|
||||
.collect();
|
||||
let unreachable: Vec<String> = plan
|
||||
.failures_of(FailureKind::Unreachable)
|
||||
.map(describe)
|
||||
.collect();
|
||||
if !incompatible.is_empty() {
|
||||
lines.push(format!(
|
||||
"不兼容所选版本/加载器 {} 个:{}",
|
||||
incompatible.len(),
|
||||
incompatible.join(";")
|
||||
));
|
||||
}
|
||||
if !unreachable.is_empty() {
|
||||
lines.push(format!(
|
||||
"取不到(网络 / 项目不存在){} 个:{}",
|
||||
unreachable.len(),
|
||||
unreachable.join(";")
|
||||
));
|
||||
}
|
||||
if installed > 0 {
|
||||
lines.push(format!("已装入 {installed} 个文件(已下好的不回滚)"));
|
||||
}
|
||||
if !plan.skipped.is_empty() {
|
||||
lines.push(format!("跳过 {} 个已存在的文件", plan.skipped.len()));
|
||||
}
|
||||
if let Some(note) = unresolved_note(plan) {
|
||||
lines.push(note);
|
||||
}
|
||||
if !plan.truncated.is_empty() {
|
||||
lines.push(format!(
|
||||
"依赖层数超过上限,未展开:{}",
|
||||
plan.truncated.join("、")
|
||||
));
|
||||
}
|
||||
// 可重试名单:只列失败项(重试时也只重解这些,不重下已装好的)
|
||||
let retry = plan.retry_request().roots;
|
||||
if !retry.is_empty() {
|
||||
lines.push(format!(
|
||||
"可只重试这 {} 个:{}",
|
||||
retry.len(),
|
||||
retry.join("、")
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn describe(failure: &DependencyFailure) -> String {
|
||||
if failure.required_by.is_empty() {
|
||||
format!("{}({})", failure.requested_as, failure.message)
|
||||
} else {
|
||||
format!(
|
||||
"{}({};被 {} 要求)",
|
||||
failure.requested_as,
|
||||
failure.message,
|
||||
failure.required_by.join("、")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 「有 required 但没有项目 id」那一档的提示(解析不了,但也不是没事发生)。
|
||||
fn unresolved_note(plan: &DependencyPlan) -> Option<String> {
|
||||
if plan.unresolved_required.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let items: Vec<String> = plan
|
||||
.unresolved_required
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let what = item
|
||||
.version_id
|
||||
.clone()
|
||||
.or_else(|| item.file_name.clone())
|
||||
.unwrap_or_else(|| "未给版本".to_string());
|
||||
format!("{}(被 {} 要求)", what, item.parent)
|
||||
})
|
||||
.collect();
|
||||
Some(format!(
|
||||
"有 {} 条必备前置只给了版本/文件名、没有项目 id,无法自动解析:{}",
|
||||
items.len(),
|
||||
items.join(";")
|
||||
))
|
||||
}
|
||||
|
||||
/// 全部装成之后的落盘描述(含跳过与前置的条数、「为什么装」的父项目)。
|
||||
///
|
||||
/// `installed` 是**真落盘**的那些(下载失败的不在里面),所以条数与文件名都从它来,
|
||||
/// 不从 `plan.nodes` 来——否则「装成了 N 个」会把下载失败的也数进去。
|
||||
pub(super) fn summary(plan: &DependencyPlan, installed: &[PlannedNode]) -> String {
|
||||
if installed.is_empty() && plan.skipped.is_empty() && plan.unresolved_required.is_empty() {
|
||||
return "没有要装的文件".to_string();
|
||||
}
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if !installed.is_empty() {
|
||||
let dependencies = installed.iter().filter(|node| !node.is_root()).count();
|
||||
// 「为什么装」:前置标出要求它的项目(根是用户自己点的,不标)
|
||||
let names: Vec<String> = installed
|
||||
.iter()
|
||||
.map(|node| {
|
||||
if node.is_root() {
|
||||
node.file.filename.clone()
|
||||
} else {
|
||||
format!(
|
||||
"{}(前置,被 {} 要求)",
|
||||
node.file.filename,
|
||||
node.required_by.join("、")
|
||||
)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let head = if dependencies == 0 {
|
||||
format!("装了 {} 个文件", installed.len())
|
||||
} else {
|
||||
format!(
|
||||
"装了 {} 个文件(含 {} 个前置)",
|
||||
installed.len(),
|
||||
dependencies
|
||||
)
|
||||
};
|
||||
parts.push(format!("{head}:{}", names.join("、")));
|
||||
}
|
||||
if !plan.skipped.is_empty() {
|
||||
let names: Vec<&str> = plan
|
||||
.skipped
|
||||
.iter()
|
||||
.map(|node| node.file_name.as_str())
|
||||
.collect();
|
||||
parts.push(format!(
|
||||
"跳过 {} 个(目录里已存在,没有重复下载):{}",
|
||||
plan.skipped.len(),
|
||||
names.join("、")
|
||||
));
|
||||
}
|
||||
if let Some(note) = unresolved_note(plan) {
|
||||
parts.push(note);
|
||||
}
|
||||
if !plan.truncated.is_empty() {
|
||||
parts.push(format!(
|
||||
"依赖层数超过上限,未展开:{}",
|
||||
plan.truncated.join("、")
|
||||
));
|
||||
}
|
||||
parts.join("\n")
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! 「这条失败该显示什么名字」——**语言中立**的标签来源(M191 r1 P2-1 / r2 P2-1 续)。
|
||||
//!
|
||||
//! 从 `plan.rs` 拆出(加完失败路径上取名字那次 GET 之后父文件到 414 行,越过 400 行硬线)。
|
||||
//! 拆的口径是「一件事一个文件」:这里只管**名字从哪来**,判定与说话仍在 `plan.rs`。
|
||||
//!
|
||||
//! 三个来源,按可信度排(细节在 [`label_of`]):原始根项目的标题、失败文件的文件名、
|
||||
//! 失败路径上现取的项目名;都没有就留空,界面只画项目 id。
|
||||
//!
|
||||
//! **不许**拿 `DependencyFailure::message` 顶:那是 core 的中文散文,画到界面上就是 i18n R2 的违规。
|
||||
|
||||
use unlucky_core::addon::online::{
|
||||
DependencyFailure, DependencyPlan, EngineTransport, FailureKind, Modrinth, OnlineError,
|
||||
ProjectDetail,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
|
||||
use super::super::super::env::HostEnv;
|
||||
|
||||
/// 这一次装入的**原始根项目**(id + 显示名)。
|
||||
///
|
||||
/// 「是不是根」只能按 `id` 比(r2 复审 P2-2):重试那一轮的 plan 来自
|
||||
/// `DependencyPlan::retry_request()`,它的 `request.roots` **就是上一轮的失败名单**——
|
||||
/// 第一个是前置,拿它当「根」会把根的标题贴到前置头上。所以判据是「这条失败的 id 是不是
|
||||
/// 当初那次装入的根 id」,两个值都由 [`remembered`] / `ContentInstallRequest` 提供。
|
||||
#[derive(Clone, Copy)]
|
||||
pub(in crate::host::addon) struct Root<'a> {
|
||||
/// 当初那次装入的根项目 id(`ContentInstallRequest.project`)
|
||||
pub id: &'a str,
|
||||
/// 它的显示名(`ContentInstallRequest.title`;只配给 `id` 用)
|
||||
pub title: &'a str,
|
||||
}
|
||||
|
||||
/// 失败路径上现取的项目名(id → title)。取不到的项目不在表里。
|
||||
pub(in crate::host::addon) type Names = Vec<(String, String)>;
|
||||
|
||||
/// 为「不兼容」档现取项目名(**只在失败路径上**,一个项目一次 GET)。
|
||||
///
|
||||
/// 为什么只给这一档:`FailureKind::Incompatible` 是「站点**有这个项目**,但目标版本/加载器下
|
||||
/// 没有条目」——项目确实存在,所以这次 GET 有东西可拿。「取不到」档里 404 的那部分项目根本
|
||||
/// 不存在,给它发请求只会再吃一个 404,于是那一档仍显示 id(合理兜底)。
|
||||
///
|
||||
/// `lookup` 由调用方注入(生产是 `Modrinth::project`,用例是闭包):于是「取名字失败也要能降级」
|
||||
/// 这件事在没有网络的用例里也能钉住。取不到 / 拿到空串的项一律**不进**名单。
|
||||
pub(in crate::host::addon) fn names_for_incompatible(
|
||||
plan: &DependencyPlan,
|
||||
lookup: impl Fn(&str) -> Option<String>,
|
||||
) -> Names {
|
||||
plan.failures_of(FailureKind::Incompatible)
|
||||
.filter_map(|failure| {
|
||||
let title = lookup(&failure.requested_as)?;
|
||||
let title = title.trim();
|
||||
(!title.is_empty()).then(|| (failure.requested_as.clone(), title.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 一条失败的可读标签(**语言中立**:站点标题、文件名或现取的项目名;拿不到就是空串)。
|
||||
///
|
||||
/// 三个来源,**按可信度排**:
|
||||
///
|
||||
/// | 情况 | 标签 | 为什么 |
|
||||
/// |---|---|---|
|
||||
/// | 这条失败的 id == 原始根 id | 请求里的 `title` | 用户刚才点的就是它 |
|
||||
/// | 计划里有这个节点(下载失败) | `node.file.filename` | 「哪个文件没下来」比项目名更具体 |
|
||||
/// | 不兼容档 | [`names_for_incompatible`] 现取的名字 | 项目存在,一次 GET 拿得到 |
|
||||
/// | 其余(解析 404 等) | 空 | 项目不存在,显示 id 是合理兜底 |
|
||||
///
|
||||
/// **不许**拿 `failure.message` 顶:那是 core 的中文散文,画到界面上就是 i18n R2 的违规。
|
||||
#[must_use]
|
||||
pub(super) fn label_of(
|
||||
plan: &DependencyPlan,
|
||||
failure: &DependencyFailure,
|
||||
root: Root<'_>,
|
||||
names: &Names,
|
||||
) -> String {
|
||||
// ① 根项目自己失败:**按 id 比**,不看 `plan.request.roots`(重试轮里那是失败名单)
|
||||
if failure.requested_as == root.id {
|
||||
return root.title.to_string();
|
||||
}
|
||||
// ② 计划里有这个节点(下载失败):文件名最具体
|
||||
if let Some(node) = plan
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.requested_as == failure.requested_as)
|
||||
{
|
||||
return node.file.filename.clone();
|
||||
}
|
||||
// ③ 不兼容档:失败路径上现取的名字;取不到就空 ⇒ 界面只画 id
|
||||
if failure.kind == FailureKind::Incompatible {
|
||||
return names
|
||||
.iter()
|
||||
.find(|(id, _)| id == &failure.requested_as)
|
||||
.map(|(_, title)| title.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// 取一个项目的显示名(**只在失败路径上**发)。
|
||||
///
|
||||
/// 一次 GET,折成「有名字 / 没名字」的那一步在 [`title_from`](那里才是能坏的地方,因此那一步
|
||||
/// 单独可测;本函数只有「怎么发这一枪」)。
|
||||
pub(in crate::host::addon) fn fetch_title(env: &HostEnv, id: &str) -> Option<String> {
|
||||
let core_engine = crate::host::engine::engine(env);
|
||||
let transport = EngineTransport::new(&core_engine);
|
||||
let client = Modrinth::new(&transport);
|
||||
title_from(|id| block_on(async { client.project(id).await }), id)
|
||||
}
|
||||
|
||||
/// 把「一次项目查询」折成「有名字 / 没名字」——**降级就在这一层**(r3 复审 p2 要的守卫)。
|
||||
///
|
||||
/// 三种「没名字」都算 `None`,一个都不许往外冒:
|
||||
///
|
||||
/// | 情况 | 结果 |
|
||||
/// |---|---|
|
||||
/// | 查询失败(没网 / 404 / 坏响应) | `None`(**不是 panic,也不是错误**) |
|
||||
/// | 站点给了空标题 | `None` |
|
||||
/// | 站点给了全空白标题 | `None` |
|
||||
///
|
||||
/// 为什么单独抽出来:这条链路上唯一会「坏」的就是这一句折叠。抽成参数化的纯函数之后,
|
||||
/// 「查询失败仍退回 id」与「空白不算名字」都能在没有网络的用例里钉住(变异:`ok` 换 `expect`
|
||||
/// ⇒ 红;去掉空串过滤 ⇒ 红)。
|
||||
fn title_from(
|
||||
query: impl FnOnce(&str) -> Result<ProjectDetail, OnlineError>,
|
||||
id: &str,
|
||||
) -> Option<String> {
|
||||
query(id)
|
||||
.ok()
|
||||
.map(|project| project.title)
|
||||
.filter(|title| !title.trim().is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn detail(title: &str) -> ProjectDetail {
|
||||
ProjectDetail {
|
||||
id: "P7dR8mSH".to_string(),
|
||||
title: title.to_string(),
|
||||
..ProjectDetail::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// **降级层**:查询失败 / 空标题 / 全空白 ⇒ 一律 `None`(界面退回只画 id),不 panic。
|
||||
///
|
||||
/// 抓得住:把 `.ok()` 换成 `expect`(查询失败即 panic)⇒ 第一条红;
|
||||
/// 去掉 `.filter(..)`(空白也算名字)⇒ 后两条红。
|
||||
#[test]
|
||||
fn a_failed_or_blank_lookup_yields_no_name() {
|
||||
// ① 查询失败(没网 / 404 / 坏响应)
|
||||
assert!(title_from(|_| Err(OnlineError::offline("断网")), "P7dR8mSH").is_none());
|
||||
// ② 站点给了空标题
|
||||
assert!(title_from(|_| Ok(detail("")), "P7dR8mSH").is_none());
|
||||
// ③ 站点给了全空白标题
|
||||
assert!(title_from(|_| Ok(detail(" ")), "P7dR8mSH").is_none());
|
||||
// ④ 正常情况:原样给出
|
||||
assert_eq!(
|
||||
title_from(|_| Ok(detail("Entity Culling")), "P7dR8mSH"),
|
||||
Some("Entity Culling".to_string())
|
||||
);
|
||||
// 传进去的 id 就是查询用的那个(不要拿别的键去问)
|
||||
let mut asked = String::new();
|
||||
let _ = title_from(
|
||||
|id| {
|
||||
asked.push_str(id);
|
||||
Ok(detail("X"))
|
||||
},
|
||||
"P7dR8mSH",
|
||||
);
|
||||
assert_eq!(asked, "P7dR8mSH");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! 「上一次装入留下的计划」表(M191):`retry_plan()` 的输入。
|
||||
//!
|
||||
//! 从 `plan.rs` 拆出(父文件加了 `label_of` 之后顶到 429 行,越过 400 行硬线)。
|
||||
//! 拆法与本仓既有 `-p1/-p2` 一致:**纯搬移**,一行不改语义;调用方路径不变
|
||||
//! (`plan.rs` 仍然 `pub(super) use` 这三个函数)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use unlucky_core::addon::online::DependencyPlan;
|
||||
|
||||
use super::super::super::env::HostEnv;
|
||||
|
||||
/// 「上一次装入留下的计划」表的一行(M191):`只重试这些` 的输入。
|
||||
///
|
||||
/// ## 为什么要一张静态表(说清楚这笔债)
|
||||
///
|
||||
/// 重试要的输入是**上一轮那份 `DependencyPlan`**(`retry_request()` 是它的方法)。它现在活在
|
||||
/// `CoreHost`(`host/dispatch.rs`)之外,因为那个文件不在本 mission 的 scope;与
|
||||
/// `addon/pack.rs` 的 `PACK_STATE` 同一手法:每 host 一格(键 = 数据根),于是测试里两个 host
|
||||
/// 互不干扰。`host/mod.rs` 一旦进 scope,把这张表搬成一个字段即可(这表就是那个字段)。
|
||||
///
|
||||
/// 键里带 `instance`:同一台机器上往两个实例装过东西,重试只该重试这个实例那一次的计划。
|
||||
/// 成功之后由 [`forget`] 清掉——「全成功」时不该留下任何可重试的东西。
|
||||
///
|
||||
/// ## 为什么连**原始根项目 id** 一起记(r2 复审 P2-2)
|
||||
///
|
||||
/// 「这一条是不是根项目自己失败」不能靠 `plan.request.roots.first()` 判:重试那一轮的 plan 来自
|
||||
/// `DependencyPlan::retry_request()`,它的 `roots` 就是**上一轮的失败名单**——第一个是前置,
|
||||
/// 拿它跟根的显示名一比,就会把 `Sodium` 贴到一个前置头上。所以判据只能是「这个 id 是不是
|
||||
/// 当初那次装入的根 id」,那个 id 得跟计划一起记下来。
|
||||
struct Entry {
|
||||
host: PathBuf,
|
||||
instance: String,
|
||||
/// 落盘目录(重试要按它重新扫 `present`,且**不许**换目录)
|
||||
dir: PathBuf,
|
||||
/// 当初那次装入的根项目 id(`ContentInstallRequest.project`,判「是不是根」的唯一依据)
|
||||
root: String,
|
||||
/// 当初那次装入的显示名(只配给 `root` 用)
|
||||
title: String,
|
||||
plan: DependencyPlan,
|
||||
}
|
||||
|
||||
static REMEMBERED: Mutex<Vec<Entry>> = Mutex::new(Vec::new());
|
||||
|
||||
/// 取回的那一份记录(拆成结构体而不是四元组:调用点读得懂哪一个是哪个)。
|
||||
pub(in crate::host::addon) struct Remembered {
|
||||
/// 落盘目录
|
||||
pub dir: PathBuf,
|
||||
/// 当初那次装入的根项目 id
|
||||
pub root: String,
|
||||
/// 当初那次装入的显示名
|
||||
pub title: String,
|
||||
/// 上一轮的依赖计划(`retry_request()` 的来源)
|
||||
pub plan: DependencyPlan,
|
||||
}
|
||||
|
||||
/// 记下这一轮的计划(**覆盖**同一个 host + 实例的上一份)。
|
||||
pub(in crate::host::addon) fn remember(
|
||||
env: &HostEnv,
|
||||
instance: &str,
|
||||
dir: &Path,
|
||||
root: &str,
|
||||
title: &str,
|
||||
plan: &DependencyPlan,
|
||||
) {
|
||||
let host = env.layout.home().to_path_buf();
|
||||
let mut table = REMEMBERED
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
table.retain(|item| !(item.host == host && item.instance == instance));
|
||||
table.push(Entry {
|
||||
host,
|
||||
instance: instance.to_string(),
|
||||
dir: dir.to_path_buf(),
|
||||
root: root.to_string(),
|
||||
title: title.to_string(),
|
||||
plan: plan.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 忘掉这个实例的计划(装成功 / 没有失败项时调:没有可重试的东西了)。
|
||||
pub(in crate::host::addon) fn forget(env: &HostEnv, instance: &str) {
|
||||
let host = env.layout.home().to_path_buf();
|
||||
let mut table = REMEMBERED
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
table.retain(|item| !(item.host == host && item.instance == instance));
|
||||
}
|
||||
|
||||
/// 这个实例上一次失败的计划(+ 落盘目录 + 原始根 id 与显示名);`None` = 没有可重试的记录。
|
||||
pub(in crate::host::addon) fn remembered(env: &HostEnv, instance: &str) -> Option<Remembered> {
|
||||
let host = env.layout.home().to_path_buf();
|
||||
let table = REMEMBERED
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
table
|
||||
.iter()
|
||||
.find(|item| item.host == host && item.instance == instance)
|
||||
.map(|item| Remembered {
|
||||
dir: item.dir.clone(),
|
||||
root: item.root.clone(),
|
||||
title: item.title.clone(),
|
||||
plan: item.plan.clone(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
//! 两个站点的检索方言(M109;M162 起 Modrinth 走 `core::addon::online`)+ **详情/版本列表**。
|
||||
//!
|
||||
//! # M162 之后的分工
|
||||
//!
|
||||
//! | 站点 | 检索 | 详情 / 版本列表 | 端点拼装与 JSON 解析在哪 |
|
||||
//! |------|------|------------------|--------------------------|
|
||||
//! | Modrinth | `core::addon::online` | `core::addon::online` | core(`online::modrinth` / `online::model`) |
|
||||
//! | CurseForge | 本文件(要 `X-API-KEY`) | **未接通**(明确报错) | 本文件 |
|
||||
//!
|
||||
//! 为什么 Modrinth 搬走:「在线内容源」是 core 的能力(导出的 `RemoteCatalog`、导入的
|
||||
//! `PackFetcher`、UI 的检索要共用同一份解析与同一套错误分类)。presenter 只留 DTO 映射与
|
||||
//! 失败分类——它不再自己拼 URL、不再自己读 JSON 字段名。
|
||||
//!
|
||||
//! # 两个站点的字段名完全不同(CF 这一半仍是本文件的活)
|
||||
//!
|
||||
//! | 语义 | Modrinth `/v2/search` | CurseForge `/v1/mods/search` |
|
||||
//! |---|---|---|
|
||||
//! | 项目 id | `project_id`(base62) | `id`(数字) |
|
||||
//! | 标题 / 简介 | `title` / `description` | `name` / `summary` |
|
||||
//! | 下载量 | `downloads` | `downloadCount` |
|
||||
//! | 游戏版本 | `versions` | `latestFilesIndexes[].gameVersion` |
|
||||
//! | 加载器 | `categories` 里混着的 `fabric`/`forge`/… | `latestFilesIndexes[].modLoader`(数字枚举) |
|
||||
//!
|
||||
//! 加载器那一行是最容易抄错的地方:Modrinth 的 `categories` 同时装着 `optimization` 这种**分类标签**,
|
||||
//! 直接整份当加载器用,画面上就会多出「optimization」这种假加载器(core 那一侧的
|
||||
//! `known_loaders` 已经过滤,`sources/tests` 钉住了这一条)。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use unlucky_core::addon::online::{
|
||||
ContentType, Modrinth, OnlineError, OnlineErrorKind, SearchQuery, SortIndex, VersionQuery,
|
||||
};
|
||||
|
||||
use crate::dto::{
|
||||
loader_facet, ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind,
|
||||
ContentHit, ContentQuery, ContentResults, ContentSource, ContentVersionView,
|
||||
};
|
||||
|
||||
use super::super::engine;
|
||||
use super::super::env::HostEnv;
|
||||
use super::curseforge_ready;
|
||||
use super::http::{encode, get};
|
||||
use unlucky_core::addon::online::EngineTransport;
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
|
||||
// Modrinth 的根不再在本文件:URL 拼装与解析都在 `core::addon::online`,这里只留 CF 的
|
||||
// (它要 `X-API-KEY`,是 core 那一侧**故意**没接的那半)。
|
||||
/// CurseForge v1 的根(需要 `X-API-KEY`)。
|
||||
pub(super) const CURSEFORGE: &str = "https://api.curseforge.com/v1";
|
||||
/// CurseForge 的「Minecraft」gameId(官方固定值)。
|
||||
const CF_MINECRAFT: u32 = 432;
|
||||
|
||||
/// 检索分派。
|
||||
pub(super) fn search(
|
||||
env: &HostEnv,
|
||||
query: &ContentQuery,
|
||||
) -> Result<ContentResults, ContentFailure> {
|
||||
match query.source {
|
||||
ContentSource::Modrinth => modrinth(env, query),
|
||||
ContentSource::Curseforge => curseforge(env, query),
|
||||
}
|
||||
}
|
||||
|
||||
/// 详情 + 版本列表分派(M162)。
|
||||
pub(super) fn detail(
|
||||
env: &HostEnv,
|
||||
request: &ContentDetailRequest,
|
||||
) -> Result<ContentDetail, ContentFailure> {
|
||||
match request.source {
|
||||
ContentSource::Modrinth => modrinth_detail(env, request),
|
||||
ContentSource::Curseforge => Err(ContentFailure::unsupported(
|
||||
"CurseForge 的详情/版本列表未接通(要 X-API-KEY 的文件端点);先切到 Modrinth 源",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- Modrinth
|
||||
|
||||
fn modrinth(env: &HostEnv, query: &ContentQuery) -> Result<ContentResults, ContentFailure> {
|
||||
let core_engine = engine::engine(env);
|
||||
let transport = EngineTransport::new(&core_engine);
|
||||
let client = Modrinth::new(&transport);
|
||||
let page = block_on(client.search(&search_query(query))).map_err(failure_of)?;
|
||||
Ok(ContentResults {
|
||||
query: query.clone(),
|
||||
hits: page.hits.iter().map(hit_of).collect(),
|
||||
total: u32::try_from(page.total_hits).unwrap_or(0),
|
||||
curseforge: curseforge_ready(),
|
||||
})
|
||||
}
|
||||
|
||||
fn modrinth_detail(
|
||||
env: &HostEnv,
|
||||
request: &ContentDetailRequest,
|
||||
) -> Result<ContentDetail, ContentFailure> {
|
||||
let core_engine = engine::engine(env);
|
||||
let transport = EngineTransport::new(&core_engine);
|
||||
let client = Modrinth::new(&transport);
|
||||
let query = VersionQuery {
|
||||
project: request.project.clone(),
|
||||
game_version: request.game_version.clone(),
|
||||
loader: request.loader.and_then(loader_facet).map(str::to_string),
|
||||
};
|
||||
// 项目详情 + 版本列表是**两次串行 GET**,包在**一次** `block_on` 里(宿主侧少一层阻塞边界,
|
||||
// 也就少一次运行时进出——M165 登记的「单请求两次 block_on」在这里收掉)。
|
||||
//
|
||||
// 这不是嵌套 `block_on`:两段 `await` 依次跑在同一个 future 里,落在同一个常驻运行时
|
||||
// (`core::download::blocking`)与同一个共享引擎上,连接池/限流照常复用。
|
||||
// 失败语义不变:哪一步先失败就先返回,与拆成两次调用时的先后顺序一致,分类仍走 `failure_of`。
|
||||
let (project, versions) = block_on(async {
|
||||
let project = client.project(&request.project).await?;
|
||||
let versions = client.versions(&query).await?;
|
||||
Ok::<_, OnlineError>((project, versions))
|
||||
})
|
||||
.map_err(failure_of)?;
|
||||
Ok(ContentDetail {
|
||||
project: if project.id.is_empty() {
|
||||
request.project.clone()
|
||||
} else {
|
||||
project.id
|
||||
},
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
body: project.body,
|
||||
versions: versions.iter().map(version_of).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// DTO 检索条件 → core 的检索请求(**唯一**一处字段搬运)。
|
||||
fn search_query(query: &ContentQuery) -> SearchQuery {
|
||||
SearchQuery {
|
||||
text: query.text.clone(),
|
||||
project_type: ContentType::parse(query.kind.modrinth_type()),
|
||||
game_version: query.game_version.clone(),
|
||||
loader: query.loader.and_then(loader_facet).map(str::to_string),
|
||||
index: match query.sort {
|
||||
crate::dto::ContentSort::Relevance => SortIndex::Relevance,
|
||||
crate::dto::ContentSort::Downloads => SortIndex::Downloads,
|
||||
crate::dto::ContentSort::Newest => SortIndex::Newest,
|
||||
crate::dto::ContentSort::Updated => SortIndex::Updated,
|
||||
},
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
}
|
||||
}
|
||||
|
||||
/// core 的命中项 → DTO(缺字段一律给空/零,**不**编造)。
|
||||
fn hit_of(hit: &unlucky_core::addon::online::ProjectHit) -> ContentHit {
|
||||
ContentHit {
|
||||
id: hit.project_id.clone(),
|
||||
slug: hit.slug.clone(),
|
||||
title: hit.title.clone(),
|
||||
summary: hit.description.clone(),
|
||||
author: hit.author.clone(),
|
||||
downloads: hit.downloads,
|
||||
updated: date_part(&hit.updated),
|
||||
source: ContentSource::Modrinth,
|
||||
game_versions: hit.game_versions.clone(),
|
||||
loaders: hit.loaders.clone(),
|
||||
icon: hit.icon_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// core 的版本 → DTO 的版本行(文件名/大小取 `primary` 那个)。
|
||||
fn version_of(version: &unlucky_core::addon::online::Version) -> ContentVersionView {
|
||||
let file = version.primary_file();
|
||||
ContentVersionView {
|
||||
id: version.id.clone(),
|
||||
name: version.name.clone(),
|
||||
number: version.version_number.clone(),
|
||||
kind: version.version_type.as_str().to_string(),
|
||||
game_versions: version.game_versions.clone(),
|
||||
loaders: version.loaders.clone(),
|
||||
published: date_part(&version.date_published),
|
||||
file_name: file
|
||||
.map(|file| file.filename.clone())
|
||||
.filter(|name| !name.trim().is_empty()),
|
||||
file_size: file.map(|file| file.size).filter(|size| *size > 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// core 的错误 → DTO 的失败大类(**一一映射,不重新判**)。
|
||||
pub(super) fn failure_of(error: OnlineError) -> ContentFailure {
|
||||
let kind = match error.kind() {
|
||||
OnlineErrorKind::NotConnected => ContentFailureKind::Offline,
|
||||
OnlineErrorKind::NotFound => ContentFailureKind::NotFound,
|
||||
OnlineErrorKind::Server => ContentFailureKind::Server,
|
||||
OnlineErrorKind::Unsupported => ContentFailureKind::Unsupported,
|
||||
OnlineErrorKind::Malformed | OnlineErrorKind::Io | OnlineErrorKind::Integrity => {
|
||||
ContentFailureKind::Other
|
||||
}
|
||||
};
|
||||
ContentFailure::new(kind, error.message().to_string())
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- CurseForge
|
||||
|
||||
fn curseforge(env: &HostEnv, query: &ContentQuery) -> Result<ContentResults, ContentFailure> {
|
||||
let Some(key) = super::curseforge_key() else {
|
||||
return Err(ContentFailure::unsupported(
|
||||
"CurseForge 需要 API key(ULL_CF_API_KEY 未设置)",
|
||||
));
|
||||
};
|
||||
let mut url = format!(
|
||||
"{CURSEFORGE}/mods/search?gameId={CF_MINECRAFT}&classId={}&index={}&pageSize={}&sortField={}&sortOrder=desc",
|
||||
query.kind.curseforge_class(),
|
||||
query.offset,
|
||||
query.limit.min(50),
|
||||
query.sort.curseforge_field()
|
||||
);
|
||||
if !query.text.trim().is_empty() {
|
||||
url.push_str(&format!("&searchFilter={}", encode(query.text.trim())));
|
||||
}
|
||||
if let Some(version) = &query.game_version {
|
||||
url.push_str(&format!("&gameVersion={}", encode(version)));
|
||||
}
|
||||
|
||||
let body = get(env, &url, &[("x-api-key", key.as_str())]).map_err(cf_failure)?;
|
||||
let json: Value = serde_json::from_str(&body).map_err(|error| {
|
||||
ContentFailure::new(
|
||||
ContentFailureKind::Other,
|
||||
format!("CurseForge 返回的不是合法 JSON:{error}"),
|
||||
)
|
||||
})?;
|
||||
let hits = json
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| {
|
||||
ContentFailure::new(ContentFailureKind::Other, "CurseForge 返回里没有 data 字段")
|
||||
})?
|
||||
.iter()
|
||||
.map(curseforge_hit)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(ContentResults {
|
||||
query: query.clone(),
|
||||
hits,
|
||||
total: json
|
||||
.get("pagination")
|
||||
.map(|pagination| u64_of(pagination, "totalCount"))
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.unwrap_or(0),
|
||||
curseforge: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// CurseForge 的字符串错误 → 失败大类:只有「缺密钥」是 Unsupported,其余按站点/其它。
|
||||
fn cf_failure(reason: String) -> ContentFailure {
|
||||
let kind = if reason.contains("ULL_CF_API_KEY") {
|
||||
ContentFailureKind::Unsupported
|
||||
} else if reason.contains("HTTP 503")
|
||||
|| reason.contains("HTTP 502")
|
||||
|| reason.contains("HTTP 500")
|
||||
{
|
||||
ContentFailureKind::Server
|
||||
} else {
|
||||
ContentFailureKind::Other
|
||||
};
|
||||
ContentFailure::new(kind, reason)
|
||||
}
|
||||
|
||||
/// 一条 CF 命中项 → DTO。加载器只能从 `latestFilesIndexes[].modLoader` 的数字枚举反推。
|
||||
fn curseforge_hit(value: &Value) -> ContentHit {
|
||||
let mut game_versions: Vec<String> = Vec::new();
|
||||
let mut loaders: Vec<String> = Vec::new();
|
||||
if let Some(indexes) = value.get("latestFilesIndexes").and_then(Value::as_array) {
|
||||
for entry in indexes {
|
||||
if let Some(version) = entry.get("gameVersion").and_then(Value::as_str) {
|
||||
if !game_versions.iter().any(|item| item == version) {
|
||||
game_versions.push(version.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(loader) = entry
|
||||
.get("modLoader")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(curseforge_loader)
|
||||
{
|
||||
if !loaders.iter().any(|item| item == loader) {
|
||||
loaders.push(loader.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let author = value
|
||||
.get("authors")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.map(|entry| text(entry, "name"))
|
||||
.unwrap_or_default();
|
||||
|
||||
ContentHit {
|
||||
id: value
|
||||
.get("id")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_default(),
|
||||
slug: text(value, "slug"),
|
||||
title: text(value, "name"),
|
||||
summary: text(value, "summary"),
|
||||
author,
|
||||
downloads: u64_of(value, "downloadCount"),
|
||||
updated: date_part(&text(value, "dateModified")),
|
||||
source: ContentSource::Curseforge,
|
||||
game_versions,
|
||||
loaders,
|
||||
icon: value
|
||||
.get("logo")
|
||||
.and_then(|logo| logo.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// CF 的 `modLoader` 枚举 → Modrinth 口径的 loader 名(1 Forge / 4 Fabric / 5 Quilt / 6 NeoForge)。
|
||||
pub(super) fn curseforge_loader(raw: u64) -> Option<&'static str> {
|
||||
match raw {
|
||||
1 => Some("forge"),
|
||||
4 => Some("fabric"),
|
||||
5 => Some("quilt"),
|
||||
6 => Some("neoforge"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 小工具
|
||||
|
||||
/// 取字符串字段(缺 / 类型不对 ⇒ 空串)。
|
||||
fn text(value: &Value, key: &str) -> String {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn u64_of(value: &Value, key: &str) -> u64 {
|
||||
value.get(key).and_then(Value::as_u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 四条侧栏的常量表没被改乱(检索本身按 `kind` 走 facet)。
|
||||
#[must_use]
|
||||
pub(super) fn all_kinds() -> [crate::dto::ContentKind; 4] {
|
||||
crate::dto::ContentKind::ALL
|
||||
}
|
||||
|
||||
/// ISO-8601 → 日期部分(`2024-05-01T…` → `2024-05-01`;短串原样返回)。
|
||||
pub(super) fn date_part(raw: &str) -> String {
|
||||
raw.get(..10).unwrap_or(raw).to_string()
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//! 内容库宿主的守门测试(M109):编码、文件名穿越、退避判定、两个站点的方言映射。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::http::{encode, retry_after, retryable};
|
||||
use super::install::safe_file_name;
|
||||
use super::sources::{all_kinds, curseforge_loader, date_part, failure_of};
|
||||
use crate::dto::{ContentFailureKind, ContentKind};
|
||||
use unlucky_core::addon::online::{OnlineError, OnlineErrorKind};
|
||||
|
||||
#[test]
|
||||
fn percent_encoding_keeps_the_query_readable_and_escapes_the_rest() {
|
||||
assert_eq!(encode("1.20.1"), "1.20.1");
|
||||
assert_eq!(
|
||||
encode("[\"versions:1.20.1\"]"),
|
||||
"%5B%22versions%3A1.20.1%22%5D"
|
||||
);
|
||||
assert_eq!(encode("中文"), "%E4%B8%AD%E6%96%87");
|
||||
assert_eq!(encode("a b"), "a%20b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_names_never_carry_a_path() {
|
||||
// 抓得住:站点给的 `../../evil.jar` 落到实例目录之外(穿越)。
|
||||
assert_eq!(safe_file_name("sodium.jar"), "sodium.jar");
|
||||
assert_eq!(safe_file_name("../../evil.jar"), "evil.jar");
|
||||
assert_eq!(safe_file_name("mods\\jei.jar"), "jei.jar");
|
||||
assert_eq!(safe_file_name(".."), "download.bin");
|
||||
assert_eq!(safe_file_name(""), "download.bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates_are_sliced_not_guessed() {
|
||||
assert_eq!(date_part("2024-05-01T12:00:00Z"), "2024-05-01");
|
||||
assert_eq!(date_part("2024-05"), "2024-05");
|
||||
assert_eq!(date_part(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_rate_limited_and_transient_failures_are_retried() {
|
||||
assert!(retryable("HTTP 429 (retry_after=Some(3))"));
|
||||
assert!(retryable("HTTP 503 (retry_after=None)"));
|
||||
assert!(!retryable("HTTP 404 (retry_after=None)"));
|
||||
assert!(
|
||||
!retryable("HTTP 401 (retry_after=None)"),
|
||||
"密钥错重试只会更糟"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_prefers_the_server_and_falls_back_to_exponential() {
|
||||
assert_eq!(
|
||||
retry_after("HTTP 429 (retry_after=Some(7))", 0),
|
||||
Duration::from_secs(7)
|
||||
);
|
||||
assert_eq!(
|
||||
retry_after("HTTP 429 (retry_after=None)", 0),
|
||||
Duration::from_secs(1)
|
||||
);
|
||||
assert_eq!(
|
||||
retry_after("HTTP 429 (retry_after=None)", 2),
|
||||
Duration::from_secs(4)
|
||||
);
|
||||
// 服务端要得再多也不等超过 30s(交互与截图不能被一次退避挂死)
|
||||
assert_eq!(
|
||||
retry_after("HTTP 429 (retry_after=Some(900))", 0),
|
||||
Duration::from_secs(30)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curseforge_loader_ids_follow_the_official_enum() {
|
||||
assert_eq!(curseforge_loader(1), Some("forge"));
|
||||
assert_eq!(curseforge_loader(4), Some("fabric"));
|
||||
assert_eq!(curseforge_loader(5), Some("quilt"));
|
||||
assert_eq!(curseforge_loader(6), Some("neoforge"));
|
||||
assert_eq!(curseforge_loader(99), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_four_content_kinds_are_the_four_side_entries() {
|
||||
assert_eq!(
|
||||
all_kinds().map(ContentKind::slug),
|
||||
["modpack", "mod", "resourcepack", "shader"]
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------ M162:失败分类(core → DTO)
|
||||
|
||||
/// core 的 `OnlineErrorKind` → DTO 的 `ContentFailureKind`:**一一映射,不重新判**。
|
||||
///
|
||||
/// 「没联网」是单独一类:页面据此画离线态(说「检查网络」),而不是画「换个关键词」。
|
||||
#[test]
|
||||
fn core_errors_map_to_dto_failure_kinds() {
|
||||
let cases = [
|
||||
(OnlineErrorKind::NotConnected, ContentFailureKind::Offline),
|
||||
(OnlineErrorKind::NotFound, ContentFailureKind::NotFound),
|
||||
(OnlineErrorKind::Server, ContentFailureKind::Server),
|
||||
(
|
||||
OnlineErrorKind::Unsupported,
|
||||
ContentFailureKind::Unsupported,
|
||||
),
|
||||
(OnlineErrorKind::Malformed, ContentFailureKind::Other),
|
||||
(OnlineErrorKind::Io, ContentFailureKind::Other),
|
||||
(OnlineErrorKind::Integrity, ContentFailureKind::Other),
|
||||
];
|
||||
for (core, expected) in cases {
|
||||
let failure = failure_of(OnlineError::new(core, "x"));
|
||||
assert_eq!(failure.kind, expected, "{} 映射错了", core.as_str());
|
||||
}
|
||||
assert!(failure_of(OnlineError::offline("connect timed out")).is_offline());
|
||||
// 详情/版本列表的文案要带出 core 的原话(不是二次包装的「出错了」)
|
||||
assert_eq!(
|
||||
failure_of(OnlineError::offline("connect timed out")).detail,
|
||||
"connect timed out"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------ M186:前置解析的两档失败与可重试名单
|
||||
|
||||
use super::install::finish;
|
||||
use super::plan::{failure_of_plan, summary};
|
||||
use crate::dto::{InstallProgressView, InstallStage};
|
||||
use unlucky_core::addon::online::{
|
||||
DependencyFailure, DependencyPlan, FailureKind, PlannedNode, ResolveRequest, SkipReason,
|
||||
SkippedNode, UnresolvedRequired, VersionFile,
|
||||
};
|
||||
|
||||
fn node(project: &str, file: &str, depth: u8, required_by: &[&str]) -> PlannedNode {
|
||||
PlannedNode {
|
||||
project: project.to_string(),
|
||||
requested_as: project.to_string(),
|
||||
version_id: format!("ver-{project}"),
|
||||
version_name: project.to_string(),
|
||||
version_number: "1.0".to_string(),
|
||||
file: VersionFile {
|
||||
url: format!("https://cdn.modrinth.com/data/{project}/1.0/{file}"),
|
||||
filename: file.to_string(),
|
||||
size: 1024,
|
||||
..VersionFile::default()
|
||||
},
|
||||
depth,
|
||||
required_by: required_by
|
||||
.iter()
|
||||
.map(|parent| (*parent).to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(
|
||||
project: &str,
|
||||
kind: FailureKind,
|
||||
cause: Option<OnlineErrorKind>,
|
||||
message: &str,
|
||||
) -> DependencyFailure {
|
||||
DependencyFailure {
|
||||
requested_as: project.to_string(),
|
||||
required_by: vec!["AANobbMI".to_string()],
|
||||
depth: 1,
|
||||
kind,
|
||||
cause,
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn plan(
|
||||
failures: Vec<DependencyFailure>,
|
||||
nodes: Vec<PlannedNode>,
|
||||
skipped: Vec<SkippedNode>,
|
||||
) -> DependencyPlan {
|
||||
DependencyPlan {
|
||||
request: ResolveRequest::single("sodium"),
|
||||
nodes,
|
||||
skipped,
|
||||
failures,
|
||||
truncated: Vec::new(),
|
||||
unresolved_required: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 全是「不兼容」时:说换版本/加载器,而不是「下载失败」,并且照样给重试名单。
|
||||
#[test]
|
||||
fn an_incompatible_only_plan_is_not_reported_as_a_download_failure() {
|
||||
let plan = plan(
|
||||
vec![failure(
|
||||
"P7dR8mSH",
|
||||
FailureKind::Incompatible,
|
||||
None,
|
||||
"没有匹配所选游戏版本/加载器(1.20.1 / fabric)的版本",
|
||||
)],
|
||||
vec![node("AANobbMI", "sodium.jar", 0, &[])],
|
||||
Vec::new(),
|
||||
);
|
||||
let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条");
|
||||
// M191:「不兼容」有了自己的大类(以前借 `Unsupported`,与「缺密钥」共用一档)
|
||||
assert_eq!(failure.kind, ContentFailureKind::Incompatible);
|
||||
assert!(failure.kind.is_incompatible());
|
||||
assert!(!failure.is_offline());
|
||||
assert!(failure.detail.contains("不兼容所选版本/加载器 1 个"));
|
||||
assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH"));
|
||||
assert!(!failure.detail.contains("下载失败"), "{}", failure.detail);
|
||||
assert!(
|
||||
!failure.detail.contains("取不到"),
|
||||
"两档不许混:{}",
|
||||
failure.detail
|
||||
);
|
||||
}
|
||||
|
||||
/// 两档同时出现时:两段都写,大类按「用户接下来该做什么」选(查无此项 > 不兼容)。
|
||||
#[test]
|
||||
fn a_mixed_plan_keeps_both_buckets_and_picks_the_actionable_kind() {
|
||||
let plan = plan(
|
||||
vec![
|
||||
failure(
|
||||
"P7dR8mSH",
|
||||
FailureKind::Incompatible,
|
||||
None,
|
||||
"没有匹配的版本",
|
||||
),
|
||||
failure(
|
||||
"1eAoo2KR",
|
||||
FailureKind::Unreachable,
|
||||
Some(OnlineErrorKind::NotFound),
|
||||
"取版本失败(online_not_found):HTTP 404",
|
||||
),
|
||||
],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条");
|
||||
assert_eq!(failure.kind, ContentFailureKind::NotFound);
|
||||
assert!(failure.detail.contains("不兼容所选版本/加载器 1 个"));
|
||||
assert!(failure.detail.contains("取不到(网络 / 项目不存在)1 个"));
|
||||
assert!(failure.detail.contains("可只重试这 2 个"));
|
||||
}
|
||||
|
||||
/// 没网那一档:大类是离线(页面据此画「检查网络」而不是「换个关键词」)。
|
||||
#[test]
|
||||
fn an_offline_jump_wins_over_everything_else() {
|
||||
let plan = plan(
|
||||
vec![
|
||||
failure(
|
||||
"P7dR8mSH",
|
||||
FailureKind::Incompatible,
|
||||
None,
|
||||
"没有匹配的版本",
|
||||
),
|
||||
failure(
|
||||
"1eAoo2KR",
|
||||
FailureKind::Unreachable,
|
||||
Some(OnlineErrorKind::NotConnected),
|
||||
"连不上 api.modrinth.com",
|
||||
),
|
||||
],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条");
|
||||
assert_eq!(failure.kind, ContentFailureKind::Offline);
|
||||
assert!(failure.is_offline());
|
||||
}
|
||||
|
||||
/// 全装成的那句话要点出「含 N 个前置」与「跳过 M 个已存在」。
|
||||
#[test]
|
||||
fn the_success_message_counts_dependencies_and_skipped_files() {
|
||||
let plan = plan(
|
||||
Vec::new(),
|
||||
vec![
|
||||
node("AANobbMI", "sodium.jar", 0, &[]),
|
||||
node("P7dR8mSH", "fabric-api.jar", 1, &["AANobbMI"]),
|
||||
],
|
||||
vec![SkippedNode {
|
||||
project: "1eAoo2KR".to_string(),
|
||||
requested_as: "1eAoo2KR".to_string(),
|
||||
file_name: "yacl.jar".to_string(),
|
||||
depth: 2,
|
||||
required_by: vec!["P7dR8mSH".to_string()],
|
||||
reason: SkipReason::AlreadyPresent,
|
||||
}],
|
||||
);
|
||||
assert!(failure_of_plan(&plan, 2).is_none());
|
||||
let installed = plan.nodes.clone();
|
||||
let message = summary(&plan, &installed);
|
||||
assert!(
|
||||
message.contains("装了 2 个文件(含 1 个前置)"),
|
||||
"{message}"
|
||||
);
|
||||
// 「为什么装」:前置那句要带出要求它的项目
|
||||
assert!(
|
||||
message.contains("fabric-api.jar(前置,被 AANobbMI 要求)"),
|
||||
"{message}"
|
||||
);
|
||||
assert!(message.contains("跳过 1 个(目录里已存在,没有重复下载):yacl.jar"));
|
||||
}
|
||||
|
||||
/// 扫目录只认文件、不认子目录(子目录不是 jar,混进来会让去重误判)。
|
||||
#[test]
|
||||
fn present_files_lists_only_files_in_the_target_directory() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-m186-present-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("nested")).expect("建临时目录");
|
||||
std::fs::write(dir.join("sodium.jar"), b"x").expect("写文件");
|
||||
std::fs::write(dir.join("yacl.jar.disabled"), b"x").expect("写文件");
|
||||
let mut names: Vec<String> = super::plan::present_files(&dir)
|
||||
.into_iter()
|
||||
.map(|file| file.name)
|
||||
.collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["sodium.jar", "yacl.jar.disabled"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ------------------------------------------------ M186 r1:完成态进度、下载失败上下文、无地址前置
|
||||
|
||||
/// 完成态不许把进度倒回 `1/N`(r1 P2-3:这里曾写死 `files_done = 1`)。
|
||||
#[test]
|
||||
fn finishing_aligns_the_terminal_progress_with_the_total() {
|
||||
let mut progress = InstallProgressView {
|
||||
files_total: 3,
|
||||
files_done: 3,
|
||||
stage: InstallStage::Downloading,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
finish(&mut progress);
|
||||
assert_eq!(progress.stage, InstallStage::Done);
|
||||
assert_eq!(progress.files_done, progress.files_total);
|
||||
assert_eq!(progress.ratio(), 1.0, "装完了条必须满");
|
||||
|
||||
// 全部跳过(total = 0)时也不许变成 1/0
|
||||
let mut skipped_all = InstallProgressView {
|
||||
files_total: 0,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
finish(&mut skipped_all);
|
||||
assert_eq!(skipped_all.files_done, 0);
|
||||
assert_eq!(skipped_all.stage, InstallStage::Done);
|
||||
}
|
||||
|
||||
/// 下载阶段的失败也要带上下文:已装几个 + 缺的是谁的前置 + 可只重试谁。
|
||||
#[test]
|
||||
fn a_download_failure_keeps_the_already_installed_context() {
|
||||
let plan = plan(
|
||||
vec![failure(
|
||||
"P7dR8mSH",
|
||||
FailureKind::Unreachable,
|
||||
Some(OnlineErrorKind::NotConnected),
|
||||
"下载 fabric-api.jar 失败(online_not_connected):连不上 cdn.modrinth.com",
|
||||
)],
|
||||
vec![node("AANobbMI", "sodium.jar", 0, &[])],
|
||||
Vec::new(),
|
||||
);
|
||||
let failure = failure_of_plan(&plan, 1).expect("有失败就该给一条");
|
||||
assert_eq!(failure.kind, ContentFailureKind::Offline);
|
||||
assert!(
|
||||
failure.detail.contains("已装入 1 个文件(已下好的不回滚)"),
|
||||
"{}",
|
||||
failure.detail
|
||||
);
|
||||
assert!(failure.detail.contains("取不到(网络 / 项目不存在)1 个"));
|
||||
assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH"));
|
||||
}
|
||||
|
||||
/// 只有版本 id、没有项目 id 的必备前置:既装不了,也不许静默吞掉。
|
||||
#[test]
|
||||
fn a_required_dependency_without_a_project_id_is_reported() {
|
||||
let mut plan = plan(
|
||||
Vec::new(),
|
||||
vec![node("AANobbMI", "sodium.jar", 0, &[])],
|
||||
Vec::new(),
|
||||
);
|
||||
plan.unresolved_required = vec![UnresolvedRequired {
|
||||
parent: "AANobbMI".to_string(),
|
||||
version_id: Some("ver-embedded".to_string()),
|
||||
file_name: Some("embedded-lib.jar".to_string()),
|
||||
}];
|
||||
let installed = plan.nodes.clone();
|
||||
let message = summary(&plan, &installed);
|
||||
assert!(
|
||||
message.contains("有 1 条必备前置只给了版本/文件名、没有项目 id,无法自动解析:ver-embedded(被 AANobbMI 要求)"),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
|
||||
// M191:「只重试这些」的名单与重试路径(按主题分卷,父文件已贴 400 行硬线)
|
||||
mod retry;
|
||||
@@ -0,0 +1,332 @@
|
||||
//! M191:「只重试这些」的两档名单 + 真重试路径(`super` 的用例按主题分卷,这一卷专管重试)。
|
||||
//!
|
||||
//! 三条判据对应的变异守门在各自的文档里写清了「改了什么这条会红」。
|
||||
|
||||
use super::{failure, node, plan};
|
||||
use crate::dto::{InstallFailureKind, InstallFailureView};
|
||||
use crate::host::addon::plan::{
|
||||
failure_of_plan, forget, names_for_incompatible, remember, remembered, retry_items, retry_plan,
|
||||
retry_request, Root,
|
||||
};
|
||||
use unlucky_core::addon::online::{DependencyPlan, FailureKind, OnlineErrorKind, PresentFile};
|
||||
|
||||
/// 一份「根装好了、两个前置失败」的计划(重试路径的典型输入)。
|
||||
fn plan_with_two_tier_failures() -> DependencyPlan {
|
||||
plan(
|
||||
vec![
|
||||
failure(
|
||||
"P7dR8mSH",
|
||||
FailureKind::Incompatible,
|
||||
None,
|
||||
"没有匹配的版本",
|
||||
),
|
||||
failure(
|
||||
"1eAoo2KR",
|
||||
FailureKind::Unreachable,
|
||||
Some(OnlineErrorKind::NotFound),
|
||||
"取版本失败(online_not_found):HTTP 404",
|
||||
),
|
||||
],
|
||||
vec![node("AANobbMI", "sodium.jar", 0, &[])],
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 可重试名单:**两档分在类型上**,每条的 project 就是重试会发出去的那个键。
|
||||
///
|
||||
/// 抓得住:把 `Incompatible` 并进 `Unreachable`(两档合成一档)⇒ 这条立刻红。
|
||||
#[test]
|
||||
fn the_retry_list_keeps_the_two_tiers_apart() {
|
||||
let items = retry_items(
|
||||
&plan_with_two_tier_failures(),
|
||||
Root {
|
||||
id: "AANobbMI",
|
||||
title: "Sodium",
|
||||
},
|
||||
&Vec::new(),
|
||||
);
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(
|
||||
items
|
||||
.iter()
|
||||
.map(|item| (item.project.as_str(), item.kind))
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
("P7dR8mSH", InstallFailureKind::Incompatible),
|
||||
("1eAoo2KR", InstallFailureKind::Unreachable),
|
||||
]
|
||||
);
|
||||
// 两档的名字**必须不同**(界面拿它分组、也是日志/用例里的稳定 id)
|
||||
assert_ne!(
|
||||
InstallFailureKind::Incompatible.as_str(),
|
||||
InstallFailureKind::Unreachable.as_str()
|
||||
);
|
||||
}
|
||||
|
||||
/// 全成功 ⇒ 名单是空的(界面据此**不渲染**那条入口,而不是画一个灰按钮)。
|
||||
#[test]
|
||||
fn a_clean_plan_offers_nothing_to_retry() {
|
||||
let clean = plan(
|
||||
Vec::new(),
|
||||
vec![node("AANobbMI", "sodium.jar", 0, &[])],
|
||||
Vec::new(),
|
||||
);
|
||||
assert!(retry_items(
|
||||
&clean,
|
||||
Root {
|
||||
id: "AANobbMI",
|
||||
title: "Sodium",
|
||||
},
|
||||
&Vec::new(),
|
||||
)
|
||||
.is_empty());
|
||||
assert!(failure_of_plan(&clean, 1).is_none());
|
||||
}
|
||||
|
||||
/// 「只重试这些」**只请求失败的那几个项目**:根项目不再发一次,解析只跑一次。
|
||||
///
|
||||
/// 这条是 M191 的变异守门①:把 `retry_request()` 换成「重新装根」(`ResolveRequest::single(root)`
|
||||
/// 或 `plan.request.clone()`)⇒ `roots` 里会出现 `AANobbMI`,这条红。
|
||||
#[test]
|
||||
fn retrying_asks_for_the_failed_projects_only() {
|
||||
let plan = plan_with_two_tier_failures();
|
||||
let present = vec![PresentFile::new("sodium.jar")];
|
||||
let mut seen: Vec<unlucky_core::addon::online::ResolveRequest> = Vec::new();
|
||||
let _ = retry_plan(&plan, present, |request| {
|
||||
seen.push(request.clone());
|
||||
// 假解析器:把请求的根原样回成节点(用例只关心「请求了谁、请求了几次」)
|
||||
DependencyPlan {
|
||||
request: request.clone(),
|
||||
nodes: request
|
||||
.roots
|
||||
.iter()
|
||||
.map(|root| node(root, "x.jar", 0, &[]))
|
||||
.collect(),
|
||||
skipped: Vec::new(),
|
||||
failures: Vec::new(),
|
||||
truncated: Vec::new(),
|
||||
unresolved_required: Vec::new(),
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(seen.len(), 1, "重试只发一次解析,请求数不许放大");
|
||||
let roots = &seen[0].roots;
|
||||
assert_eq!(roots, &["P7dR8mSH".to_string(), "1eAoo2KR".to_string()]);
|
||||
assert!(
|
||||
!roots.contains(&"AANobbMI".to_string()),
|
||||
"根项目已装好,重试不许再解它:{roots:?}"
|
||||
);
|
||||
// `present` 重新扫过(这一轮新落盘的文件要在里面,否则会被当成「还没有」再下一份)
|
||||
assert_eq!(seen[0].present, vec![PresentFile::new("sodium.jar")]);
|
||||
// 过滤条件沿用上次那份
|
||||
assert_eq!(seen[0].game_version, plan.request.game_version);
|
||||
}
|
||||
|
||||
/// 计划被记住 / 忘掉:键是「host + 实例」,成功之后不该留可重试的记录。
|
||||
#[test]
|
||||
fn the_last_failed_plan_is_remembered_per_instance() {
|
||||
let root = std::env::temp_dir().join(format!("ull-m191-retry-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let env = crate::host::HostEnv::at_root(&root);
|
||||
assert!(remembered(&env, "survival").is_none());
|
||||
|
||||
let plan = plan_with_two_tier_failures();
|
||||
let dir = root.join(".minecraft/mods");
|
||||
remember(&env, "survival", &dir, "AANobbMI", "Sodium", &plan);
|
||||
let saved = remembered(&env, "survival").expect("记住了这一次");
|
||||
assert_eq!(saved.dir, dir);
|
||||
// 原始根 id + 显示名跟着计划一起记住:重试时面板头用它,判「是不是根」也用它(r2 P2-2)
|
||||
assert_eq!(saved.root, "AANobbMI");
|
||||
assert_eq!(saved.title, "Sodium");
|
||||
assert_eq!(saved.plan.failures.len(), 2);
|
||||
// 别的实例不受影响(同一台机器上装过两个实例)
|
||||
assert!(remembered(&env, "modded").is_none());
|
||||
|
||||
// 重试请求从「记住的计划」里取:仍然只含失败项
|
||||
let request = retry_request(&saved.plan, Vec::new());
|
||||
assert_eq!(request.roots, vec!["P7dR8mSH", "1eAoo2KR"]);
|
||||
|
||||
forget(&env, "survival");
|
||||
assert!(remembered(&env, "survival").is_none());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// DTO 名单与「真重试请求」是**同一份**:界面画的 N 个 = 重试会请求的 N 个。
|
||||
///
|
||||
/// 抓得住:界面名单自己另拼一份(多列/少列一个项目)⇒ 这条红。
|
||||
#[test]
|
||||
fn the_dto_list_is_exactly_what_the_retry_request_asks_for() {
|
||||
let plan = plan_with_two_tier_failures();
|
||||
let items: Vec<InstallFailureView> = retry_items(
|
||||
&plan,
|
||||
Root {
|
||||
id: "AANobbMI",
|
||||
title: "Sodium",
|
||||
},
|
||||
&Vec::new(),
|
||||
);
|
||||
let request = retry_request(&plan, Vec::new());
|
||||
assert_eq!(
|
||||
items
|
||||
.iter()
|
||||
.map(|item| item.project.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
request.roots
|
||||
);
|
||||
}
|
||||
|
||||
/// **P2-1 的守门**:面板上写的是可读名,不是裸项目 id。
|
||||
///
|
||||
/// 三个来源各一条(`plan::label_of` 的三种情况),而且都必须**语言中立**(站点标题 / 文件名,
|
||||
/// 不是 core 的中文散文):
|
||||
///
|
||||
/// | 项 | 情况 | 期望那一行 |
|
||||
/// |---|---|---|
|
||||
/// | 根项目 | 它自己失败 | `Sodium (AANobbMI)` —— 名字来自请求的 `title` |
|
||||
/// | 前置 | 下载失败(计划里有节点) | `fabric-api.jar (P7dR8mSH)` —— 名字来自文件名 |
|
||||
/// | 前置 | 解析阶段 404(没有节点) | `1eAoo2KR` —— 拿不到名,只画 id(合理兜底) |
|
||||
///
|
||||
/// 变异守门:把 `label_of` 的标签置空(或 `retry_items` 传空串)⇒ 这条红。
|
||||
#[test]
|
||||
fn the_retry_list_shows_a_readable_name_not_just_the_project_id() {
|
||||
let mut plan = plan(
|
||||
vec![
|
||||
// 根项目自己失败(下载失败):requested_as 与 roots[0] 相同
|
||||
failure(
|
||||
"AANobbMI",
|
||||
FailureKind::Unreachable,
|
||||
Some(OnlineErrorKind::NotConnected),
|
||||
"下载 sodium.jar 失败(online_not_connected):连不上 cdn.modrinth.com",
|
||||
),
|
||||
// 前置下载失败:计划里有它的节点 ⇒ 用文件名
|
||||
failure(
|
||||
"P7dR8mSH",
|
||||
FailureKind::Unreachable,
|
||||
Some(OnlineErrorKind::NotConnected),
|
||||
"下载 fabric-api.jar 失败(online_not_connected):连不上 cdn.modrinth.com",
|
||||
),
|
||||
// 前置解析 404:没有节点、也不是根 ⇒ 没有名字可显示
|
||||
failure(
|
||||
"1eAoo2KR",
|
||||
FailureKind::Unreachable,
|
||||
Some(OnlineErrorKind::NotFound),
|
||||
"取版本失败(online_not_found):HTTP 404",
|
||||
),
|
||||
],
|
||||
vec![
|
||||
node("AANobbMI", "sodium-fabric-0.6.0.jar", 0, &[]),
|
||||
node("P7dR8mSH", "fabric-api.jar", 1, &["AANobbMI"]),
|
||||
],
|
||||
Vec::new(),
|
||||
);
|
||||
// 根必须是请求里的那个键(`plan::request` 只放一个根)
|
||||
plan.request = unlucky_core::addon::online::ResolveRequest::single("AANobbMI");
|
||||
|
||||
let items = retry_items(
|
||||
&plan,
|
||||
Root {
|
||||
id: "AANobbMI",
|
||||
title: "Sodium",
|
||||
},
|
||||
&Vec::new(),
|
||||
);
|
||||
let lines: Vec<String> = items.iter().map(InstallFailureView::line).collect();
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
"Sodium (AANobbMI)".to_string(),
|
||||
"fabric-api.jar (P7dR8mSH)".to_string(),
|
||||
// 拿不到名的那一档:只画 id,不编造、也不拿 core 的中文 message 顶上
|
||||
"1eAoo2KR".to_string(),
|
||||
]
|
||||
);
|
||||
// 标签是语言中立的:站点标题/文件名,不是 core 的中文散文
|
||||
assert!(!items[0].label.contains("下载"), "{}", items[0].label);
|
||||
assert!(!items[1].label.contains("失败"), "{}", items[1].label);
|
||||
}
|
||||
|
||||
/// **r2 P2-2 的守门**:重试那一轮,第一条失败项**不许**被贴成原根项目的名字。
|
||||
///
|
||||
/// 形状就是复审给的那个:重试轮的 plan 来自 `retry_request()`,它的 `roots` 是上一轮的失败名单
|
||||
/// (第一个是**前置**),而 `root` 记的是原始根 `AANobbMI / Sodium`。老实现拿 `roots.first()`
|
||||
/// 判「是不是根」⇒ 画出 `Sodium (P7dR8mSH)`;现在按 id 比 ⇒ 第一条是它自己的名字(或 id)。
|
||||
///
|
||||
/// 变异守门:把 `label_of` 的判据换回 `plan.request.roots.first()` ⇒ 这条红。
|
||||
#[test]
|
||||
fn the_retry_round_does_not_paste_the_root_title_onto_a_dependency() {
|
||||
let mut plan = plan_with_two_tier_failures();
|
||||
// 重试请求:roots = 上一轮的失败名单
|
||||
plan.request = unlucky_core::addon::online::ResolveRequest {
|
||||
roots: vec!["P7dR8mSH".to_string(), "1eAoo2KR".to_string()],
|
||||
..plan.request.clone()
|
||||
};
|
||||
// 重试轮里没有被安装的节点(上一轮装好的不会再进计划),于是标签只能来自 id
|
||||
let items = retry_items(
|
||||
&plan,
|
||||
Root {
|
||||
id: "AANobbMI",
|
||||
title: "Sodium",
|
||||
},
|
||||
&Vec::new(),
|
||||
);
|
||||
let lines: Vec<String> = items.iter().map(InstallFailureView::line).collect();
|
||||
assert_eq!(lines, vec!["P7dR8mSH".to_string(), "1eAoo2KR".to_string()]);
|
||||
assert!(
|
||||
!lines.iter().any(|line| line.contains("Sodium")),
|
||||
"重试轮把根的标题贴到前置上了:{lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **r2 P2-1 续的守门**:不兼容档要显示项目名,不是 id。
|
||||
///
|
||||
/// 那一档的项目**确实存在**(站点只是没有匹配目标版本/加载器的条目),所以失败路径上值得
|
||||
/// 现取一次名字。变异守门:把这次获取去掉(`names` 传空)⇒ 第一条退回 `P7dR8mSH`,这条红。
|
||||
#[test]
|
||||
fn an_incompatible_item_shows_the_project_name_once_it_is_known() {
|
||||
let plan = plan_with_two_tier_failures();
|
||||
let root = Root {
|
||||
id: "AANobbMI",
|
||||
title: "Sodium",
|
||||
};
|
||||
// 生产路径:`names_for_incompatible` 用注入的查询取名字(这里模拟"站点有回应")
|
||||
let names = names_for_incompatible(&plan, |id| {
|
||||
(id == "P7dR8mSH").then(|| "Entity Culling".to_string())
|
||||
});
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![("P7dR8mSH".to_string(), "Entity Culling".to_string())],
|
||||
"只给不兼容档取名字(404 那条不发请求)"
|
||||
);
|
||||
|
||||
let items = retry_items(&plan, root, &names);
|
||||
assert_eq!(items[0].line(), "Entity Culling (P7dR8mSH)");
|
||||
// 「取不到」那档不受影响:没有节点、没有名字 ⇒ 只画 id
|
||||
assert_eq!(items[1].line(), "1eAoo2KR");
|
||||
}
|
||||
|
||||
/// **降级**:取名字本身失败,不许把安装结果搞坏,也不许编名字——照旧画 id。
|
||||
///
|
||||
/// 变异守门:让 `project_title` 的错误往外冒(`?` / `expect`)⇒ 这条连编译都过不了,
|
||||
/// 或者(退回成「拿不到就 panic」)在这里红。
|
||||
#[test]
|
||||
fn a_failed_name_lookup_falls_back_to_the_id_without_breaking_anything() {
|
||||
let plan = plan_with_two_tier_failures();
|
||||
// 站点 404 / 没网 / 坏响应:`lookup` 全是 `None`
|
||||
let names = names_for_incompatible(&plan, |_| None);
|
||||
assert!(names.is_empty(), "{names:?}");
|
||||
let items = retry_items(
|
||||
&plan,
|
||||
Root {
|
||||
id: "AANobbMI",
|
||||
title: "Sodium",
|
||||
},
|
||||
&names,
|
||||
);
|
||||
assert_eq!(items[0].line(), "P7dR8mSH", "取不到名字就画 id,不编造");
|
||||
// 拿到空串(站点给了个空标题)同样不算名字
|
||||
let blank = names_for_incompatible(&plan, |_| Some(" ".to_string()));
|
||||
assert!(blank.is_empty(), "{blank:?}");
|
||||
}
|
||||
|
||||
// r3 复审 p2:生产接线与降级守卫(按主题分卷,父文件已贴 400 行硬线)
|
||||
mod wire;
|
||||
@@ -0,0 +1,115 @@
|
||||
//! r3 复审 p2:**生产接线**与**降级**的守卫(`super` 按主题分卷)。
|
||||
//!
|
||||
//! 这一卷的用例驱动的是生产那个 `collect`(不是它的复制品):假下载器 + 假取名字器,
|
||||
//! 于是「失败路径真的去取了不兼容档的名字」这件事在没有网络的用例里也能钉住。
|
||||
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use unlucky_core::addon::online::{
|
||||
ArtifactFetcher, FetchArgs, OnlineError, OnlineErrorKind, OnlineFuture,
|
||||
};
|
||||
|
||||
use super::plan_with_two_tier_failures;
|
||||
use crate::dto::{
|
||||
ContentInstallRequest, ContentSource, InstallFailureKind, InstallProgressView, LoaderKind,
|
||||
};
|
||||
use crate::host::addon::install::{collect, Placement, Ports};
|
||||
use crate::host::addon::plan::Root;
|
||||
|
||||
/// 假下载器:一律「写一行字节成功」——本组用例只关心收口那一半(下载阶段另有自己的用例)。
|
||||
struct QuietFetcher;
|
||||
|
||||
impl ArtifactFetcher for QuietFetcher {
|
||||
fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result<u64, OnlineError>> {
|
||||
Box::pin(async move {
|
||||
std::fs::write(args.dest, b"jar").map_err(|error| {
|
||||
OnlineError::new(OnlineErrorKind::Io, format!("写盘失败:{error}"))
|
||||
})?;
|
||||
Ok(4)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn request_for(instance: &str) -> ContentInstallRequest {
|
||||
ContentInstallRequest {
|
||||
project: "AANobbMI".to_string(),
|
||||
title: "Sodium".to_string(),
|
||||
kind: crate::dto::ContentKind::Mod,
|
||||
source: ContentSource::Modrinth,
|
||||
instance: instance.to_string(),
|
||||
game_version: "1.20.1".to_string(),
|
||||
loader: LoaderKind::Fabric,
|
||||
retry: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// **接线层守卫**(r3 复审 p2 第 1 条):安装失败路径**真的**去取了不兼容档的名字。
|
||||
///
|
||||
/// 这条驱动的是生产那个 `collect`(不是它的复制品):假下载器 + 假取名字器,
|
||||
/// 于是一份「一个文件下成功 + 一个不兼容前置 + 一条 404」的计划跑完后:
|
||||
///
|
||||
/// - 不兼容那条**拿到了名字**(`Entity Culling`);
|
||||
/// - 取名字器只被**不兼容**那条问过一次(404 那条不发请求)。
|
||||
///
|
||||
/// 变异守门:把 `collect` 里那行 `names_for_incompatible(...)` 删掉/短路(传空名单)⇒ 红。
|
||||
#[test]
|
||||
fn the_failed_install_path_really_fetches_names_for_the_incompatible_items() {
|
||||
let root = std::env::temp_dir().join(format!("ull-m191-wire-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let dir = root.join(".minecraft/mods");
|
||||
std::fs::create_dir_all(&dir).expect("建目录");
|
||||
let env = crate::host::HostEnv::at_root(&root);
|
||||
|
||||
let plan = plan_with_two_tier_failures();
|
||||
let asked: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
let title = |id: &str| {
|
||||
asked.lock().expect("锁没中毒").push(id.to_string());
|
||||
(id == "P7dR8mSH").then(|| "Entity Culling".to_string())
|
||||
};
|
||||
let fetcher = QuietFetcher;
|
||||
let ports = Ports {
|
||||
env: &env,
|
||||
fetcher: &fetcher,
|
||||
title: &title,
|
||||
};
|
||||
let (tx, _rx) = crate::host::job::channel();
|
||||
let cancel = AtomicBool::new(false);
|
||||
let mut progress = InstallProgressView::new();
|
||||
let request = request_for("survival");
|
||||
|
||||
let result = collect(
|
||||
&ports,
|
||||
&request,
|
||||
plan,
|
||||
Placement {
|
||||
dir: &dir,
|
||||
root: Root {
|
||||
id: "AANobbMI",
|
||||
title: "Sodium",
|
||||
},
|
||||
},
|
||||
&cancel,
|
||||
&mut progress,
|
||||
&tx,
|
||||
1,
|
||||
);
|
||||
|
||||
assert!(result.is_err(), "有失败项就该回一条失败");
|
||||
let incompatible = progress
|
||||
.retry
|
||||
.iter()
|
||||
.find(|item| item.kind == InstallFailureKind::Incompatible)
|
||||
.expect("不兼容档要在名单里");
|
||||
assert_eq!(
|
||||
incompatible.line(),
|
||||
"Entity Culling (P7dR8mSH)",
|
||||
"失败路径没有把取到的名字贴上去"
|
||||
);
|
||||
assert_eq!(
|
||||
*asked.lock().expect("锁没中毒"),
|
||||
vec!["P7dR8mSH".to_string()],
|
||||
"只该给不兼容档取名字(404 那条不发请求)"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! 「导入已有版本」的宿主侧(M223 GUIADOPT):扫描 / 预检 / 确认三条后台线程。
|
||||
//!
|
||||
//! 能力全在 core(`unlucky_core::game::adopt`,M221):本模块只做三件事——**起线程、翻 DTO、
|
||||
//! 按代次回流**。这里不重新判定任何东西:能不能登记、落到哪个运行目录、有哪些问题,全部由 core
|
||||
//! 说了算,本模块逐字段照搬([`scan_view`] / [`plan_view`] / [`done_view`] / [`failure_view`])。
|
||||
//!
|
||||
//! ## 三个命令与写盘面
|
||||
//!
|
||||
//! | 命令 | core 调用 | 写盘 |
|
||||
//! |---|---|---|
|
||||
//! | [`AdoptCommand::Scan`] | `adopt::scan` | 零 |
|
||||
//! | [`AdoptCommand::Preview`] | `adopt::plan` | 零(预检就是 core 的 dry-run) |
|
||||
//! | [`AdoptCommand::Confirm`] | `adopt::adopt` | 实例设置 + `profiles.json`(+ 缺省运行目录) |
|
||||
//!
|
||||
//! 预检与确认走**同一个** `AdoptRequestView`:页面在预检拿到落点与后果原文,用户点确认时把
|
||||
//! **同一份请求**发回来(不是「预检 A、写 B」)。这也是「运行目录只认回程」的实现方式——
|
||||
//! 页面从头到尾没有机会自己派生一个目录名。
|
||||
//!
|
||||
//! ## 代次:为什么要它
|
||||
//!
|
||||
//! 三条命令各起一条线程,用户可能连点(扫完再扫、预检后改目标再预检、失败后关面板)。
|
||||
//! 每条线程带一个代次回来,`adopt_epoch` 只认**最新**那一条:关面板([`AdoptCommand::Dismiss`])
|
||||
//! 也会把代次 +1,于是「点了关闭,两秒后扫描结果又把面板点亮」这种旧病不会发生
|
||||
//! (它与 M105 安装取消、M113 导入取消是同一套做法)。
|
||||
//!
|
||||
//! ## 不做什么
|
||||
//!
|
||||
//! - **不自己派生运行目录**:`run_dir` 只从 `plan`/`adopt` 的返回里取(缺省档可能带 `-2` 后缀);
|
||||
//! - **不自己拼后果话术**:`notes` 原样来自 core 的 `target_notes()`,与 CLI 打的是同一句;
|
||||
//! - **不折叠问题**:`problems[]` 逐条搬进 [`AdoptFailureView`],一条都不省。
|
||||
|
||||
use unlucky_core::game::adopt::{
|
||||
self, AdoptCandidate, AdoptError, AdoptOutcome, AdoptPlan, AdoptProblem, AdoptRequest,
|
||||
AdoptTarget,
|
||||
};
|
||||
use unlucky_core::game::install::instance::Isolation;
|
||||
use unlucky_core::launch::time::{format_rfc3339_millis, now};
|
||||
|
||||
use crate::dto::{
|
||||
AdoptCandidateView, AdoptCommand, AdoptDoneView, AdoptEvent, AdoptFailureView,
|
||||
AdoptIsolationView, AdoptPlanView, AdoptProblemView, AdoptRequestView, AdoptScanView,
|
||||
AdoptTargetView, CoreEvent,
|
||||
};
|
||||
|
||||
use super::env::HostEnv;
|
||||
use super::job::{self, HostTask, JobSender};
|
||||
use super::CoreHost;
|
||||
|
||||
/// 后台那一步是「只看不写」还是「真的写」。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Mode {
|
||||
/// `adopt::plan`(预检,零写盘)
|
||||
Preview,
|
||||
/// `adopt::adopt`(真写)
|
||||
Confirm,
|
||||
}
|
||||
|
||||
impl CoreHost {
|
||||
/// 作废在飞的 adopt 结果,返回新代次(每条命令都调它,见模块文档「代次」一节)。
|
||||
fn next_adopt_epoch(&mut self) -> u64 {
|
||||
self.adopt_epoch = self.adopt_epoch.wrapping_add(1);
|
||||
self.adopt_epoch
|
||||
}
|
||||
|
||||
/// `AppCommand::Adopt` 的落点(M223)。
|
||||
///
|
||||
/// 每条命令都先把代次 +1:这一条之后回来的旧结果一律被 [`Self::handle_adopt`] 丢掉。
|
||||
pub(super) fn adopt(&mut self, command: AdoptCommand) {
|
||||
match command {
|
||||
AdoptCommand::Scan => {
|
||||
let epoch = self.next_adopt_epoch();
|
||||
spawn_scan(self.env.clone(), self.tx.clone(), epoch);
|
||||
}
|
||||
AdoptCommand::Preview(request) => {
|
||||
let epoch = self.next_adopt_epoch();
|
||||
spawn_adopt(
|
||||
self.env.clone(),
|
||||
self.tx.clone(),
|
||||
*request,
|
||||
Mode::Preview,
|
||||
epoch,
|
||||
);
|
||||
}
|
||||
AdoptCommand::Confirm(request) => {
|
||||
let epoch = self.next_adopt_epoch();
|
||||
spawn_adopt(
|
||||
self.env.clone(),
|
||||
self.tx.clone(),
|
||||
*request,
|
||||
Mode::Confirm,
|
||||
epoch,
|
||||
);
|
||||
}
|
||||
AdoptCommand::Dismiss => {
|
||||
// 关面板 = 作废在飞的一切(否则迟到的扫描结果会把面板重新点亮)
|
||||
self.next_adopt_epoch();
|
||||
self.send(CoreEvent::Adopt(Box::new(AdoptEvent::Cleared)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台线程的回流落点:代次不符就丢(见模块文档「代次」一节)。
|
||||
///
|
||||
/// 登记成功之后补一次实例快照刷新:新实例必须**这一拍就出现在实例列表里**,
|
||||
/// 而不是等用户手点刷新(否则「导入了但看不见」)。
|
||||
pub(super) fn handle_adopt(&mut self, event: AdoptEvent, epoch: u64) {
|
||||
if epoch != self.adopt_epoch {
|
||||
return;
|
||||
}
|
||||
let registered = matches!(event, AdoptEvent::Done(_));
|
||||
self.send(CoreEvent::Adopt(Box::new(event)));
|
||||
if registered {
|
||||
self.refresh_instances();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 扫描当前游戏根的 `versions/`(只读)。
|
||||
pub(super) fn spawn_scan(env: HostEnv, tx: JobSender, epoch: u64) {
|
||||
job::spawn("adopt-scan", move || {
|
||||
let event = match adopt::scan(&env.game, &env.layout) {
|
||||
Ok(scan) => AdoptEvent::Scan(Box::new(AdoptScanView {
|
||||
versions_dir: scan.versions_dir.clone(),
|
||||
candidates: scan.candidates.iter().map(candidate_view).collect(),
|
||||
})),
|
||||
// 读目录失败也是**具名**失败(断链的 versions 联接 / 权限),不降级成空列表
|
||||
Err(error) => AdoptEvent::Failed(Box::new(failure_view(error))),
|
||||
};
|
||||
let _ = tx.send(HostTask::Adopt {
|
||||
event: Box::new(event),
|
||||
epoch,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 预检(零写盘)或确认登记。
|
||||
pub(super) fn spawn_adopt(
|
||||
env: HostEnv,
|
||||
tx: JobSender,
|
||||
request: AdoptRequestView,
|
||||
mode: Mode,
|
||||
epoch: u64,
|
||||
) {
|
||||
job::spawn("adopt", move || {
|
||||
let request = core_request(request);
|
||||
let event = match mode {
|
||||
Mode::Preview => match adopt::plan(&env.game, &env.layout, &request) {
|
||||
Ok(plan) => AdoptEvent::Planned(Box::new(plan_view(plan))),
|
||||
Err(error) => AdoptEvent::Failed(Box::new(failure_view(error))),
|
||||
},
|
||||
Mode::Confirm => match adopt::adopt(&env.game, &env.layout, &request) {
|
||||
Ok(outcome) => AdoptEvent::Done(Box::new(done_view(outcome))),
|
||||
Err(error) => AdoptEvent::Failed(Box::new(failure_view(error))),
|
||||
},
|
||||
};
|
||||
let _ = tx.send(HostTask::Adopt {
|
||||
event: Box::new(event),
|
||||
epoch,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 视图请求 → core 请求(`created_at` 由宿主给:core 不碰时钟)。
|
||||
fn core_request(view: AdoptRequestView) -> AdoptRequest {
|
||||
AdoptRequest {
|
||||
version_id: view.version_id,
|
||||
name: view.name.filter(|name| !name.trim().is_empty()),
|
||||
target: target_of(view.target),
|
||||
created_at: Some(format_rfc3339_millis(now())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 三档视图 → core 的运行目录选择。
|
||||
///
|
||||
/// `custom` **不映射**:GUI 不开「用户手输绝对路径」这一档(那要目录选择器),
|
||||
/// 需要它的调用方走 CLI 的 `ull instances adopt --isolation custom --game-dir DIR`。
|
||||
fn target_of(view: AdoptTargetView) -> AdoptTarget {
|
||||
match view {
|
||||
AdoptTargetView::InstanceDir => AdoptTarget::InstanceDir,
|
||||
AdoptTargetView::Shared => AdoptTarget::Shared,
|
||||
AdoptTargetView::VersionDir => AdoptTarget::VersionDir,
|
||||
}
|
||||
}
|
||||
|
||||
/// core 隔离档 → 视图。
|
||||
fn isolation_view(isolation: Isolation) -> AdoptIsolationView {
|
||||
match isolation {
|
||||
Isolation::Isolated => AdoptIsolationView::Isolated,
|
||||
Isolation::Shared => AdoptIsolationView::Shared,
|
||||
Isolation::Custom => AdoptIsolationView::Custom,
|
||||
}
|
||||
}
|
||||
|
||||
/// core 候选 → 视图(`registered` 只带实例名:界面要的是「给谁登记过了」)。
|
||||
fn candidate_view(candidate: &AdoptCandidate) -> AdoptCandidateView {
|
||||
AdoptCandidateView {
|
||||
version_id: candidate.version_id.clone(),
|
||||
jar_present: candidate.jar_present,
|
||||
inherits: candidate.inherits.clone(),
|
||||
registered: candidate
|
||||
.registered
|
||||
.iter()
|
||||
.map(|instance| instance.name.clone())
|
||||
.collect(),
|
||||
problems: candidate.problems.iter().map(problem_view).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// core 问题 → 视图(`code` 稳定 + `Display` 原文:路径、行列号、缺哪个父版本都在里面)。
|
||||
fn problem_view(problem: &AdoptProblem) -> AdoptProblemView {
|
||||
AdoptProblemView {
|
||||
code: problem.code().to_string(),
|
||||
message: problem.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// core 计划 → 视图。`run_dir` / `notes` **原样**搬运,界面不许再算一遍。
|
||||
fn plan_view(plan: AdoptPlan) -> AdoptPlanView {
|
||||
AdoptPlanView {
|
||||
version_id: plan.candidate.version_id.clone(),
|
||||
name: plan.name,
|
||||
target: target_view(&plan.target),
|
||||
isolation: isolation_view(plan.isolation),
|
||||
run_dir: plan.run_dir,
|
||||
notes: plan.notes,
|
||||
}
|
||||
}
|
||||
|
||||
/// core 结果 → 视图。
|
||||
fn done_view(outcome: AdoptOutcome) -> AdoptDoneView {
|
||||
AdoptDoneView {
|
||||
instance_id: outcome.instance_id,
|
||||
name: outcome.name,
|
||||
version_id: outcome.version_id,
|
||||
isolation: isolation_view(outcome.isolation),
|
||||
run_dir: outcome.run_dir,
|
||||
dir_created: outcome.dir_created,
|
||||
notes: outcome.notes,
|
||||
}
|
||||
}
|
||||
|
||||
/// core 运行目录选择 → 视图(`Custom` 只在 core 侧出现,界面按「缺省档」显示)。
|
||||
fn target_view(target: &AdoptTarget) -> AdoptTargetView {
|
||||
match target {
|
||||
AdoptTarget::InstanceDir | AdoptTarget::Custom(_) => AdoptTargetView::InstanceDir,
|
||||
AdoptTarget::Shared => AdoptTargetView::Shared,
|
||||
AdoptTarget::VersionDir => AdoptTargetView::VersionDir,
|
||||
}
|
||||
}
|
||||
|
||||
/// core 失败 → 视图:**`problems[]` 一条不少地带出来**(契约 §5.3)。
|
||||
fn failure_view(error: AdoptError) -> AdoptFailureView {
|
||||
AdoptFailureView {
|
||||
code: error.code().to_string(),
|
||||
message: error.to_string(),
|
||||
problems: error.problems().iter().map(problem_view).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,217 @@
|
||||
//! `host::adopt` 的用例(M223):真盘夹具 + 三条命令的回程形状。
|
||||
//!
|
||||
//! 抓得住:扫描只读(`.ULL/config` 不出现)、预检与确认的落点一致且都来自 core、
|
||||
//! 坏清单失败时 `problems[]` 逐条带出(带行列号)、三档映射。
|
||||
//! 抓不住:真启动游戏用不用这个运行目录(那是 launch 链路的活);D30 视觉(另走截图通道)。
|
||||
//!
|
||||
//! 为什么单独一个文件:`host/adopt.rs` 带用例会到 440 行,越过 400 行硬线(AGENTS §3.1)。
|
||||
|
||||
use super::*;
|
||||
use crate::dto::AdoptStage;
|
||||
|
||||
/// 真盘夹具:`<root>/.minecraft/versions/<id>/`(清单 + jar)+ `<root>/.ULL`。
|
||||
struct Fixture {
|
||||
root: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new(tag: &str) -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"ull-presenter-adopt-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos())
|
||||
));
|
||||
std::fs::create_dir_all(&root).expect("建临时根");
|
||||
Self { root }
|
||||
}
|
||||
|
||||
fn env(&self) -> HostEnv {
|
||||
HostEnv::at_root(&self.root)
|
||||
}
|
||||
|
||||
/// 铺一个「别的启动器装好的」版本(`manifest` 原样写,便于造坏清单)。
|
||||
fn version(&self, id: &str, manifest: &str) {
|
||||
let dir = self.root.join(".minecraft/versions").join(id);
|
||||
std::fs::create_dir_all(&dir).expect("建版本目录");
|
||||
std::fs::write(dir.join(format!("{id}.json")), manifest).expect("写清单");
|
||||
std::fs::write(dir.join(format!("{id}.jar")), b"jar-bytes").expect("写 jar");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
|
||||
const MANIFEST: &str = r#"{
|
||||
"id": "1.20.1",
|
||||
"type": "release",
|
||||
"mainClass": "net.minecraft.main.Main",
|
||||
"libraries": []
|
||||
}"#;
|
||||
|
||||
fn request(version_id: &str, target: AdoptTargetView) -> AdoptRequestView {
|
||||
AdoptRequestView {
|
||||
version_id: version_id.to_string(),
|
||||
name: None,
|
||||
target,
|
||||
}
|
||||
}
|
||||
|
||||
/// 收一条 `HostTask::Adopt`(同步等后台线程交卷)。
|
||||
fn recv(rx: &super::super::job::JobReceiver) -> AdoptEvent {
|
||||
match rx.recv().expect("后台线程应回一条") {
|
||||
HostTask::Adopt { event, .. } => *event,
|
||||
other => panic!("期望 HostTask::Adopt,收到 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_lists_the_foreign_versions_without_writing() {
|
||||
let fixture = Fixture::new("scan");
|
||||
fixture.version("1.20.1", MANIFEST);
|
||||
let env = fixture.env();
|
||||
let (tx, rx) = job::channel();
|
||||
spawn_scan(env.clone(), tx, 1);
|
||||
|
||||
let AdoptEvent::Scan(scan) = recv(&rx) else {
|
||||
panic!("扫描应回 AdoptEvent::Scan");
|
||||
};
|
||||
assert_eq!(scan.versions_dir, fixture.root.join(".minecraft/versions"));
|
||||
assert_eq!(scan.total(), 1);
|
||||
assert_eq!(scan.adoptable(), 1);
|
||||
assert!(scan.candidates[0].jar_present);
|
||||
// 扫描零写盘:`.ULL/config` 下不该出现任何东西
|
||||
assert!(!fixture.root.join(".ULL/config/profiles.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_registers_and_reports_the_core_run_dir() {
|
||||
let fixture = Fixture::new("confirm");
|
||||
fixture.version("1.20.1", MANIFEST);
|
||||
let env = fixture.env();
|
||||
|
||||
// 先预检:零写盘,落点与后果原文都来自 core
|
||||
let (tx, rx) = job::channel();
|
||||
spawn_adopt(
|
||||
env.clone(),
|
||||
tx,
|
||||
request("1.20.1", AdoptTargetView::InstanceDir),
|
||||
Mode::Preview,
|
||||
1,
|
||||
);
|
||||
let AdoptEvent::Planned(plan) = recv(&rx) else {
|
||||
panic!("预检应回 AdoptEvent::Planned");
|
||||
};
|
||||
assert_eq!(plan.target, AdoptTargetView::InstanceDir);
|
||||
assert_eq!(plan.isolation, AdoptIsolationView::Custom);
|
||||
// 缺省档的运行目录落数据区(**不是** `versions/<id>`)
|
||||
assert!(
|
||||
plan.run_dir
|
||||
.starts_with(fixture.root.join(".ULL/data/instances")),
|
||||
"落点应在 ULL 数据区:{}",
|
||||
plan.run_dir.display()
|
||||
);
|
||||
assert!(!plan.notes.is_empty(), "缺省档也要说清后果");
|
||||
assert!(!fixture.root.join(".ULL/config/profiles.json").exists());
|
||||
|
||||
// 确认:同一份请求发回去,真的登记
|
||||
let (tx, rx) = job::channel();
|
||||
spawn_adopt(
|
||||
env,
|
||||
tx,
|
||||
request("1.20.1", AdoptTargetView::InstanceDir),
|
||||
Mode::Confirm,
|
||||
2,
|
||||
);
|
||||
let AdoptEvent::Done(done) = recv(&rx) else {
|
||||
panic!("确认应回 AdoptEvent::Done");
|
||||
};
|
||||
assert_eq!(done.version_id, "1.20.1");
|
||||
assert_eq!(done.run_dir, plan.run_dir, "确认的落点必须与预检一致");
|
||||
assert!(done.dir_created);
|
||||
let profiles = std::fs::read_to_string(fixture.root.join(".ULL/config/profiles.json"))
|
||||
.expect("profiles.json 应被写出来");
|
||||
assert!(profiles.contains("1.20.1"));
|
||||
// 游戏根的版本目录一个字节都没写(只有夹具自己那两个文件)
|
||||
let dir = fixture.root.join(".minecraft/versions/1.20.1");
|
||||
let mut names: Vec<String> = std::fs::read_dir(&dir)
|
||||
.expect("读版本目录")
|
||||
.map(|entry| {
|
||||
entry
|
||||
.expect("条目")
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
})
|
||||
.collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["1.20.1.jar", "1.20.1.json"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_manifest_comes_back_as_every_problem_not_one_sentence() {
|
||||
let fixture = Fixture::new("problems");
|
||||
fixture.version("1.20.1", "{ this is not json");
|
||||
let (tx, rx) = job::channel();
|
||||
spawn_adopt(
|
||||
fixture.env(),
|
||||
tx,
|
||||
request("1.20.1", AdoptTargetView::InstanceDir),
|
||||
Mode::Preview,
|
||||
1,
|
||||
);
|
||||
let AdoptEvent::Failed(failure) = recv(&rx) else {
|
||||
panic!("坏清单应回 AdoptEvent::Failed");
|
||||
};
|
||||
assert_eq!(failure.code, "adopt_not_adoptable");
|
||||
assert_eq!(failure.problems.len(), 1);
|
||||
assert_eq!(failure.problems[0].code, "manifest_invalid_json");
|
||||
assert!(
|
||||
failure.problems[0].message.contains("line"),
|
||||
"清单问题要带行列号:{}",
|
||||
failure.problems[0].message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targets_map_to_core_and_custom_stays_out_of_the_gui() {
|
||||
assert_eq!(
|
||||
target_of(AdoptTargetView::InstanceDir),
|
||||
AdoptTarget::InstanceDir
|
||||
);
|
||||
assert_eq!(target_of(AdoptTargetView::Shared), AdoptTarget::Shared);
|
||||
assert_eq!(
|
||||
target_of(AdoptTargetView::VersionDir),
|
||||
AdoptTarget::VersionDir
|
||||
);
|
||||
// `Custom` 只在 core 侧出现(CLI 的 `--game-dir`):界面按缺省档显示
|
||||
assert_eq!(
|
||||
target_view(&AdoptTarget::Custom(std::path::PathBuf::from("/x"))),
|
||||
AdoptTargetView::InstanceDir
|
||||
);
|
||||
assert_eq!(
|
||||
isolation_view(Isolation::Custom),
|
||||
AdoptIsolationView::Custom
|
||||
);
|
||||
assert_eq!(
|
||||
isolation_view(Isolation::Shared),
|
||||
AdoptIsolationView::Shared
|
||||
);
|
||||
assert_eq!(
|
||||
isolation_view(Isolation::Isolated),
|
||||
AdoptIsolationView::Isolated
|
||||
);
|
||||
// 空名回落到版本 id 由 core 负责;这里只钉「空白名不进请求」
|
||||
let request = core_request(AdoptRequestView {
|
||||
version_id: "1.20.1".to_string(),
|
||||
name: Some(" ".to_string()),
|
||||
target: AdoptTargetView::Shared,
|
||||
});
|
||||
assert_eq!(request.name, None);
|
||||
assert!(request.created_at.is_some());
|
||||
assert_eq!(AdoptStage::Pick.slug(), "pick");
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! 取消安装的**实测装置**(M105)。
|
||||
//!
|
||||
//! ## 为什么这条测试要真起一次安装
|
||||
//!
|
||||
//! 「点取消没反应」有三种可能的断点,读码分不出来:
|
||||
//! ① 按钮/消息链断了(不派 `AppCommand::CancelInstall`);
|
||||
//! ② host 收下取消后,**还在跑的任务**继续发进度事件,把面板又点亮(面板闪一下又回来);
|
||||
//! ③ 取消根本停不下传输(M105 之前 core 的 `execute` 没有取消入参,下载线程会跑到段尾)。
|
||||
//!
|
||||
//! ①② 能靠单测的假事件流抓;③ 只有真起一次安装、真取消、真量「取消之后还在发生什么」才抓得到。
|
||||
//! 修完之后 ①②③ 都有守门:① 是 app 层的消息测试,② 是下面那条 CI 可跑的
|
||||
//! [`progress_from_a_cancelled_job_is_dropped`],③ 就是本用例(真网络)。
|
||||
//!
|
||||
//! 所以这一条是**真网络**用例:`#[ignore]` 守着(CI 不跑),人工跑:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo test -p unlucky-presenter --test '' -- real_install --ignored --nocapture
|
||||
//! # 或(等价,本用例在 lib 测试里)
|
||||
//! cargo test -p unlucky-presenter real_install_cancel -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! ## 它量什么(三样里能抓住的两样)
|
||||
//!
|
||||
//! | 现象 | 怎么量 | 抓住什么 / 抓不住什么 |
|
||||
//! |---|---|---|
|
||||
//! | 进度还在推进 | 取消后收到的 `InstallProgress(Some)` 条数与 `files_done` 增量 | 抓得住「UI 侧还在被事件喂」;抓不住事件之外的后台动作 |
|
||||
//! | 断点文件 | `<game>/**/*.ullpart*` 的总字节:取消时 vs 取消后 20 s,**并且**绝对值必须是 0 | 抓得住「还在写」与「留了半截垃圾」;抓不住「线程还活着」(`.ullpart` 不再长也可能是卡在等响应) |
|
||||
//! | 半成品变成品 | `<game>/versions/<id>/<id>.json`(版本清单)与 `<launcher_home>/config/profiles.json`(**实例登记的唯一真相源**,`LauncherLayout::profiles_config()`) | 抓得住「取消后仍然落了成品 / 登记了实例」;抓不住「用户看不到的临时目录」 |
|
||||
//!
|
||||
//! 线程与 socket 两样**抓不住**(没有可移植的查询口,见 `probe` 一节),所以这里不用它当判据。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::dto::{AppCommand, AppEvent, Backend, CoreEvent, InstallProgressView, InstallStage};
|
||||
|
||||
use super::job::HostTask;
|
||||
use super::{env::HostEnv, CoreHost};
|
||||
|
||||
/// 实测用的数据根(放在固定位置而不是系统 temp:便于人工复跑时留着看)。
|
||||
fn root() -> PathBuf {
|
||||
std::env::temp_dir().join("ull-m105-cancel")
|
||||
}
|
||||
|
||||
/// 递归数 `.ullpart` 家族(中间文件 + 续传元数据)的总字节。
|
||||
///
|
||||
/// 目录很小(只有本次安装写的那几层),所以裸递归够用;读不动的一律当 0
|
||||
/// (量的是趋势,不是精确值)。
|
||||
fn part_bytes(dir: &Path) -> u64 {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return 0;
|
||||
};
|
||||
let mut total = 0;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Ok(meta) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
if meta.is_dir() {
|
||||
total += part_bytes(&path);
|
||||
} else if let Some(name) = path.file_name().and_then(|name| name.to_str()) {
|
||||
// 中间文件 `<name>.ullpart[-<pid>-<seq>]` 与它的元数据 `.ullmeta.json`
|
||||
if name.contains(".ullpart") {
|
||||
total += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// 一次 tick 后抽干事件,返回本轮的全部事件。
|
||||
fn pump(host: &mut CoreHost, wait: Duration) -> Vec<AppEvent> {
|
||||
host.tick(wait);
|
||||
host.drain_events()
|
||||
}
|
||||
|
||||
/// 事件里的安装进度(别的变体一概忽略)。
|
||||
fn progress(event: &AppEvent) -> Option<&crate::dto::InstallProgressView> {
|
||||
match event {
|
||||
AppEvent::Core(CoreEvent::InstallProgress(Some(progress))) => Some(progress),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// **实测主用例**:真起一次安装 → 取消 → 量取消之后还在发生什么。
|
||||
///
|
||||
/// 版本用环境变量 `ULL_CANCEL_VERSION` 覆盖(默认 `1.20.1`:文件多、总量够大,
|
||||
/// 取消点之后还有一大截没下完,于是「取消后还在跑」一眼可见)。
|
||||
#[test]
|
||||
#[ignore = "真网络:真起一次安装再取消(需要 ULL_CANCEL_VERSION 指定的版本可下载)"]
|
||||
fn real_install_cancel_stops_progress_and_leaves_no_half_product() {
|
||||
let version = std::env::var("ULL_CANCEL_VERSION").unwrap_or_else(|_| "1.20.1".to_string());
|
||||
let root = root();
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
|
||||
let env = HostEnv::at_root(&root);
|
||||
let game = env.game.root().to_path_buf();
|
||||
// 实例登记的真相源是 `<launcher_home>/config/profiles.json`(`LauncherLayout::profiles_config()`),
|
||||
// 不是 `<game>/instances` —— 后者根本不存在,探它等于什么都没探(复审 P2-3 第 2 点)。
|
||||
let launcher = env.layout.clone();
|
||||
let mut host = CoreHost::headless(env);
|
||||
|
||||
// ---- 起点:取消之前 ----
|
||||
host.dispatch(AppCommand::InstallVersion(version.clone()));
|
||||
let started = Instant::now();
|
||||
let mut before_cancel_progress = 0_usize;
|
||||
let mut done_at_cancel = 0_u64;
|
||||
// 等到「真在下载」(files_done ≥ 2)再取消:取消得太早会落在 Planning/解析阶段,
|
||||
// 那种取消本来就只是阶段边界停,证明不了下载中取消的行为。
|
||||
while started.elapsed() < Duration::from_secs(180) {
|
||||
for event in pump(&mut host, Duration::from_millis(100)) {
|
||||
if let Some(progress) = progress(&event) {
|
||||
before_cancel_progress += 1;
|
||||
done_at_cancel = done_at_cancel.max(progress.files_done);
|
||||
}
|
||||
}
|
||||
if done_at_cancel >= 2 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
done_at_cancel >= 2,
|
||||
"180 s 内没进到下载阶段(files_done={done_at_cancel}):网络或版本号有问题,先让这条能跑起来"
|
||||
);
|
||||
let parts_at_cancel = part_bytes(&game);
|
||||
|
||||
// ---- 取消 ----
|
||||
host.dispatch(AppCommand::CancelInstall);
|
||||
let cancelled = Instant::now();
|
||||
let mut after: Vec<(f32, u64, InstallStage, u64)> = Vec::new();
|
||||
for event in host.drain_events() {
|
||||
if let Some(progress) = progress(&event) {
|
||||
after.push((
|
||||
cancelled.elapsed().as_secs_f32(),
|
||||
progress.files_done,
|
||||
progress.stage,
|
||||
part_bytes(&game),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 取消之后 20 s:还会不会被喂进度、断点文件还会不会长 ----
|
||||
//
|
||||
// 每秒采一次 `.ullpart` 总字节:判据不是「取消那一刻的瞬时值」,而是**它在 5 s 之后
|
||||
// 不再变高**——在飞的那个文件本来就要多活一个数据块(取消延迟的定义),
|
||||
// 5 s 是给它留的余量(远超一个数据块)。
|
||||
let settle = Duration::from_secs(5);
|
||||
let mut latest = None;
|
||||
let mut samples: Vec<(f32, u64)> = Vec::new();
|
||||
let mut next_sample = Duration::from_secs(1);
|
||||
while cancelled.elapsed() < Duration::from_secs(20) {
|
||||
for event in pump(&mut host, Duration::from_millis(100)) {
|
||||
if let Some(progress) = progress(&event) {
|
||||
latest = Some(progress.clone());
|
||||
after.push((
|
||||
cancelled.elapsed().as_secs_f32(),
|
||||
progress.files_done,
|
||||
progress.stage,
|
||||
part_bytes(&game),
|
||||
));
|
||||
}
|
||||
}
|
||||
let elapsed = cancelled.elapsed();
|
||||
if elapsed >= next_sample {
|
||||
samples.push((elapsed.as_secs_f32(), part_bytes(&game)));
|
||||
next_sample += Duration::from_secs(1);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let parts_after = part_bytes(&game);
|
||||
let done_after = latest
|
||||
.as_ref()
|
||||
.map_or(done_at_cancel, |view| view.files_done);
|
||||
|
||||
// ---- 取证:日志行 ----
|
||||
eprintln!("[cancel-test] root={}", root.display());
|
||||
eprintln!(
|
||||
"[cancel-test] 取消前:进度事件 {before_cancel_progress} 条,files_done={done_at_cancel},.ullpart 合计 {parts_at_cancel} 字节"
|
||||
);
|
||||
eprintln!(
|
||||
"[cancel-test] 取消后 20 s:进度事件 {} 条,files_done={done_after}(+{} 个文件),.ullpart 合计 {parts_after} 字节({:+})",
|
||||
after.len(),
|
||||
done_after.saturating_sub(done_at_cancel),
|
||||
i64::try_from(parts_after).unwrap_or(i64::MAX) - i64::try_from(parts_at_cancel).unwrap_or(i64::MAX)
|
||||
);
|
||||
for (at, parts) in &samples {
|
||||
eprintln!("[cancel-test] t={at:>5.1}s ullpart={parts}");
|
||||
}
|
||||
for (index, (at, done, stage, parts)) in after.iter().enumerate().take(12) {
|
||||
eprintln!(
|
||||
"[cancel-test] #{index} +{at:.2}s files_done={done} stage={stage:?} ullpart={parts}"
|
||||
);
|
||||
}
|
||||
if after.len() > 12 {
|
||||
eprintln!("[cancel-test] …(另有 {} 条)", after.len() - 12);
|
||||
}
|
||||
|
||||
// ---- 断点文件 / 临时目录 / 成品三样 ----
|
||||
// 版本 JSON 只在「全量就绪」才落盘(`execute` 的 complete 判定);
|
||||
// 实例登记在 `run()` 的最后一步,取消后不该登记(读 profiles.json 这份唯一真相源)。
|
||||
let version_json = game
|
||||
.join("versions")
|
||||
.join(&version)
|
||||
.join(format!("{version}.json"));
|
||||
let profiles = unlucky_core::game::install::instance::read_profiles(&launcher)
|
||||
.expect("profiles.json 应可读(不存在 = 空清单)");
|
||||
eprintln!(
|
||||
"[cancel-test] 版本清单{} / profiles.json 里 {} 条实例",
|
||||
if version_json.is_file() {
|
||||
"已落盘"
|
||||
} else {
|
||||
"未落盘"
|
||||
},
|
||||
profiles.profiles.len()
|
||||
);
|
||||
|
||||
// ---- 判据(修好之后必须成立的三条) ----
|
||||
assert!(
|
||||
after.is_empty(),
|
||||
"取消之后还在被喂进度事件({} 条):面板会被重新点亮 = 用户看到的「点了没反应」",
|
||||
after.len()
|
||||
);
|
||||
let settled = samples
|
||||
.iter()
|
||||
.find(|(at, _)| Duration::from_secs_f32(*at) >= settle)
|
||||
.map(|(_, parts)| *parts)
|
||||
.unwrap_or(parts_at_cancel);
|
||||
let grew = samples
|
||||
.iter()
|
||||
.filter(|(at, _)| Duration::from_secs_f32(*at) >= settle)
|
||||
.map(|(_, parts)| *parts)
|
||||
.max()
|
||||
.map_or(0, |peak| peak.saturating_sub(settled));
|
||||
assert_eq!(
|
||||
grew, 0,
|
||||
"取消 {settle:?} 之后断点文件还在长(+{grew} 字节):下载没有真的停"
|
||||
);
|
||||
assert_eq!(
|
||||
parts_after, 0,
|
||||
"取消之后还留着 {parts_after} 字节的 `.ullpart`:半截垃圾没清掉(D21 要的就是「清掉或标清楚」)"
|
||||
);
|
||||
assert!(
|
||||
profiles.profiles.is_empty(),
|
||||
"取消之后仍然登记了实例(profiles.json 有 {} 条):半成品被记成成品",
|
||||
profiles.profiles.len()
|
||||
);
|
||||
assert!(
|
||||
!version_json.is_file(),
|
||||
"取消之后仍然落了版本清单:半成品被记成「已安装」"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- CI 可跑的守门
|
||||
|
||||
/// 一条安装进度快照(够画面板就行)。
|
||||
fn progress_snapshot(files_done: u64) -> InstallProgressView {
|
||||
InstallProgressView {
|
||||
version: "1.20.1".to_string(),
|
||||
stage: InstallStage::Downloading,
|
||||
files_done,
|
||||
files_total: 512,
|
||||
..InstallProgressView::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// **「点了取消没反应」的真根因**:取消之后旧任务的进度事件还在往回灌。
|
||||
///
|
||||
/// 现场:用户点「取消安装」→ host 收下取消、清 `install`、发 `InstallProgress(None)`(面板回弹),
|
||||
/// 但**后台线程还在跑**(core 的下载段不是瞬停的),它每下完一个文件就发一条
|
||||
/// `HostTask::Install`。没有代次的话这条事件照样被翻译成 `InstallProgress(Some(..))`,
|
||||
/// 面板立刻又被点亮 —— 用户看到的就是「取消按钮点了没反应」。
|
||||
///
|
||||
/// 抓得住:代次不符的 `Install` / `InstallDone` 被丢掉(面板不复燃、也不清掉新一轮的取消句柄)。
|
||||
/// 抓不住:真网络的取消延迟(那条在 `real_install_cancel_*`,`#[ignore]` 守着要手动跑)。
|
||||
#[test]
|
||||
fn progress_from_a_cancelled_job_is_dropped() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-cancel-epoch-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut host = CoreHost::headless(HostEnv::at_root(&dir));
|
||||
|
||||
// 开工:拿当代次
|
||||
let epoch = host.next_install_epoch();
|
||||
host.handle(HostTask::Install {
|
||||
progress: Box::new(progress_snapshot(3)),
|
||||
epoch,
|
||||
});
|
||||
let live: Vec<AppEvent> = host.drain_events();
|
||||
assert!(
|
||||
live.iter()
|
||||
.any(|event| matches!(event, AppEvent::Core(CoreEvent::InstallProgress(Some(_))))),
|
||||
"当代次的事件必须照常点亮面板"
|
||||
);
|
||||
|
||||
// 取消
|
||||
host.dispatch(AppCommand::CancelInstall);
|
||||
let cancelled: Vec<AppEvent> = host.drain_events();
|
||||
assert!(
|
||||
cancelled
|
||||
.iter()
|
||||
.any(|event| matches!(event, AppEvent::Core(CoreEvent::InstallProgress(None)))),
|
||||
"取消要立刻把面板收回(不等后台)"
|
||||
);
|
||||
assert!(
|
||||
!cancelled
|
||||
.iter()
|
||||
.any(|event| matches!(event, AppEvent::Core(CoreEvent::InstallProgress(Some(_))))),
|
||||
"取消本身不该重新点亮面板"
|
||||
);
|
||||
|
||||
// 旧任务继续发进度(现场就是这样)——必须被丢掉
|
||||
host.handle(HostTask::Install {
|
||||
progress: Box::new(progress_snapshot(4)),
|
||||
epoch,
|
||||
});
|
||||
host.handle(HostTask::InstallDone {
|
||||
progress: Box::new(progress_snapshot(512)),
|
||||
result: Err(crate::dto::ContentFailure::new(
|
||||
crate::dto::ContentFailureKind::Other,
|
||||
"已取消",
|
||||
)),
|
||||
epoch,
|
||||
});
|
||||
let after: Vec<AppEvent> = host.drain_events();
|
||||
assert!(
|
||||
after.is_empty(),
|
||||
"取消之后旧任务的事件必须一条都不出(实际 {} 条):出了就会把面板重新点亮",
|
||||
after.len()
|
||||
);
|
||||
|
||||
// 新一轮安装的代次是新的:它的进度照常生效
|
||||
let fresh = host.next_install_epoch();
|
||||
assert_ne!(fresh, epoch, "新一轮开工必须换代次");
|
||||
host.handle(HostTask::Install {
|
||||
progress: Box::new(progress_snapshot(1)),
|
||||
epoch: fresh,
|
||||
});
|
||||
assert!(
|
||||
host.drain_events()
|
||||
.iter()
|
||||
.any(|event| matches!(event, AppEvent::Core(CoreEvent::InstallProgress(Some(_))))),
|
||||
"新一轮的进度要能点亮面板(不能把代次守卫写成「一律丢弃」)"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
//! `CoreHost::dispatch` 的指令实现(从 `host/mod.rs` 拆出,守 400 行上限)。
|
||||
//!
|
||||
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.env` / `self.tx` 等。
|
||||
|
||||
use crate::dto::{
|
||||
AccountNoticeCode, AppCommand, Backend, CoreEvent, CreateRequest, LoaderKind, PackEvent,
|
||||
};
|
||||
use crate::toast::ToastKind;
|
||||
|
||||
use super::accounts::{self, LoginJob};
|
||||
use super::{
|
||||
addon, game, install, java, launch, loader_versions, logs, mods, saves, settings, versions,
|
||||
CoreHost,
|
||||
};
|
||||
|
||||
impl CoreHost {
|
||||
/// 下发一条用户意图(`Backend::dispatch` 的实现体)。
|
||||
pub(super) fn dispatch_command(&mut self, command: AppCommand) {
|
||||
self.dispatch_inner(command);
|
||||
}
|
||||
}
|
||||
impl CoreHost {
|
||||
/// 指令分发(见 `dispatch_command`)。
|
||||
fn dispatch_inner(&mut self, command: AppCommand) {
|
||||
match command {
|
||||
AppCommand::RefreshAll => self.refresh_all(),
|
||||
AppCommand::LaunchInstance(id) => self.start_launch(&id),
|
||||
AppCommand::StopInstance(id) => match self.running_pid(&id) {
|
||||
Some(pid) => match launch::stop(pid) {
|
||||
Ok(()) => self.toast(ToastKind::Info, "已请求停止游戏进程"),
|
||||
Err(reason) => self.toast(ToastKind::Error, reason),
|
||||
},
|
||||
None => self.toast(ToastKind::Warn, "这个实例当前没有在跑"),
|
||||
},
|
||||
AppCommand::ListVersions(_) => versions::spawn_list(self.env.clone(), self.tx.clone()),
|
||||
AppCommand::ListLoaderVersions { kind, mc_version } => {
|
||||
loader_versions::spawn_list(self.env.clone(), self.tx.clone(), kind, mc_version);
|
||||
}
|
||||
AppCommand::InstallVersion(version) => {
|
||||
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
self.install_cancel = Some(std::sync::Arc::clone(&cancel));
|
||||
let epoch = self.next_install_epoch();
|
||||
install::spawn_install(
|
||||
self.env.clone(),
|
||||
self.tx.clone(),
|
||||
CreateRequest {
|
||||
name: version.clone(),
|
||||
version,
|
||||
loader: LoaderKind::Vanilla,
|
||||
loader_version: String::new(),
|
||||
},
|
||||
cancel,
|
||||
epoch,
|
||||
);
|
||||
}
|
||||
AppCommand::CreateInstance(request) => {
|
||||
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
self.install_cancel = Some(std::sync::Arc::clone(&cancel));
|
||||
let epoch = self.next_install_epoch();
|
||||
install::spawn_install(self.env.clone(), self.tx.clone(), request, cancel, epoch);
|
||||
}
|
||||
AppCommand::CancelInstall => {
|
||||
// 取消的三级语义(M105 实测后定稿,见 `install::CANCELLED`):
|
||||
// ① **面板立刻收回**(不等后台,用户点了就有反应);
|
||||
// ② **作废旧任务的代次**:后台线程是异步停的(取消延迟 = 一个数据块),
|
||||
// 它带回来的进度事件一律因代次不符被 `handle` 丢掉——面板不会被重新点亮。
|
||||
// 这是「点了没反应」的真根因:以前生效了又被旧事件盖回去;
|
||||
// ③ 后台线程经 `install::run` 的取消检查停在段边界、**绝不登记实例**,
|
||||
// core 的 `execute_with_cancel` 还会就地掐断在飞的那个文件。
|
||||
//
|
||||
// 为什么不在这里 join 后台线程:UI 线程绝不能为了「等它停」而阻塞
|
||||
// (一个数据块的延迟在人眼下就是「立刻」,但足以让 UI 卡住一帧以上)。
|
||||
if let Some(cancel) = &self.install_cancel {
|
||||
cancel.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
// M113: import/export —— 同一块面板的「取消」对导入也生效:立标记 + 作废 pack 代次。
|
||||
// 返回值告诉我们「刚才有没有导入在跑」:有的话**不能**再发 install 那条取消信号
|
||||
// (它说的是「安装已取消、实例未创建」,而导入的取消是**管线级**的:core 在检查点
|
||||
// 停手并回滚,文案见 `PackCancelled`;M195 之前它会在后台跑完并登记实例)。
|
||||
let pack_was_running = addon::cancel_pack(&self.env);
|
||||
if pack_was_running {
|
||||
self.send(CoreEvent::Pack(Box::new(PackEvent::Cancelled)));
|
||||
}
|
||||
self.install_epoch = self.install_epoch.wrapping_add(1);
|
||||
self.install = None;
|
||||
self.send(CoreEvent::InstallProgress(None));
|
||||
// 只发信号、不发文案(复审 P2-2):presenter 没有 i18n 依赖,
|
||||
// 写死的中文会和页面语言打架。提示语由 app 层按 `Key` 现求。
|
||||
// 导入在跑时不发这条:下载页那条文案说的是「实例未创建」,对导入是撒谎。
|
||||
if !pack_was_running {
|
||||
self.send(CoreEvent::InstallCancelled);
|
||||
}
|
||||
}
|
||||
AppCommand::SetIsolation { id, mode } => {
|
||||
match game::set_isolation(&self.env, &id, &mode) {
|
||||
Ok(()) => {
|
||||
self.toast(ToastKind::Success, "隔离档位已更新");
|
||||
self.refresh_instances();
|
||||
}
|
||||
Err(reason) => self.toast(ToastKind::Error, reason),
|
||||
}
|
||||
}
|
||||
AppCommand::SetInstanceLaunchArgs { id, patch } => {
|
||||
match self.apply_launch_args(&id, &patch) {
|
||||
Ok(()) => {
|
||||
self.toast(ToastKind::Success, "启动参数已保存");
|
||||
self.refresh_instances();
|
||||
}
|
||||
Err(reason) => self.toast(ToastKind::Error, reason),
|
||||
}
|
||||
}
|
||||
AppCommand::RefreshLogSessions => {
|
||||
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
|
||||
}
|
||||
AppCommand::OpenLogSession(id) => self.open_session(&id),
|
||||
AppCommand::ImportCrashLog(path) => match logs::analyze_crash_file(&path) {
|
||||
Ok((label, report)) => {
|
||||
self.send(CoreEvent::DiagnosisReady(report));
|
||||
self.toast(ToastKind::Info, label);
|
||||
}
|
||||
Err(reason) => self.toast(ToastKind::Warn, reason),
|
||||
},
|
||||
// 账户增删切:结果走**页面横幅**(`AccountNoticeChanged`),不发 toast——
|
||||
// app-iced 不渲染 toast,发出去等于用户什么都看不到(复审 r1 P2-2)。
|
||||
AppCommand::AddOfflineAccount(name) => match accounts::add_offline(&self.env, &name) {
|
||||
Ok(display) => {
|
||||
self.notice(AccountNoticeCode::Added, &display, "");
|
||||
self.refresh_accounts();
|
||||
}
|
||||
Err(failure) => {
|
||||
self.notice_failure(failure, &name);
|
||||
self.refresh_accounts();
|
||||
}
|
||||
},
|
||||
AppCommand::RemoveAccount(key) => match accounts::remove(&self.env, &key) {
|
||||
Ok(display) => {
|
||||
if self.active_account.as_deref() == Some(key.as_str()) {
|
||||
self.active_account = None;
|
||||
}
|
||||
self.notice(AccountNoticeCode::Removed, &display, "");
|
||||
self.refresh_accounts();
|
||||
}
|
||||
Err(failure) => {
|
||||
self.notice_failure(failure, &key);
|
||||
self.refresh_accounts();
|
||||
}
|
||||
},
|
||||
AppCommand::SwitchAccount(id) => {
|
||||
let name = self
|
||||
.accounts
|
||||
.iter()
|
||||
.find(|account| account.id == id)
|
||||
.map_or_else(|| id.clone(), |account| account.name.clone());
|
||||
self.active_account = Some(id);
|
||||
self.refresh_accounts();
|
||||
self.notice(AccountNoticeCode::Switched, &name, "");
|
||||
}
|
||||
AppCommand::DismissAccountNotice => {
|
||||
self.send(CoreEvent::AccountNoticeChanged(None));
|
||||
}
|
||||
AppCommand::StartDeviceLogin => {
|
||||
self.login = Some(LoginJob::new());
|
||||
let job = self.login.clone().unwrap_or_default();
|
||||
self.send(CoreEvent::DeviceLoginChanged(crate::dto::DeviceLoginView {
|
||||
phase: crate::dto::DeviceLoginPhase::Requesting,
|
||||
message: "正在向微软申请设备码…".to_string(),
|
||||
..crate::dto::DeviceLoginView::new()
|
||||
}));
|
||||
accounts::start_login(self.env.clone(), self.tx.clone(), job);
|
||||
}
|
||||
AppCommand::StartBrowserLogin => {
|
||||
self.login = Some(LoginJob::new());
|
||||
let job = self.login.clone().unwrap_or_default();
|
||||
self.send(CoreEvent::DeviceLoginChanged(crate::dto::DeviceLoginView {
|
||||
phase: crate::dto::DeviceLoginPhase::Requesting,
|
||||
message: "正在准备浏览器登录(本地回调端口)…".to_string(),
|
||||
..crate::dto::DeviceLoginView::new()
|
||||
}));
|
||||
accounts::start_browser_login(self.env.clone(), self.tx.clone(), job);
|
||||
}
|
||||
AppCommand::CancelDeviceLogin => {
|
||||
if let Some(job) = &self.login {
|
||||
job.cancel();
|
||||
}
|
||||
self.login = None;
|
||||
// 第三方轨的面板也归这条「关掉登录面板」:两条轨都不该留一个转不完的圈
|
||||
if let Some(job) = &self.authlib {
|
||||
job.cancel();
|
||||
}
|
||||
self.authlib = None;
|
||||
self.send(CoreEvent::DeviceLoginChanged(
|
||||
crate::dto::DeviceLoginView::new(),
|
||||
));
|
||||
self.send(CoreEvent::AuthlibLoginChanged(
|
||||
crate::dto::AuthlibLoginView::new(),
|
||||
));
|
||||
}
|
||||
AppCommand::AddAuthlibAccount(request) => {
|
||||
self.authlib = Some(LoginJob::new());
|
||||
let job = self.authlib.clone().unwrap_or_default();
|
||||
self.send(CoreEvent::AuthlibLoginChanged(
|
||||
crate::dto::AuthlibLoginView::running(&request.server, "正在校验服务器地址…"),
|
||||
));
|
||||
accounts::start_authlib_login(self.env.clone(), self.tx.clone(), job, request);
|
||||
}
|
||||
AppCommand::ProbeAuthlibServer(server) => {
|
||||
// 只探活:后台线程跑服务发现,结果回到同一条 `AuthlibLoginChanged`
|
||||
let env = self.env.clone();
|
||||
let tx = self.tx.clone();
|
||||
accounts::spawn_probe(env, tx, server);
|
||||
}
|
||||
AppCommand::InstallJava(vendor) => {
|
||||
self.java_busy = true;
|
||||
self.java_message = format!("正在安装 Java({vendor})…");
|
||||
self.refresh_java();
|
||||
java::spawn_install(
|
||||
self.env.clone(),
|
||||
self.tx.clone(),
|
||||
unlucky_core::game::java::JdkVendor::parse(&vendor),
|
||||
);
|
||||
}
|
||||
AppCommand::SetSetting { key, value } => match settings::set(&self.env, &key, &value) {
|
||||
Ok(()) => {
|
||||
// 「重新检测地区」:`settings::set` 已经删了 `region.json` 并让共享引擎失效,
|
||||
// 但自更新的 `Factory` 常驻持有旧引擎 —— 不在这条路径上重建它,自更新会继续
|
||||
// 按旧地区档案跑,直到重启(M165 复审 P2-1)。
|
||||
if key == super::engine::DETECT_REGION_ACTION {
|
||||
self.refresh_update_factory();
|
||||
}
|
||||
// M193:下载三键(并发/重试/gh-proxy)改了要立刻换引擎参数。这是**显式**的同步点
|
||||
// ——不藏在任何读函数里。值没变时它一行不动。
|
||||
if settings::download::apply_tuning(&self.env) {
|
||||
self.refresh_update_factory();
|
||||
}
|
||||
self.send(CoreEvent::SettingsChanged(settings::groups(&self.env)));
|
||||
self.java_chain.clear();
|
||||
self.refresh_java();
|
||||
}
|
||||
Err(reason) => self.toast(ToastKind::Error, reason),
|
||||
},
|
||||
AppCommand::DismissNotice(id) => {
|
||||
self.notices.retain(|notice| notice.id != id);
|
||||
self.send(CoreEvent::NoticesChanged(self.notices.clone()));
|
||||
}
|
||||
// M109:内容库检索(网络在后台线程,结果经 `HostTask::Content` 回来)
|
||||
AppCommand::SearchContent(query) => {
|
||||
addon::spawn_search(self.env.clone(), self.tx.clone(), query);
|
||||
}
|
||||
// M162:详情/版本列表(选中项目才发;结果经 `CoreEvent::ContentDetail` 回来)
|
||||
AppCommand::LoadContentDetail(request) => {
|
||||
addon::spawn_detail(self.env.clone(), self.tx.clone(), *request);
|
||||
}
|
||||
// M109:把某个内容项目装进指定实例。取消标记与安装面板共用同一条路
|
||||
// (`CancelInstall` 会把标记置位,下载在阶段边界收尾)。
|
||||
// M113: import/export —— 导入 / 预检 / 确认写盘 / 关面板
|
||||
AppCommand::Pack(command) => self.pack(*command),
|
||||
// M223: 导入已有版本 —— 扫描 / 预检 / 确认登记 / 关面板
|
||||
AppCommand::Adopt(command) => self.adopt(*command),
|
||||
AppCommand::InstallContent(request) => {
|
||||
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
self.install_cancel = Some(std::sync::Arc::clone(&cancel));
|
||||
// 代次与版本安装共用同一条路(M105 的形状):取消/被顶掉之后,旧任务带回来的
|
||||
// 进度事件一律被 `handle` 丢掉,内容库种的这次安装不会把面板重新点亮。
|
||||
let epoch = self.next_install_epoch();
|
||||
addon::spawn_install(self.env.clone(), self.tx.clone(), *request, cancel, epoch);
|
||||
}
|
||||
// M159: 自更新 —— 检查 / 下载替换 / 拒绝 / 开关
|
||||
AppCommand::CheckUpdate => self.start_update_check(true),
|
||||
AppCommand::ApplyUpdate => self.start_update_apply(),
|
||||
AppCommand::DismissUpdate => {
|
||||
self.update.dismiss();
|
||||
self.publish_update();
|
||||
}
|
||||
AppCommand::SetUpdateAutoCheck(on) => match self.update.set_auto_check(on) {
|
||||
Ok(()) => self.publish_update(),
|
||||
Err(reason) => {
|
||||
// 写不进去也要把这次的选择留在会话里(不把开关弹回去),并说清原因。
|
||||
self.publish_update();
|
||||
self.toast(ToastKind::Warn, reason);
|
||||
}
|
||||
},
|
||||
// M224:本地 Mod 列表 / 启停(磁盘在后台线程碰;这里只算"游戏在不在跑")
|
||||
AppCommand::Mods(command) => {
|
||||
let id = match &*command {
|
||||
crate::dto::ModsCommand::List(id) => id.clone(),
|
||||
crate::dto::ModsCommand::SetEnabled { id, .. } => id.clone(),
|
||||
};
|
||||
// 运行中挡在 host 这一侧(不只是 UI 灰掉):Windows 上进程正持有 jar,
|
||||
// rename 必然失败,与其让用户读一句 OS 报错,不如直说。
|
||||
let running = self.in_flight(&id);
|
||||
mods::dispatch(self.env.clone(), self.tx.clone(), *command, running);
|
||||
}
|
||||
// M235:实例存档 / 截图的只读列表 + 打开文件夹;M247 加了三个写操作。
|
||||
//
|
||||
// **写操作要看"游戏在不在跑"**(M247 补齐,与 `AppCommand::Mods` 同款判据):
|
||||
// Windows 上进程占着世界目录 ⇒ rename/删除会撞共享冲突;**Linux 上删一个正在跑的世界
|
||||
// 会成功**——那是在用户正在玩的时候把存档删掉。所以判据放在 host 这一侧、落 core 之前,
|
||||
// 在飞就回一条结构化失败「游戏正在运行,先退出再操作」,一个字节都不碰盘。
|
||||
// 读路径与「打开文件夹」不受影响(它们不改盘)。
|
||||
AppCommand::Saves(command) => {
|
||||
let id = saves::instance_of(&command).to_string();
|
||||
let running = self.in_flight(&id);
|
||||
saves::dispatch(self.env.clone(), self.tx.clone(), *command, running);
|
||||
}
|
||||
// AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型)
|
||||
other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => {
|
||||
self.chat.dispatch(other);
|
||||
for event in self.chat.drain_events() {
|
||||
self.events.push_back(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! 下载引擎与 Java 落点的装配(versions / install / java 三个任务共用)。
|
||||
//!
|
||||
//! **引擎是进程级的、按数据根共享的**(M165):本模块不再自己 `DownloadEngine::new`,而是向
|
||||
//! [`unlucky_core::download::shared`] 要那个数据根上已经存在的引擎。于是连接池、信号量限流、
|
||||
//! 镜像健康度、内容寻址缓存都跨请求累积,而不是每次请求清零(逐项代价与边界见那个模块的文档)。
|
||||
//!
|
||||
//! 与 CLI `install::build_engine` 同源:内容寻址缓存落 `.ULL/cache/`,地区判定走
|
||||
//! 24h 缓存 + 环境信号(**不做网络探测**,与 D7 的「不探测时」约定一致);区别只在生命周期
|
||||
//! ——CLI 一条命令一个进程,建一次就够;GUI 进程要在成百上千次请求之间复用同一份。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_core::download::shared;
|
||||
use unlucky_core::download::DownloadEngine;
|
||||
use unlucky_core::game::java::vendor::JdkVendor;
|
||||
use unlucky_core::game::java::{self, JavaInstallRoot};
|
||||
use unlucky_core::platform::paths::ProcessEnv;
|
||||
|
||||
use super::env::HostEnv;
|
||||
|
||||
/// 取该数据根上**共享**的下载引擎(缓存/健康度/地区判定都在数据根里)。
|
||||
///
|
||||
/// 同一个数据根无论被请求多少次,都是同一个引擎;不同数据根各一份(缓存/健康度不串味)。
|
||||
///
|
||||
/// **取引擎前先把下载设置灌进 core 的下载调优**(M193,按数据根存):`download.concurrency` /
|
||||
/// `download.retry_limit` / `download.gh_proxy` 变了就重建引擎(值没变时 [`settings::download::apply_tuning`]
|
||||
/// 一行不动,所以每次调用都灌是安全的)。这是「用户改了设置 ⇒ 下一次下载真的按新值跑」的落点:
|
||||
/// 不在这里灌,进程级引擎会带着启动时那份参数活到进程结束——那正是「设置页看着有、实际没用」。
|
||||
#[must_use]
|
||||
pub fn engine(env: &HostEnv) -> Arc<DownloadEngine> {
|
||||
super::settings::download::apply_tuning(env);
|
||||
shared::engine_for(env.layout.cache())
|
||||
}
|
||||
|
||||
/// 让这个数据根的共享引擎失效:下一次 [`engine`] 重新构造(重读地区判定/健康度/缓存索引)。
|
||||
///
|
||||
/// 在飞请求手里的 `Arc` 不受影响。设置页「重新检测地区」清掉 `region.json` 之后必须调它,
|
||||
/// 否则那句话(「下次下载重新判定」)在进程级引擎下就不成立了。
|
||||
pub fn forget(env: &HostEnv) {
|
||||
shared::forget(env.layout.cache());
|
||||
}
|
||||
|
||||
/// 是否国内网络(决定默认发行版顺序与镜像优先级)。
|
||||
pub fn domestic(env: &HostEnv) -> bool {
|
||||
engine(env).region().is_domestic()
|
||||
}
|
||||
|
||||
/// 「重新检测地区」这个动作键(设置页的行 id、`settings::set` 的分派、`dispatch` 里「要不要重建
|
||||
/// 自更新 Factory」的判断共用一个真相源)。
|
||||
///
|
||||
/// 动作不是普通设置:它要清 `region.json`、让 [`forget`] 失效共享引擎,并让自更新的 `Factory`
|
||||
/// 换引擎(M165 复审 P2-1)——所以三处都得认出它。常量放这里(引擎/地区这一侧),不放已经顶到
|
||||
/// 400 行红线的 `settings.rs`。
|
||||
pub(super) const DETECT_REGION_ACTION: &str = "download.action.detect_region";
|
||||
|
||||
/// 设置里的 `java.vendorPriority`(空 = 按地区默认序)。
|
||||
pub fn java_priority(env: &HostEnv) -> Vec<JdkVendor> {
|
||||
env.store()
|
||||
.effective("java.vendorPriority")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|items| {
|
||||
let texts: Vec<String> = items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
unlucky_core::game::java::vendor::parse_priority(&texts)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 设置里的 `java.installRoot`(空 = 按数据目录模式派生)。
|
||||
pub fn java_install_override(env: &HostEnv) -> Option<PathBuf> {
|
||||
env.store()
|
||||
.doc()
|
||||
.effective_str("java.installRoot")
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// 托管 JDK 落点(解析失败 → 用户可读文案)。
|
||||
pub fn java_install_root(env: &HostEnv) -> Result<JavaInstallRoot, String> {
|
||||
let override_root = java_install_override(env);
|
||||
JavaInstallRoot::resolve(
|
||||
env.mode(),
|
||||
&env.layout,
|
||||
override_root.as_deref(),
|
||||
env.platform,
|
||||
&ProcessEnv,
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// 托管 JDK 可能存在的全部安装根(扫描运行时用)。
|
||||
pub fn java_scan_roots(env: &HostEnv) -> Vec<PathBuf> {
|
||||
java::location::scan_roots(
|
||||
env.mode(),
|
||||
&env.layout,
|
||||
java_install_override(env).as_deref(),
|
||||
env.platform,
|
||||
&ProcessEnv,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 每个用例一个独立数据根(共享登记处是进程级的,键撞了会互相干扰)。
|
||||
fn root(tag: &str) -> std::path::PathBuf {
|
||||
static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-host-engine-{tag}-{}-{seq}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join(".ULL").join("cache")).expect("cache");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_uses_the_data_root_cache() {
|
||||
let dir = root("cache");
|
||||
let env = HostEnv::at_root(&dir);
|
||||
let engine = engine(&env);
|
||||
assert_eq!(
|
||||
engine.config().max_concurrency,
|
||||
unlucky_core::download::config::DEFAULT_MAX_CONCURRENCY
|
||||
);
|
||||
assert_eq!(engine.concurrency_probe(), engine.config().max_concurrency);
|
||||
// 地区判定不联网:只读缓存/信号
|
||||
let _ = domestic(&env);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// **M165 的守卫**:同一 host 的多次请求只建一个引擎。
|
||||
///
|
||||
/// 断言的是**构造次数**(`shared::constructions`),不是「能不能下到文件」——每请求现建一个
|
||||
/// 引擎照样能下到文件,只有次数能把「复用」与「没复用」分开。
|
||||
///
|
||||
/// 抓得住的变异:把 `core::download::shared::engine_for` 里 `engines.get` 的命中分支删掉
|
||||
/// (每次调用都现建),本用例红在 `constructions(...) == 1`。
|
||||
#[test]
|
||||
fn host_requests_share_one_engine() {
|
||||
let dir = root("share");
|
||||
let env = HostEnv::at_root(&dir);
|
||||
let cache_root = env.layout.cache();
|
||||
|
||||
// 三条不同「请求」路径都从 engine() 取引擎(检索 / 清单 / 安装共用这一个漏斗)。
|
||||
let first = engine(&env);
|
||||
let second = engine(&env);
|
||||
let third = engine(&HostEnv::at_root(&dir));
|
||||
|
||||
assert!(
|
||||
Arc::ptr_eq(&first, &second) && Arc::ptr_eq(&second, &third),
|
||||
"同一数据根的多次请求必须是同一个引擎"
|
||||
);
|
||||
assert_eq!(shared::constructions(&cache_root), 1, "只该构造一次引擎");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 不同数据根**不共享**引擎(缓存/健康度串味比多建一个引擎严重得多)。
|
||||
#[test]
|
||||
fn different_data_roots_get_their_own_engine() {
|
||||
let dir_a = root("root-a");
|
||||
let dir_b = root("root-b");
|
||||
let env_a = HostEnv::at_root(&dir_a);
|
||||
let env_b = HostEnv::at_root(&dir_b);
|
||||
let engine_a = engine(&env_a);
|
||||
let engine_b = engine(&env_b);
|
||||
assert!(!Arc::ptr_eq(&engine_a, &engine_b));
|
||||
assert_eq!(shared::constructions(env_a.layout.cache()), 1);
|
||||
assert_eq!(shared::constructions(env_b.layout.cache()), 1);
|
||||
std::fs::remove_dir_all(&dir_a).ok();
|
||||
std::fs::remove_dir_all(&dir_b).ok();
|
||||
}
|
||||
|
||||
/// `forget`(「重新检测地区」)之后下一次请求拿到新引擎。
|
||||
#[test]
|
||||
fn forget_makes_the_next_request_rebuild() {
|
||||
let dir = root("forget");
|
||||
let env = HostEnv::at_root(&dir);
|
||||
let before = engine(&env);
|
||||
forget(&env);
|
||||
let after = engine(&env);
|
||||
assert!(!Arc::ptr_eq(&before, &after));
|
||||
assert_eq!(shared::constructions(env.layout.cache()), 2);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_merge_priority_reads_settings() {
|
||||
let dir = root("priority");
|
||||
let env = HostEnv::at_root(&dir);
|
||||
std::fs::create_dir_all(env.layout.config()).expect("config");
|
||||
std::fs::write(
|
||||
env.launcher_config(),
|
||||
r#"{"java":{"vendorPriority":["zulu"]}}"#,
|
||||
)
|
||||
.expect("写配置");
|
||||
assert_eq!(java_priority(&env), vec![JdkVendor::Zulu]);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! host 的环境快照:一份 `ResolvedData` + 由其派生的布局视图。
|
||||
//!
|
||||
//! app 自己不解析三模式数据根(那是 `core::platform::data_dir` 的活);这里只做两件事:
|
||||
//! ①按「exe 同级便携 `launcher.json` 的 `core.*` 四键」拼请求;②把解析结果与
|
||||
//! `Platform` / `DataMode` 打成一个可克隆的结构,交给后台线程。
|
||||
//!
|
||||
//! 截图/演示通道用 [`HostEnv::at_root`] 直接钉一个根(`<root>/.ULL` + `<root>/.minecraft`)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::game::GameLayout;
|
||||
use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, ResolvedData, Resolver};
|
||||
use unlucky_core::platform::layout::LauncherLayout;
|
||||
use unlucky_core::platform::paths::Platform;
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
/// 数据根决定键(与 CLI 的 `DATA_ROOT_KEYS` 同源同义)。
|
||||
pub(crate) const DATA_ROOT_KEYS: [&str; 4] = [
|
||||
"core.data_mode",
|
||||
"core.data_custom_root",
|
||||
"core.launcher_home",
|
||||
"core.game_home",
|
||||
];
|
||||
|
||||
/// host 运行期需要的一切路径/平台信息(可克隆进线程)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostEnv {
|
||||
/// 数据根解析结果
|
||||
pub data: ResolvedData,
|
||||
/// `.ULL/` 布局
|
||||
pub layout: LauncherLayout,
|
||||
/// `.minecraft/` 布局
|
||||
pub game: GameLayout,
|
||||
/// 平台
|
||||
pub platform: Platform,
|
||||
}
|
||||
|
||||
impl HostEnv {
|
||||
/// 按便携目录的 `core.*` 解析真实数据根;解析不出来返回 `None`(host 退回 mock)。
|
||||
pub fn resolve() -> Option<Self> {
|
||||
let exe_dir = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|path| path.parent().map(Path::to_path_buf))?;
|
||||
let request = request_from_portable(&exe_dir);
|
||||
let resolver = Resolver::new(Platform::current(), Some(exe_dir));
|
||||
let data = resolver.resolve_default(&request).ok()?.clone();
|
||||
Some(Self::from_data(data))
|
||||
}
|
||||
|
||||
/// 直接钉一个根:`<root>/.ULL` + `<root>/.minecraft`(截图/演示通道)。
|
||||
///
|
||||
/// 根下已有 `config/launcher.json` 时**尊重其中的 `core.game_home` / `core.launcher_home`**:
|
||||
/// 演示根常用它把游戏目录指到既有真数据上(避免复制几 GB 的 assets/libraries)。
|
||||
pub fn at_root(root: impl AsRef<Path>) -> Self {
|
||||
let root = root.as_ref();
|
||||
let request = request_from_portable(root);
|
||||
let absolute = |path: Option<PathBuf>| path.filter(|path| path.is_absolute());
|
||||
Self::from_data(ResolvedData {
|
||||
mode: DataMode::Portable,
|
||||
launcher_home: absolute(request.launcher_home).unwrap_or_else(|| root.join(".ULL")),
|
||||
game_home: absolute(request.game_home).unwrap_or_else(|| root.join(".minecraft")),
|
||||
redirected_from: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 由解析结果建环境。
|
||||
///
|
||||
/// **顺带做一件有副作用的事**:把本进程的日志目录登记给 `launch::launcher_log`(M188)。
|
||||
/// 这是**启动早期那几行诊断**能落到 `<.ULL>/logs/launcher.log` 的唯一时机——app 层在
|
||||
/// presenter 打开之前就在打 `[probe]` / `[font]` / `[theme]`,那些行先被 core 缓冲
|
||||
/// (上限 200 行),到这里一次刷出。此前只有「启动一次游戏」或「导出诊断包」才登记,
|
||||
/// 于是「双击 → 启动 → 崩」的进程根本不产生 launcher.log——正是用户投诉的那一半。
|
||||
pub fn from_data(data: ResolvedData) -> Self {
|
||||
let layout = data.layout();
|
||||
unlucky_core::launch::launcher_log::set_dir(layout.logs());
|
||||
Self {
|
||||
layout,
|
||||
game: GameLayout::new(data.game_home.clone()),
|
||||
platform: Platform::current(),
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
/// `config/launcher.json` 路径。
|
||||
pub fn launcher_config(&self) -> PathBuf {
|
||||
self.layout.launcher_config()
|
||||
}
|
||||
|
||||
/// 设置存储(每次现读一份;写操作自己 save)。
|
||||
pub fn store(&self) -> SettingStore {
|
||||
SettingStore::load(self.launcher_config(), launcher_schema())
|
||||
}
|
||||
|
||||
/// 启动会话根(M188 起 = `logs/`;`LauncherConfig` 在其下建 `game/`)。
|
||||
pub fn sessions_root(&self) -> PathBuf {
|
||||
self.layout.sessions_root()
|
||||
}
|
||||
|
||||
/// 会话日志的**旧家**(`data/agent/sessions/`):只读兼容,M188 起不再往里写。
|
||||
pub fn legacy_sessions_root(&self) -> PathBuf {
|
||||
self.layout.agent_sessions()
|
||||
}
|
||||
|
||||
/// 启动器日志目录(`logs/`:`launcher.log` + `game/` 会话)。
|
||||
pub fn logs_dir(&self) -> PathBuf {
|
||||
self.layout.logs()
|
||||
}
|
||||
|
||||
/// 数据目录模式。
|
||||
pub fn mode(&self) -> DataMode {
|
||||
self.data.mode
|
||||
}
|
||||
|
||||
/// 人读一行(设置页「数据」分组展示)。
|
||||
pub fn data_line(&self) -> String {
|
||||
format!(
|
||||
"{} · 启动器 {} · 游戏 {}",
|
||||
self.data.mode,
|
||||
self.data.launcher_home.display(),
|
||||
self.data.game_home.display()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 便携目录那份 `launcher.json` → 数据根请求(只读 `core.*` 四键)。
|
||||
pub(crate) fn request_from_portable(exe_dir: &Path) -> DataDirRequest {
|
||||
let mut request = DataDirRequest::default();
|
||||
let path = portable_config_path(exe_dir);
|
||||
if !path.is_file() {
|
||||
return request;
|
||||
}
|
||||
let store = SettingStore::load(&path, launcher_schema());
|
||||
let text = |key: &str| store.doc().effective_str(key).map(str::to_string);
|
||||
if let Some(mode) = text(DATA_ROOT_KEYS[0]).as_deref().and_then(DataMode::parse) {
|
||||
request.mode = mode;
|
||||
}
|
||||
if let Some(root) = text(DATA_ROOT_KEYS[1]) {
|
||||
request.custom_root = Some(PathBuf::from(root));
|
||||
}
|
||||
if let Some(home) = text(DATA_ROOT_KEYS[2]) {
|
||||
request.launcher_home = Some(PathBuf::from(home));
|
||||
}
|
||||
if let Some(home) = text(DATA_ROOT_KEYS[3]) {
|
||||
request.game_home = Some(PathBuf::from(home));
|
||||
}
|
||||
request
|
||||
}
|
||||
|
||||
/// `<exe 目录>/.ULL/config/launcher.json`。
|
||||
pub(crate) fn portable_config_path(exe_dir: &Path) -> PathBuf {
|
||||
LauncherLayout::new(exe_dir.join(".ULL")).launcher_config()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_core::platform::paths::MapEnv;
|
||||
|
||||
#[test]
|
||||
fn at_root_splits_launcher_and_game_homes() {
|
||||
let root = std::env::temp_dir().join(format!("ull-host-env-root-{}", std::process::id()));
|
||||
let env = HostEnv::at_root(&root);
|
||||
assert_eq!(env.layout.home(), root.join(".ULL"));
|
||||
assert_eq!(env.game.root(), root.join(".minecraft"));
|
||||
assert_eq!(env.mode(), DataMode::Portable);
|
||||
assert!(env.data_line().contains("portable"));
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// M188 的守门②:**日志根随数据模式变化**(便携 / 标准 / 自定义各一条断言)。
|
||||
///
|
||||
/// 三条走的都是 `HostEnv` 真正用的那条解析链(`Resolver` → `ResolvedData::layout`):
|
||||
///
|
||||
/// | 模式 | 期望的 `<home>` | 期望的日志根 |
|
||||
/// |---|---|---|
|
||||
/// | portable | `<exe>/.ULL` | `<exe>/.ULL/logs` |
|
||||
/// | standard | `%APPDATA%/ULL` | `%APPDATA%/ULL/logs` |
|
||||
/// | custom | `<root>/.ULL` | `<root>/.ULL/logs` |
|
||||
///
|
||||
/// 这里只碰解析结果、不建 `HostEnv`:`from_data` 会真的去登记日志目录(那是产品行为,
|
||||
/// 单测里拿假绝对路径调它会在盘根建目录)。
|
||||
/// 变异证明:把 `LauncherLayout::logs` 改成返回 `cache/`,三条全红。
|
||||
#[test]
|
||||
fn logs_root_follows_the_three_data_modes() {
|
||||
let env = MapEnv::new().with("APPDATA", "/roaming");
|
||||
let exe_dir = PathBuf::from("/games/ULL");
|
||||
// custom 模式的根必须是**绝对路径**(Windows 上 `/custom-root` 不算绝对,要带盘符)。
|
||||
let custom_root = std::env::temp_dir().join("ull-m188-custom");
|
||||
let resolver = Resolver::with_env(Platform::current(), Some(exe_dir.clone()), &env);
|
||||
|
||||
let cases = [
|
||||
(
|
||||
DataDirRequest {
|
||||
mode: DataMode::Portable,
|
||||
..DataDirRequest::default()
|
||||
},
|
||||
exe_dir.join(".ULL/logs"),
|
||||
),
|
||||
(
|
||||
DataDirRequest {
|
||||
mode: DataMode::Standard,
|
||||
..DataDirRequest::default()
|
||||
},
|
||||
PathBuf::new(),
|
||||
),
|
||||
(
|
||||
DataDirRequest {
|
||||
mode: DataMode::Custom,
|
||||
custom_root: Some(custom_root.clone()),
|
||||
..DataDirRequest::default()
|
||||
},
|
||||
custom_root.join(".ULL/logs"),
|
||||
),
|
||||
];
|
||||
|
||||
for (request, expected) in cases {
|
||||
let Ok(data) = resolver.resolve(&request) else {
|
||||
// 标准模式在某些平台拿不到(没有 HOME/APPDATA):这一档只能跳过,不许假绿。
|
||||
assert_eq!(request.mode, DataMode::Standard, "{request:?}");
|
||||
continue;
|
||||
};
|
||||
let home = data.launcher_home.clone();
|
||||
let layout = data.layout();
|
||||
assert_eq!(layout.logs(), home.join("logs"), "{request:?}");
|
||||
assert_eq!(layout.sessions_root(), home.join("logs"), "{request:?}");
|
||||
assert_eq!(layout.game_logs(), home.join("logs/game"), "{request:?}");
|
||||
assert_eq!(
|
||||
layout.agent_sessions(),
|
||||
home.join("data/agent/sessions"),
|
||||
"{request:?}"
|
||||
);
|
||||
if !expected.as_os_str().is_empty() {
|
||||
assert_eq!(layout.logs(), expected, "{request:?}");
|
||||
}
|
||||
// 自定义模式的根在临时目录里,顺手把 `HostEnv` 那三个访问器也钉一遍——
|
||||
// 「layout 对了但 `HostEnv::logs_dir` 写死」这种假绿只有这一段抓得住。
|
||||
if request.mode == DataMode::Custom {
|
||||
let env = HostEnv::from_data(data);
|
||||
assert_eq!(env.logs_dir(), home.join("logs"), "{request:?}");
|
||||
assert_eq!(env.sessions_root(), home.join("logs"), "{request:?}");
|
||||
assert_eq!(
|
||||
env.legacy_sessions_root(),
|
||||
home.join("data/agent/sessions"),
|
||||
"{request:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 三种模式的日志根**各不相同**(防止「都指向同一个写死的目录」这种假绿)。
|
||||
#[test]
|
||||
fn the_three_modes_do_not_share_one_logs_root() {
|
||||
let env = MapEnv::new().with("APPDATA", "/roaming");
|
||||
let resolver =
|
||||
Resolver::with_env(Platform::current(), Some(PathBuf::from("/games/ULL")), &env);
|
||||
let portable = resolver
|
||||
.resolve(&DataDirRequest {
|
||||
mode: DataMode::Portable,
|
||||
..DataDirRequest::default()
|
||||
})
|
||||
.expect("portable 一定解析得出");
|
||||
let custom = resolver
|
||||
.resolve(&DataDirRequest {
|
||||
mode: DataMode::Custom,
|
||||
custom_root: Some(std::env::temp_dir().join("ull-m188-custom")),
|
||||
..DataDirRequest::default()
|
||||
})
|
||||
.expect("custom 有绝对路径");
|
||||
assert_ne!(portable.layout().logs(), custom.layout().logs());
|
||||
}
|
||||
|
||||
/// `HostEnv` 自己的三格(日志根 / 会话根 / 旧会话根)指着同一份解析结果。
|
||||
#[test]
|
||||
fn env_paths_come_from_one_layout() {
|
||||
let root = std::env::temp_dir().join(format!("ull-host-env-paths-{}", std::process::id()));
|
||||
let env = HostEnv::at_root(&root);
|
||||
assert_eq!(env.logs_dir(), root.join(".ULL/logs"));
|
||||
assert_eq!(env.sessions_root(), root.join(".ULL/logs"));
|
||||
assert_eq!(
|
||||
env.legacy_sessions_root(),
|
||||
root.join(".ULL/data/agent/sessions")
|
||||
);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_request_reads_core_keys_only_when_file_exists() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-env-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join(".ULL/config")).expect("建目录");
|
||||
let request = request_from_portable(&dir);
|
||||
assert_eq!(request, DataDirRequest::default());
|
||||
|
||||
std::fs::write(
|
||||
portable_config_path(&dir),
|
||||
r#"{"core":{"data_mode":"custom","data_custom_root":"D:/data/ull"}}"#,
|
||||
)
|
||||
.expect("写配置");
|
||||
let request = request_from_portable(&dir);
|
||||
assert_eq!(request.mode, DataMode::Custom);
|
||||
assert_eq!(
|
||||
request.custom_root.as_deref(),
|
||||
Some(Path::new("D:/data/ull"))
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! 实例快照:`profiles.json` + 版本 JSON + 会话索引 + 实例设置 → [`InstanceSummary`]。
|
||||
//!
|
||||
//! 「磁贴上的每个字」都在这里翻译完:版本号取自继承链根、加载器取自版本 id(解析在
|
||||
//! [`unlucky_core::game::loader`],本文件只做 DTO 翻译)、最后游玩时间取自会话索引、
|
||||
//! 隔离档位取自实例设置(缺省跟全局 `instances.mode`)。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use unlucky_core::game::install::instance::{
|
||||
read_instance_config, read_profiles, write_instance_config,
|
||||
};
|
||||
use unlucky_core::game::isolation::IsolationMode;
|
||||
use unlucky_core::game::{ProfileList, VersionJson};
|
||||
|
||||
use crate::dto::{
|
||||
InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, NoticeLevel, NoticeView,
|
||||
};
|
||||
|
||||
use super::env::HostEnv;
|
||||
|
||||
/// 全局默认隔离档位(`instances.mode`;缺键/坏值按 D20 默认 `isolated`)。
|
||||
pub fn global_mode(env: &HostEnv) -> IsolationMode {
|
||||
env.store()
|
||||
.doc()
|
||||
.effective_str("instances.mode")
|
||||
.and_then(IsolationMode::parse)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 读实例清单(文件缺失 = 空列表,不是错误)。
|
||||
pub fn profiles(env: &HostEnv) -> ProfileList {
|
||||
read_profiles(&env.layout).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 全会话索引里「每个实例最近一次启动」的时间串(`logs/` + 旧家合并)。
|
||||
pub fn last_played(env: &HostEnv) -> BTreeMap<String, String> {
|
||||
let entries = super::logs::entries(env);
|
||||
let mut map: BTreeMap<String, String> = BTreeMap::new();
|
||||
for entry in entries {
|
||||
if entry.instance_id.is_empty() || entry.started_at.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let current = map.get(&entry.instance_id);
|
||||
if current.is_none_or(|existing| existing.as_str() < entry.started_at.as_str()) {
|
||||
map.insert(entry.instance_id.clone(), entry.started_at);
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// 版本 JSON 的继承链根 id + 加载器(读不到返回 `(version_id, 原版)`)。
|
||||
pub fn describe_version(env: &HostEnv, version_id: &str) -> (String, LoaderInfo) {
|
||||
let loader = loader_from_id(version_id);
|
||||
let mut current = version_id.to_string();
|
||||
let mut seen = 0;
|
||||
while seen < 16 {
|
||||
seen += 1;
|
||||
let path = env.game.version_json(¤t);
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
break;
|
||||
};
|
||||
let Ok(json) = VersionJson::from_json_str(&text) else {
|
||||
break;
|
||||
};
|
||||
match json.inherits_from {
|
||||
Some(parent) if !parent.is_empty() => current = parent,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
(current, loader)
|
||||
}
|
||||
|
||||
/// 版本 id → 加载器。
|
||||
///
|
||||
/// 解析本身**不在这里**:真相源是 [`unlucky_core::game::loader`](生成端
|
||||
/// `addon::loader::LoaderKind::version_id` 拼的是 `<mc>-<kind>-<loader>`,如
|
||||
/// `1.20.4-fabric-0.16.5`)。本函数只做两件事:调 core 的解析、把 core 的种类翻成 DTO
|
||||
/// 种类(`dto` 里不许出现 core 类型,翻译只能发生在 host 这条边界上)。
|
||||
pub fn loader_from_id(version_id: &str) -> LoaderInfo {
|
||||
let parsed = unlucky_core::game::InstanceLoader::parse(version_id);
|
||||
LoaderInfo {
|
||||
kind: dto_kind(parsed.kind),
|
||||
version: parsed.version,
|
||||
}
|
||||
}
|
||||
|
||||
/// core 的加载器种类 → DTO 种类。
|
||||
///
|
||||
/// **这里没有映射表**:种类由 core 的稳定 id 反查 DTO 词表得来([`LoaderKind::parse`] →
|
||||
/// `dto::LoaderKind::from_core`,全仓唯一那份 core↔DTO 映射)。M134 之前这里是七支手写 match,
|
||||
/// M126 r3 复审实测过:core 加第八家、契约与 help 都补齐、**只在这一支写 `=> Vanilla`**,三个
|
||||
/// crate 全绿(402 / 220 / 42)而磁贴静默显示「原版」——M122 修掉的用户可见 bug 复活。委派之后
|
||||
/// **没有落笔处**:新种类必须先过 `dto::LoaderKind::from_core`(那边编不过就说明 DTO 还没决定怎么
|
||||
/// 显示它),糊错的后果由 `dto::instances` 的 `loader_kind_ids_are_the_released_contract` 与本
|
||||
/// 文件的 `every_core_loader_kind_reaches_the_tile_as_itself` 钉住。反查不到只能是 core 的
|
||||
/// `as_str` 与 `parse` 自相矛盾(同一张宏表长出来,物理上做不到),真发生了就 panic:**不许**
|
||||
/// 退回「原版」。
|
||||
fn dto_kind(kind: unlucky_core::game::LoaderKind) -> LoaderKind {
|
||||
LoaderKind::parse(kind.as_str()).unwrap_or_else(|| {
|
||||
unreachable!(
|
||||
"core 的稳定 id `{}` 反查不到 DTO 种类:词表两边脱节了",
|
||||
kind.as_str()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// 实例的隔离档位(实例文件优先,缺省显示全局档位)。
|
||||
pub fn isolation_of(env: &HostEnv, id: &str) -> String {
|
||||
let global = global_mode(env);
|
||||
match read_instance_config(&env.layout, id) {
|
||||
Ok(Some(config)) => config.effective_isolation(global).as_str().to_string(),
|
||||
_ => global.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 实例快照。
|
||||
pub fn instances(env: &HostEnv) -> Vec<InstanceSummary> {
|
||||
let profiles = profiles(env);
|
||||
let played = last_played(env);
|
||||
profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.map(|profile| {
|
||||
let version_id = profile.version_id.clone();
|
||||
let (mc_version, loader) = describe_version(env, &version_id);
|
||||
let id = profile.id.as_str();
|
||||
InstanceSummary {
|
||||
id: id.to_string(),
|
||||
name: profile.name.clone(),
|
||||
mc_version,
|
||||
loader,
|
||||
last_played: played.get(id).cloned(),
|
||||
runtime: InstanceRuntime::Idle,
|
||||
icon: None,
|
||||
isolation: isolation_of(env, id),
|
||||
version_present: env.game.is_version_installed(&version_id),
|
||||
launch: super::launch_args::view(env, id, &version_id),
|
||||
version_id,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 改实例隔离档位(写实例设置文件;不动任何数据)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 实例不存在、文件读不了/写不了时返回用户可读文案。
|
||||
pub fn set_isolation(env: &HostEnv, id: &str, mode: &str) -> Result<(), String> {
|
||||
let parsed = IsolationMode::parse(mode)
|
||||
.ok_or_else(|| format!("未知隔离档位 `{mode}`(isolated / shared)"))?;
|
||||
let profiles = profiles(env);
|
||||
let profile = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.id.as_str() == id)
|
||||
.ok_or_else(|| format!("找不到实例 `{id}`"))?;
|
||||
let mut config = read_instance_config(&env.layout, id)
|
||||
.map_err(|error| format!("读实例设置失败:{error}"))?
|
||||
.unwrap_or_else(|| {
|
||||
unlucky_core::game::install::instance::InstanceConfig::new(
|
||||
profile.id.as_str(),
|
||||
&profile.version_id,
|
||||
)
|
||||
});
|
||||
config.isolation = Some(match parsed {
|
||||
IsolationMode::Isolated => unlucky_core::game::install::instance::Isolation::Isolated,
|
||||
IsolationMode::Shared => unlucky_core::game::install::instance::Isolation::Shared,
|
||||
});
|
||||
write_instance_config(&env.layout, &config)
|
||||
.map(|_| ())
|
||||
.map_err(|error| format!("写实例设置失败:{error}"))
|
||||
}
|
||||
|
||||
/// 启动前的切换检测:把 M30 的 `ModeChangeWarning` 翻成横幅通知。
|
||||
///
|
||||
/// 只在真启动时调用(会写 `lastModeChange.warned`,保证同一次切换只提示一次)。
|
||||
pub fn mode_change_notice(env: &HostEnv, id: &str) -> Option<NoticeView> {
|
||||
let mut config = read_instance_config(&env.layout, id).ok()??;
|
||||
let warning = config.take_mode_change_warning()?;
|
||||
let _ = write_instance_config(&env.layout, &config);
|
||||
Some(NoticeView::new(
|
||||
&format!("isolation:{id}"),
|
||||
NoticeLevel::Warn,
|
||||
"隔离档位已切换",
|
||||
&warning.message(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 建目录(数据根首次运行时)。
|
||||
pub fn ensure_root(env: &HostEnv) {
|
||||
let _ = env.layout.ensure();
|
||||
let _ = std::fs::create_dir_all(env.game.root());
|
||||
}
|
||||
|
||||
/// 版本本体目录是否存在(启动前快速判断)。
|
||||
pub fn version_present(env: &HostEnv, version_id: &str) -> bool {
|
||||
env.game.is_version_installed(version_id)
|
||||
}
|
||||
|
||||
/// 路径显示成短串(通知/错误文案用)。
|
||||
pub fn short_path(path: &Path) -> String {
|
||||
path.display().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,189 @@
|
||||
use super::*;
|
||||
|
||||
/// 版本 id → DTO 加载器的接口面(解析在 core,这里只验翻译没走样)。
|
||||
///
|
||||
/// 抓得住:显示路径把某个种类翻错/翻塌(例如 Quilt 写成 Forge)——`dto_kind` 的委派与
|
||||
/// `dto` 的 `from_core` 都落在这一条上;以及 core 解析端退回老口径(`fabric-loader-…` 才认、
|
||||
/// 自家 `1.20.4-fabric-0.16.5` 认不出)——后两条正是 M122 的 bug 面。
|
||||
/// 抓不住:core 解析规则本身(真值表在 `unlucky_core::game::loader` 的测试里,那边才是
|
||||
/// 真相源);也抓不住「版本号为空时不显示品牌名」那条(在 `LoaderInfo::badge` 与
|
||||
/// app-iced 的 `loader_badge`,M120 已修,别在这里重复)。
|
||||
#[test]
|
||||
fn loader_parsing_covers_all_shapes() {
|
||||
let cases = [
|
||||
("1.20.4", LoaderKind::Vanilla, ""),
|
||||
// 自家口径(生成端 `LoaderKind::version_id`):`<mc>-<kind>-<loader>`
|
||||
("1.20.4-fabric-0.16.5", LoaderKind::Fabric, "0.16.5"),
|
||||
("1.20.4-quilt-0.27.0", LoaderKind::Quilt, "0.27.0"),
|
||||
("1.20.1-forge-47.2.0", LoaderKind::Forge, "47.2.0"),
|
||||
("1.21.1-neoforge-21.1.80", LoaderKind::NeoForge, "21.1.80"),
|
||||
// 老 HMCL 口径(存量实例还会带着它们)
|
||||
("fabric-loader-0.16.5-1.20.1", LoaderKind::Fabric, "0.16.5"),
|
||||
("quilt-loader-0.24.0-1.20.1", LoaderKind::Quilt, "0.24.0"),
|
||||
// 第三方口径
|
||||
("1.13.2-OptiFine_HD_U_E6", LoaderKind::OptiFine, "HD_U_E6"),
|
||||
("1.12.2-LiteLoader", LoaderKind::LiteLoader, ""),
|
||||
];
|
||||
for (id, kind, version) in cases {
|
||||
let info = loader_from_id(id);
|
||||
assert_eq!(info.kind, kind, "{id}");
|
||||
assert_eq!(info.version, version, "{id}");
|
||||
assert!(!info.badge().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示路径的**全量**锚:core 的每一种加载器,从自家版本 id 走到磁贴时仍然是**它自己**。
|
||||
///
|
||||
/// 清单取自 [`unlucky_core::game::LoaderKind::ALL`],**不手抄**:M126 r3 复审实测出的缺口正是
|
||||
/// 「手抄的清单跟着漏」——core 加第八家、契约与 help 都补齐、只在 `dto_kind` 那一支写
|
||||
/// `=> Vanilla`,三个 crate 全绿而磁贴静默显示「原版」。`ALL` 由 core 的宏表长出来,加一家
|
||||
/// 自动进圈,测试作者不需要(也没法)记得补一行。
|
||||
///
|
||||
/// 抓得住:显示路径任何一环把种类翻塌成原版——`dto_kind` 又变回手写映射、`dto` 的
|
||||
/// `from_core` 那一支接错、`label()` 被写成「原版」;**包括新加的那种**(那时 `ALL` 先变长)。
|
||||
/// 抓不住:core 的 id 解析规则本身(真值表在 `unlucky_core::game::loader`);版本号为空的
|
||||
/// 徽标判据(M120 的 `loader_badge`,本 crate 不渲染)。
|
||||
#[test]
|
||||
fn every_core_loader_kind_reaches_the_tile_as_itself() {
|
||||
use unlucky_core::game::LoaderKind as Core;
|
||||
for kind in Core::ALL {
|
||||
let info = loader_from_id(&format!("1.20.4-{}-0.16.5", kind.as_str()));
|
||||
assert_eq!(
|
||||
info.kind.as_str(),
|
||||
kind.as_str(),
|
||||
"core 的 {kind:?} 走到磁贴时变成了 `{}`(徽标 `{}`)",
|
||||
info.kind.as_str(),
|
||||
info.badge()
|
||||
);
|
||||
assert_eq!(
|
||||
info.badge().starts_with(LoaderKind::Vanilla.label()),
|
||||
kind == Core::Vanilla,
|
||||
"core 的 {kind:?} 在磁贴上的徽标是 `{}`",
|
||||
info.badge()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 端到端(数据层):真装出来的四个加载器实例,走 [`instances`] 的快照各自带对加载器。
|
||||
///
|
||||
/// 造的是本启动器**真实产物**的形状:`profiles.json` 里 `versionId` 是自家 id,
|
||||
/// `versions/<id>/<id>.json` 里 `inheritsFrom` 指向原版层(生成端见
|
||||
/// `unlucky_core::addon::loader`)。原版版本号由此取自继承链根,不是从 id 里切的。
|
||||
///
|
||||
/// 抓得住:`describe_version` ↔ `loader_from_id` ↔ `instances` 这条链上任何一环把加载器
|
||||
/// 丢掉(磁贴会显示「原版」——M122 的用户可见症状);以及继承链根取错(磁贴的版本号)。
|
||||
/// 抓不住:实例页真出图(走 app-iced 的视图模型与 `loader_badge`,本 crate 不渲染);
|
||||
/// 也抓不住「真下载安装了 Fabric/Quilt/Forge/NeoForge」——这里只伪造落盘结果。
|
||||
#[test]
|
||||
fn own_installed_instances_snapshot_carries_their_loader() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-game-own-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
|
||||
|
||||
let cases = [
|
||||
(
|
||||
"1.20.4-fabric-0.16.5",
|
||||
"1.20.4",
|
||||
LoaderKind::Fabric,
|
||||
"Fabric 0.16.5",
|
||||
),
|
||||
(
|
||||
"1.20.4-quilt-0.27.0",
|
||||
"1.20.4",
|
||||
LoaderKind::Quilt,
|
||||
"Quilt 0.27.0",
|
||||
),
|
||||
(
|
||||
"1.20.1-forge-47.2.0",
|
||||
"1.20.1",
|
||||
LoaderKind::Forge,
|
||||
"Forge 47.2.0",
|
||||
),
|
||||
(
|
||||
"1.21.1-neoforge-21.1.80",
|
||||
"1.21.1",
|
||||
LoaderKind::NeoForge,
|
||||
"NeoForge 21.1.80",
|
||||
),
|
||||
];
|
||||
for (version_id, mc_version, _, _) in cases {
|
||||
for (id, inherits) in [(mc_version, None), (version_id, Some(mc_version))] {
|
||||
let json = match inherits {
|
||||
Some(parent) => format!(r#"{{"id":"{id}","inheritsFrom":"{parent}"}}"#),
|
||||
None => format!(r#"{{"id":"{id}"}}"#),
|
||||
};
|
||||
let path = env.game.version_json(id);
|
||||
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
|
||||
std::fs::write(&path, json).expect("写版本 JSON");
|
||||
}
|
||||
}
|
||||
|
||||
let profiles = ProfileList {
|
||||
profiles: cases
|
||||
.iter()
|
||||
.map(
|
||||
|(version_id, _, _, _)| unlucky_core::game::InstanceProfile {
|
||||
id: unlucky_core::game::ProfileId::generate(),
|
||||
name: format!("装好的 {version_id}"),
|
||||
version_id: (*version_id).to_string(),
|
||||
game_dir: None,
|
||||
running_dir: None,
|
||||
java_path: None,
|
||||
created_at: None,
|
||||
last_played_at: None,
|
||||
extra: BTreeMap::new(),
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
..ProfileList::default()
|
||||
};
|
||||
unlucky_core::game::install::instance::write_profiles(&env.layout, &profiles).expect("写清单");
|
||||
|
||||
let snapshot = instances(&env);
|
||||
assert_eq!(snapshot.len(), cases.len());
|
||||
for ((version_id, mc_version, kind, badge), tile) in cases.iter().zip(&snapshot) {
|
||||
assert_eq!(tile.version_id.as_str(), *version_id);
|
||||
assert_eq!(
|
||||
tile.mc_version.as_str(),
|
||||
*mc_version,
|
||||
"{version_id} 的版本号取错"
|
||||
);
|
||||
assert_eq!(tile.loader.kind, *kind, "{version_id} 的加载器");
|
||||
assert_eq!(tile.loader.badge().as_str(), *badge, "{version_id} 的徽标");
|
||||
assert!(tile.version_present, "{version_id} 的载荷应当存在");
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolation_write_then_read_round_trips() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-game-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
|
||||
|
||||
// 直接建一份 profiles.json(register 需要版本本体,这里只测隔离写读)
|
||||
let profiles = ProfileList {
|
||||
profiles: vec![unlucky_core::game::InstanceProfile {
|
||||
id: unlucky_core::game::ProfileId::generate(),
|
||||
name: "demo".to_string(),
|
||||
version_id: "1.20.1".to_string(),
|
||||
game_dir: None,
|
||||
running_dir: None,
|
||||
java_path: None,
|
||||
created_at: None,
|
||||
last_played_at: None,
|
||||
extra: BTreeMap::new(),
|
||||
}],
|
||||
..ProfileList::default()
|
||||
};
|
||||
unlucky_core::game::install::instance::write_profiles(&env.layout, &profiles).expect("写清单");
|
||||
let id = profiles.profiles[0].id.as_str().to_string();
|
||||
|
||||
assert_eq!(isolation_of(&env, &id), "isolated");
|
||||
set_isolation(&env, &id, "shared").expect("切档位");
|
||||
assert_eq!(isolation_of(&env, &id), "shared");
|
||||
assert!(set_isolation(&env, &id, "nope").is_err());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! `HostTask` → `CoreEvent` 的翻译(从 `host/mod.rs` 拆出,守 400 行硬限)。
|
||||
//!
|
||||
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。
|
||||
|
||||
use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LaunchFailureCause, LogOutcome, PackEvent};
|
||||
use crate::toast::ToastKind;
|
||||
|
||||
use super::{addon, logs, mods, CoreHost, HostTask, LiveLaunch};
|
||||
|
||||
impl CoreHost {
|
||||
/// 后台消息 → 事件。
|
||||
pub(super) fn handle(&mut self, task: HostTask) {
|
||||
match task {
|
||||
HostTask::Versions(Ok(versions)) => self.send(CoreEvent::VersionListChanged(versions)),
|
||||
HostTask::Versions(Err(reason)) => {
|
||||
self.toast(ToastKind::Warn, format!("拉取版本清单失败:{reason}"));
|
||||
}
|
||||
HostTask::LoaderVersions {
|
||||
kind,
|
||||
mc_version,
|
||||
result,
|
||||
} => {
|
||||
// 失败既要落进快照(页面禁用确认 + 显示具名原因),也要一条 Toast:
|
||||
// 用户可能正盯着上面的版本列表,下拉那一行的红字容易被忽略。
|
||||
if let Err(reason) = &result {
|
||||
self.toast(
|
||||
ToastKind::Warn,
|
||||
format!("取不到 {} 的加载器版本:{reason}", kind.label()),
|
||||
);
|
||||
}
|
||||
self.send(CoreEvent::LoaderVersions {
|
||||
kind,
|
||||
mc_version,
|
||||
result,
|
||||
});
|
||||
}
|
||||
// M109:内容库检索。`Err` 不弹 toast——页面自己有失败态(四态之一),
|
||||
// toast 只做「顺手提一句」会把可重试的错误变成一次性噪音。
|
||||
HostTask::Content(result) => match *result {
|
||||
Ok(results) => self.send(CoreEvent::ContentResults(Box::new(results))),
|
||||
Err(failure) => self.send(CoreEvent::ContentFailed(Box::new(failure))),
|
||||
},
|
||||
// M162:详情/版本列表(与检索同一套失败分类)
|
||||
HostTask::ContentDetail { project, result } => {
|
||||
self.send(CoreEvent::ContentDetail { project, result });
|
||||
}
|
||||
// M113: import/export —— 导入/导出事件带代次,取消后回来的旧事件一律丢掉
|
||||
HostTask::Pack { event, epoch } => {
|
||||
// pack 用**自己**的代次(复审 r1 P2-7):共用安装的计数器会让
|
||||
// 「关一次导入面板」把下载页正在跑的安装静默丢掉。
|
||||
if epoch != addon::pack_epoch(&self.env) {
|
||||
// 唯一例外(M195 r3):**刚被取消的那一次**交上来的终态报告。取消会把代次
|
||||
// +1,若连报告一起丢,界面就永远学不到"取消太晚、其实已经装完/回滚没做完"
|
||||
// 这个真相——只剩一句无条件的"已停手并回滚"。
|
||||
// 只放行 `Report`、且同一代次只放行一条;进度事件照旧按代次丢。
|
||||
if !(matches!(&*event, PackEvent::Report(_))
|
||||
&& addon::take_terminal_epoch(&self.env, epoch))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 导入成功可能登记了新实例:与 `InstallDone` 同一条刷新(复审 r1 P2-1),
|
||||
// 否则新实例要等下一次无关的 `RefreshAll` 才出现在实例页与目标 chips 里。
|
||||
if matches!(*event, PackEvent::Report(_)) {
|
||||
self.refresh_instances();
|
||||
}
|
||||
self.send(CoreEvent::Pack(event));
|
||||
}
|
||||
// M105 的形状(代次丢包)为准:旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉。
|
||||
// 这是「点了取消没反应」的根因:取消时 `InstallProgress(None)` 刚把面板收回,
|
||||
// 旧任务的下一条「下载中」事件立刻又把它点亮。
|
||||
HostTask::Adopt { event, epoch } => self.handle_adopt(*event, epoch),
|
||||
HostTask::Install { progress, epoch } => {
|
||||
if epoch != self.install_epoch {
|
||||
return;
|
||||
}
|
||||
self.install = Some(*progress.clone());
|
||||
self.send(CoreEvent::InstallProgress(Some(*progress)));
|
||||
}
|
||||
HostTask::InstallDone {
|
||||
progress,
|
||||
result,
|
||||
epoch,
|
||||
} => {
|
||||
// 收尾同样看代次:取消之后那次「已取消」的收尾不该再动面板(面板早就回弹了),
|
||||
// 也不该清掉**新一轮**安装的取消句柄。
|
||||
if epoch != self.install_epoch {
|
||||
return;
|
||||
}
|
||||
self.install_cancel = None;
|
||||
self.install = Some(*progress.clone());
|
||||
self.send(CoreEvent::InstallProgress(Some(*progress)));
|
||||
match result {
|
||||
Ok(_) => {
|
||||
self.refresh_instances();
|
||||
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
|
||||
}
|
||||
// 只把**一句人读原因**弹出去:大类已经随 `progress.failure_kind` 进了 DTO,
|
||||
// 面板/调用方按大类决定怎么画(离线 vs 查无此项)。
|
||||
Err(failure) => self.toast(ToastKind::Error, failure.detail),
|
||||
}
|
||||
}
|
||||
HostTask::Accounts(accounts) => {
|
||||
self.accounts = accounts.clone();
|
||||
self.send(CoreEvent::AccountsChanged(accounts));
|
||||
self.refresh_instances();
|
||||
}
|
||||
HostTask::Login(view) => self.send(CoreEvent::DeviceLoginChanged(*view)),
|
||||
HostTask::Authlib(view) => self.send(CoreEvent::AuthlibLoginChanged(*view)),
|
||||
HostTask::Avatar { id, skin } => {
|
||||
// 抓到/没抓到都记下来(`None` = 试过了,不再重试),再把列表盖好发出去
|
||||
self.skins.insert(id, skin);
|
||||
self.attach_skins();
|
||||
self.send(CoreEvent::AccountsChanged(self.accounts.clone()));
|
||||
}
|
||||
HostTask::Java(page) => self.send(CoreEvent::JavaChanged(*page)),
|
||||
HostTask::Logs {
|
||||
sessions,
|
||||
lines,
|
||||
diagnosis,
|
||||
} => {
|
||||
self.send(CoreEvent::LogSessionsChanged(sessions));
|
||||
self.send(CoreEvent::LogLinesChanged(lines));
|
||||
if let Some(report) = diagnosis {
|
||||
self.send(CoreEvent::DiagnosisReady(report));
|
||||
}
|
||||
}
|
||||
HostTask::Sessions(sessions) => self.send(CoreEvent::LogSessionsChanged(sessions)),
|
||||
HostTask::Tail(batch) => {
|
||||
if batch.running {
|
||||
self.send(CoreEvent::LogLinesChanged(batch.lines));
|
||||
} else {
|
||||
self.send(CoreEvent::LogAppended(*batch));
|
||||
}
|
||||
}
|
||||
HostTask::LaunchStarted {
|
||||
id,
|
||||
session_id,
|
||||
pid,
|
||||
} => {
|
||||
self.pending.remove(&id);
|
||||
self.failed.remove(&id);
|
||||
self.launches.retain(|live| live.instance != id);
|
||||
self.launches.push(LiveLaunch {
|
||||
instance: id.clone(),
|
||||
session: session_id.clone(),
|
||||
pid,
|
||||
});
|
||||
// 实时尾随一次只跟一场(下载页/日志页共用一个滚动窗口);
|
||||
// 已有别的实例在尾随时不抢占——它的日志仍在 JSONL 里,收尾后可回放。
|
||||
if self.tail.session().is_none() {
|
||||
self.tail.attach(
|
||||
self.env
|
||||
.layout
|
||||
.game_logs()
|
||||
.join(format!("{session_id}.jsonl")),
|
||||
&session_id,
|
||||
);
|
||||
}
|
||||
self.send(CoreEvent::InstanceRuntimeChanged {
|
||||
id,
|
||||
runtime: InstanceRuntime::Running { pid },
|
||||
});
|
||||
}
|
||||
HostTask::Runtime { id, runtime } => {
|
||||
self.send(CoreEvent::InstanceRuntimeChanged { id, runtime });
|
||||
}
|
||||
HostTask::LaunchEnd(finished) => {
|
||||
// 收尾可能发生在 LaunchStarted 之前(build_request / spawn 失败只发
|
||||
// LaunchEnd):pending 必须在这里也清掉,否则 in_flight 恒真、该实例
|
||||
// 本会话内再也起不来。
|
||||
self.pending.remove(&finished.id);
|
||||
self.launches.retain(|live| live.instance != finished.id);
|
||||
let runtime = if finished.outcome == LogOutcome::Ok {
|
||||
self.failed.remove(&finished.id);
|
||||
InstanceRuntime::Idle
|
||||
} else {
|
||||
// 记住失败:随后的 refresh_instances 会重建快照,
|
||||
// 不记的话磁贴的「失败/重试」外观会被 Idle 盖掉。
|
||||
//
|
||||
// 原因**按形状**组装(复审 P1-1):`failure` 有值 = 进程没起来(阶段失败);
|
||||
// 没值 = 跑起来了,那就必须看 `outcome`(崩溃 / 疑似静默失败),
|
||||
// 不许再拿「启动游戏进程失败」去顶——那句话在进程明明起来时说不过去。
|
||||
let cause = LaunchFailureCause::of(
|
||||
finished.outcome,
|
||||
finished.failure.as_ref(),
|
||||
finished.exit_code,
|
||||
);
|
||||
self.failed.insert(finished.id.clone(), cause.clone());
|
||||
InstanceRuntime::Failed { cause }
|
||||
};
|
||||
self.send(CoreEvent::InstanceRuntimeChanged {
|
||||
id: finished.id.clone(),
|
||||
runtime,
|
||||
});
|
||||
self.send(CoreEvent::LaunchFinished((*finished).clone()));
|
||||
// 只拆自己那场的尾随:并发时另一实例的实时日志不该被这次收尾掐掉。
|
||||
if self.tail.session() == Some(finished.session.as_str()) {
|
||||
self.tail.detach();
|
||||
}
|
||||
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
|
||||
self.refresh_instances();
|
||||
}
|
||||
HostTask::Crash(report) => {
|
||||
self.send(CoreEvent::DiagnosisReady(*report));
|
||||
}
|
||||
HostTask::Notice(notice) => self.push_notice(*notice),
|
||||
// M159:更新回报(检查结论 / 应用结局)→ 快照整体替换
|
||||
HostTask::Update(report) => {
|
||||
self.update.adopt(*report);
|
||||
self.publish_update();
|
||||
}
|
||||
// M224:本地 Mod 列表 / 启停。列表成功 ⇒ 整份快照替换;失败 ⇒ 具名原因给 app 求文案。
|
||||
// 启停**不做乐观回显**:开关跟着这一份快照翻(与启动/停止同一条纪律)。
|
||||
HostTask::Mods(report) => {
|
||||
let mods::Report {
|
||||
instance,
|
||||
list,
|
||||
toggled,
|
||||
} = *report;
|
||||
if let Some(toggled) = toggled {
|
||||
// 只留一行日志:开关自己会跟着快照翻,再弹 Toast 是噪音。
|
||||
eprintln!(
|
||||
"[mods] {instance}: {} → {} ({})",
|
||||
toggled.from,
|
||||
toggled.to,
|
||||
if toggled.enabled {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
}
|
||||
);
|
||||
}
|
||||
self.send(CoreEvent::Mods(Box::new(list)));
|
||||
}
|
||||
// M235:实例存档 / 截图的只读列表。成功 ⇒ 整份快照;失败 ⇒ 具名原因给 app 求文案。
|
||||
// 「打开文件夹」也是这条:拉起文件管理器之后再回一份新鲜快照(用户在那边删了世界,
|
||||
// 切回来这一拍就跟上)。
|
||||
HostTask::Saves(report) => {
|
||||
self.send(CoreEvent::Saves(Box::new(report.list)));
|
||||
}
|
||||
HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 尾随运行中会话(每 `TAIL_POLL_TICKS` 拍一次)。
|
||||
pub(super) fn poll_tail(&mut self) {
|
||||
let Some((session, lines)) = self.tail.poll() else {
|
||||
return;
|
||||
};
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
let running = self.launches.iter().any(|live| live.session == session);
|
||||
self.send(CoreEvent::LogAppended(crate::dto::LiveLogBatch {
|
||||
session,
|
||||
lines,
|
||||
running,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! 启动身份:选中账户 → `AuthPlaceholders`(**凭据只在这里读,读完即丢**)+ 收尾结局映射。
|
||||
//!
|
||||
//! 与 CLI `commands/identity.rs` 同源:微软轨过期就静默续期并回写凭据(refresh_token 轮换,
|
||||
//! 旧值作废);零账户回落离线占位,保证没有账户时照样能启动。
|
||||
|
||||
use unlucky_core::auth::account::{AccountKind, Credentials};
|
||||
use unlucky_core::auth::microsoft::MicrosoftAuth;
|
||||
use unlucky_core::auth::offline::offline_login;
|
||||
use unlucky_core::auth::AccountStore;
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::game::ProfileId;
|
||||
use unlucky_core::launch::request::AuthPlaceholders;
|
||||
|
||||
use crate::dto::{LaunchFailureStage, LaunchFailureView, LogOutcome};
|
||||
|
||||
use super::env::HostEnv;
|
||||
|
||||
/// 把选中账户翻成启动占位符(**凭据只在这里读,读完即丢**)。
|
||||
///
|
||||
/// 微软轨:过期就静默续期并回写凭据(refresh_token 轮换,旧值作废)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 账户不存在/凭据类型不符/令牌过期且刷新失败时返回用户可读文案。
|
||||
pub fn resolve(
|
||||
env: &HostEnv,
|
||||
account: Option<&str>,
|
||||
) -> Result<(AuthPlaceholders, String), LaunchFailureView> {
|
||||
let store = AccountStore::open(env.layout.auth_data());
|
||||
let chosen = match account {
|
||||
Some(key) => store
|
||||
.find(key)
|
||||
.cloned()
|
||||
.ok_or_else(|| identity_failure(format!("找不到账户 `{key}`")))?,
|
||||
None => match store.accounts() {
|
||||
[] => {
|
||||
let uuid = ProfileId::generate().as_str().replace('-', "");
|
||||
return Ok((
|
||||
AuthPlaceholders {
|
||||
player_name: "Player".to_string(),
|
||||
uuid,
|
||||
user_type: "legacy".to_string(),
|
||||
user_properties: "{}".to_string(),
|
||||
..AuthPlaceholders::default()
|
||||
},
|
||||
"未登录账户,按离线占位启动".to_string(),
|
||||
));
|
||||
}
|
||||
[only] => only.clone(),
|
||||
many => {
|
||||
return Err(identity_failure(format!(
|
||||
"有 {} 个账户,请在账户页选一个再启动",
|
||||
many.len()
|
||||
)))
|
||||
}
|
||||
},
|
||||
};
|
||||
let line = format!("账户 {}({})", chosen.display_name(), chosen.kind.label());
|
||||
match chosen.kind {
|
||||
AccountKind::Offline => {
|
||||
let placeholders = offline_login(&chosen)
|
||||
.map_err(|error| identity_failure(format!("离线账户解析失败:{error}")))?;
|
||||
Ok((placeholders, line))
|
||||
}
|
||||
AccountKind::Microsoft => {
|
||||
let Some(Credentials::Microsoft(session)) = store.credentials(&chosen.id) else {
|
||||
return Err(identity_failure(format!(
|
||||
"账户 {} 的微软凭据缺失或类型不符,请重新登录",
|
||||
chosen.display_name()
|
||||
)));
|
||||
};
|
||||
let auth = MicrosoftAuth::from_env()
|
||||
.map_err(|error| identity_failure(format!("微软登录不可用:{error}")))?;
|
||||
let (fresh, _) = block_on(auth.ensure_fresh(session))
|
||||
.map_err(|error| identity_failure(format!("微软令牌刷新失败:{error}")))?;
|
||||
if &fresh != session {
|
||||
// refresh_token 已轮换:立刻回写,旧值作废。
|
||||
let mut store = AccountStore::open(env.layout.auth_data());
|
||||
let _ = store.update_credentials(&chosen.id, Credentials::Microsoft(fresh.clone()));
|
||||
let _ = store.save();
|
||||
}
|
||||
let placeholders = fresh
|
||||
.to_placeholders()
|
||||
.map_err(|error| identity_failure(format!("微软身份转换失败:{error}")))?;
|
||||
Ok((placeholders, line))
|
||||
}
|
||||
AccountKind::AuthlibInjector => {
|
||||
let Some(Credentials::AuthlibInjector(session)) = store.credentials(&chosen.id) else {
|
||||
return Err(identity_failure(format!(
|
||||
"账户 {} 的第三方凭据缺失或类型不符",
|
||||
chosen.display_name()
|
||||
)));
|
||||
};
|
||||
let placeholders = session
|
||||
.to_placeholders()
|
||||
.map_err(|error| identity_failure(format!("第三方身份转换失败:{error}")))?;
|
||||
Ok((placeholders, line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 账户/身份这一步的失败(阶段固定 `Identity`;`detail` 保留原始文案与本机错误)。
|
||||
fn identity_failure(detail: String) -> LaunchFailureView {
|
||||
LaunchFailureView::new(LaunchFailureStage::Identity, detail)
|
||||
}
|
||||
|
||||
/// `SessionHandle` → 日志页的三种结局。
|
||||
pub fn outcome_of(handle: &unlucky_core::launch::SessionHandle) -> LogOutcome {
|
||||
if handle.exit.silent_failure.is_some() {
|
||||
LogOutcome::SilentFail
|
||||
} else if handle.succeeded() {
|
||||
LogOutcome::Ok
|
||||
} else {
|
||||
LogOutcome::Crashed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! 安装:新建实例向导 / 下载页「安装」→ core 安装管线 → 登记实例。
|
||||
//!
|
||||
//! 与 CLI `ull install` 同序:`RemoteSource::resolve → InstallPlan::build → execute → register`。
|
||||
//! Forge/NeoForge 多一段 installer processors([`super::install_processors`]):
|
||||
//! `ProcessorPlan::build → 依赖库下载 → 选 Java → 串行 java -cp`,**跑完才写版本 JSON**。
|
||||
//! 差异只在线程模型:进度经 [`super::job::HostTask::Install`] 回流(文件计数 + 字节 + 速度/ETA),
|
||||
//! 且整段跑在后台线程里(`block_on` 不占 UI 线程)。
|
||||
//!
|
||||
//! 加载器范围:原版 + Fabric/Quilt/Forge/NeoForge/LiteLoader(五家,与 CLI 对齐)。OptiFine
|
||||
//! 仍没有安装管线(要跑外部 `optifine.Patcher` + 原地改 zip),在 GUI 里显式拒装并说清去哪装。
|
||||
//! 失败一律落 `.ULL/logs/install.log`([`super::install_journal`]),文案里带日志路径。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_core::game::install::{InstallError, InstallReport};
|
||||
|
||||
use crate::dto::{CreateRequest, InstallStage, LoaderKind};
|
||||
use crate::toast::{Toast, ToastKind};
|
||||
|
||||
use super::env::HostEnv;
|
||||
use super::install_journal::Journal;
|
||||
use super::install_processors;
|
||||
use super::job::{spawn, HostTask, JobSender};
|
||||
use super::meter::Meter;
|
||||
|
||||
mod routes;
|
||||
|
||||
use routes::{install_loader, install_vanilla};
|
||||
|
||||
/// 文件级并发上限(与 CLI 同值:16 路压满带宽又不至于打爆镜像)。
|
||||
pub const CONCURRENCY: usize = 16;
|
||||
|
||||
/// 用户取消的稳定文案。
|
||||
///
|
||||
/// 三件事一句话说清(D21 的口径:半成品不许冒充成品,断点材料不留在半截状态):
|
||||
/// ① 怎么停的(`execute_with_cancel` 不再起新文件,在飞的那个就地掐断);
|
||||
/// ② 什么被留着(**已经下完**的文件,重装续用);
|
||||
/// ③ 什么一定没发生(实例未登记、版本清单没落盘;在飞文件的 `.ullpart` 由 `PartFile::Drop` 清掉)。
|
||||
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<'_> {
|
||||
/// 把 core 的运行期告警与「清单被替换」写进安装日志(**M216 P2-2**)。
|
||||
///
|
||||
/// 面板只有一行、Toast 4 秒就消失,安装日志是用户事后能自己回看的唯一渠道:动了共享
|
||||
/// `.minecraft` 里那份 `versions/<id>/<id>.json` 这件事必须先落在这里(issue #M216 复审
|
||||
/// P2-2:`report.manifest` 以前零消费者,GUI 侧对用户完全静默)。
|
||||
fn journal_report(&self, report: &InstallReport) {
|
||||
for warning in &report.warnings {
|
||||
self.journal.line(warning);
|
||||
}
|
||||
if let Some(backup) = report.manifest.as_ref().and_then(|write| write.backup()) {
|
||||
self.journal.line(&format!(
|
||||
"版本清单已替换,旧的那份备份在 {}",
|
||||
backup.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// processor 段的进度出口(同一套 channel/meter/journal)。
|
||||
fn reporter(&self) -> install_processors::Reporter<'_> {
|
||||
install_processors::Reporter {
|
||||
tx: self.tx,
|
||||
meter: self.meter,
|
||||
journal: self.journal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器徽标(`原版` / `Fabric 0.16.5`)。
|
||||
pub fn loader_label(kind: LoaderKind, version: &str) -> String {
|
||||
if kind == LoaderKind::Vanilla || version.trim().is_empty() {
|
||||
kind.label().to_string()
|
||||
} else {
|
||||
format!("{} {}", kind.label(), version)
|
||||
}
|
||||
}
|
||||
|
||||
/// GUI 侧支持的加载器(六选:原版 + 五家加载器,与 CLI 的 `--loader` 词表一致)。
|
||||
///
|
||||
/// 写成穷举 `matches!` 而不是 `true`:将来 core 加了第六种加载器,这里默认拒绝,
|
||||
/// 由 [`unsupported_reason`] 给出明确说法,而不是让它走进不属于它的安装分支。
|
||||
pub fn supported(kind: LoaderKind) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
LoaderKind::Vanilla
|
||||
| LoaderKind::Fabric
|
||||
| LoaderKind::Quilt
|
||||
| LoaderKind::Forge
|
||||
| LoaderKind::NeoForge
|
||||
| LoaderKind::LiteLoader
|
||||
)
|
||||
}
|
||||
|
||||
/// 不支持的加载器 → 给用户的可执行建议。
|
||||
pub fn unsupported_reason(kind: LoaderKind) -> String {
|
||||
format!(
|
||||
"{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge/LiteLoader);如果别的启动器已经装好了这个版本,可以在实例页用「导入已有版本」把它只读接进来(不复制文件),否则请换成受支持的加载器。",
|
||||
kind.label()
|
||||
)
|
||||
}
|
||||
|
||||
/// 后台安装(向导提交 / 下载页行内安装共用)。
|
||||
///
|
||||
/// `epoch` 是本次任务的代次([`super::CoreHost`] 的 `install_epoch`):它随每条进度回流,
|
||||
/// 让 host 丢掉「已被取消 / 被下一轮顶掉」的旧任务的后续事件。
|
||||
pub fn spawn_install(
|
||||
env: HostEnv,
|
||||
tx: JobSender,
|
||||
request: CreateRequest,
|
||||
cancel: Arc<AtomicBool>,
|
||||
epoch: u64,
|
||||
) {
|
||||
spawn("install", move || {
|
||||
let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request, epoch)));
|
||||
let journal = Journal::open(&env);
|
||||
journal.line(&format!(
|
||||
"—— 开始安装 {}({} {})——",
|
||||
request.instance_name(),
|
||||
loader_label(request.loader, ""),
|
||||
request.version
|
||||
));
|
||||
let job = Job {
|
||||
env: &env,
|
||||
tx: &tx,
|
||||
meter: &meter,
|
||||
cancel: &cancel,
|
||||
journal: &journal,
|
||||
};
|
||||
let result = run(&job, &request);
|
||||
let mut progress = meter
|
||||
.lock()
|
||||
.map(|guard| guard.progress.clone())
|
||||
.unwrap_or_else(|_| Meter::new(&request, epoch).progress);
|
||||
match result {
|
||||
Ok(name) => {
|
||||
journal.line(&format!("完成:实例 {name} 已登记"));
|
||||
progress.stage = InstallStage::Done;
|
||||
progress.files_done = progress.files_total;
|
||||
progress.message = format!("已安装 {name}({})", progress.loader);
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Ok(name),
|
||||
epoch,
|
||||
});
|
||||
let _ = tx.send(HostTask::Toast(Box::new(Toast {
|
||||
id: 0,
|
||||
kind: ToastKind::Success,
|
||||
text: "安装完成,实例已登记".to_string(),
|
||||
remaining_ms: 0,
|
||||
})));
|
||||
}
|
||||
Err(reason) => {
|
||||
let cancelled = reason == CANCELLED;
|
||||
progress.stage = if cancelled {
|
||||
InstallStage::Cancelled
|
||||
} else {
|
||||
InstallStage::Failed
|
||||
};
|
||||
// 失败文案带上日志路径:面板只有一行、Toast 4 秒就消失,
|
||||
// 装日志是用户事后能自己回看的唯一渠道(进度板之外)。
|
||||
let text = if cancelled {
|
||||
journal.line(CANCELLED);
|
||||
reason
|
||||
} else {
|
||||
journal.line(&format!("安装未完成:{reason}"));
|
||||
format!("{reason}(日志:{})", journal.path().display())
|
||||
};
|
||||
progress.message.clone_from(&text);
|
||||
// 版本安装这条路的失败不是内容库的分类(它是本地/运行期错误),归到 Other:
|
||||
// 大类字段仍然要填,面板才不会把「装坏了」误画成「没联网」。
|
||||
progress.failure_kind = Some(crate::dto::ContentFailureKind::Other);
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Err(crate::dto::ContentFailure::new(
|
||||
crate::dto::ContentFailureKind::Other,
|
||||
text,
|
||||
)),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 真安装(同步跑完,调用方在后台线程里)。
|
||||
fn run(job: &Job<'_>, request: &CreateRequest) -> Result<String, String> {
|
||||
if request.version.trim().is_empty() {
|
||||
return Err("没有选择版本".to_string());
|
||||
}
|
||||
if !supported(request.loader) {
|
||||
return Err(unsupported_reason(request.loader));
|
||||
}
|
||||
let name = request.instance_name();
|
||||
let env = job.env;
|
||||
let engine = super::engine::engine(env);
|
||||
{
|
||||
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||
guard.stage(InstallStage::Planning, "正在解析版本清单…");
|
||||
let _ = job.tx.send(HostTask::Install {
|
||||
progress: Box::new(guard.progress.clone()),
|
||||
epoch: guard.epoch,
|
||||
});
|
||||
}
|
||||
|
||||
// 段边界检查(M105):`execute_with_cancel` 自己会掐断在飞的文件,这里管的是**段与段之间**
|
||||
// ——原版段与加载器段、文件段与登记之间,取消都必须在下一段开工前生效。
|
||||
if job.cancel.load(Ordering::SeqCst) {
|
||||
return Err(CANCELLED.to_string());
|
||||
}
|
||||
let version_id = if request.loader == LoaderKind::Vanilla {
|
||||
install_vanilla(job, &engine, request)?
|
||||
} else {
|
||||
install_loader(job, &engine, request)?
|
||||
};
|
||||
|
||||
// **取消之后绝不登记实例**(M105):文件段已经在跑了,下完那一刻用户可能早已点了取消;
|
||||
// 这里不拦的话,取消会留下一个「用户以为没装、其实已经装好并登记」的实例——
|
||||
// 那正是「半成品污染」里最难解释的一种(列表里凭空多一条)。
|
||||
if job.cancel.load(Ordering::SeqCst) {
|
||||
job.journal
|
||||
.line("已取消:文件段跑完但用户已取消,不登记实例(版本清单是否落盘见上)");
|
||||
return Err(CANCELLED.to_string());
|
||||
}
|
||||
|
||||
{
|
||||
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||
guard.stage(InstallStage::Registering, "正在登记实例…");
|
||||
let _ = job.tx.send(HostTask::Install {
|
||||
progress: Box::new(guard.progress.clone()),
|
||||
epoch: guard.epoch,
|
||||
});
|
||||
}
|
||||
let created_at =
|
||||
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now());
|
||||
unlucky_core::game::install::instance::register(
|
||||
&env.layout,
|
||||
&name,
|
||||
&version_id,
|
||||
Some(&created_at),
|
||||
)
|
||||
.map_err(|error| format!("登记实例失败:{error}"))?;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// 安装错误 → 用户可读文案(**取消不算失败**:给那句稳定文案,不给「失败 + 日志路径」)。
|
||||
///
|
||||
/// 判据用 job 自己的取消标志而不是错误大类:core 的 `execute_with_cancel` 只在阶段边界与
|
||||
/// 每个文件开工前看标志,取消与「最后一个文件刚好失败」可能同时成立——那种情况下用户点了取消,
|
||||
/// 文案就该说取消(否则他会以为取消导致了失败)。
|
||||
fn job_error(job: &Job<'_>, error: InstallError) -> String {
|
||||
if job.cancel.load(Ordering::SeqCst) {
|
||||
return CANCELLED.to_string();
|
||||
}
|
||||
install_error(error)
|
||||
}
|
||||
|
||||
/// 安装错误 → 用户可读文案。
|
||||
fn install_error(error: InstallError) -> String {
|
||||
match error.hint() {
|
||||
Some(hint) => format!("{}({hint})", error.message()),
|
||||
None => error.message().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 有文件失败就报错(列前三条明细)。
|
||||
fn ensure_complete(report: &InstallReport) -> Result<(), String> {
|
||||
if report.is_complete() {
|
||||
return Ok(());
|
||||
}
|
||||
let details: Vec<String> = report
|
||||
.failures(3)
|
||||
.iter()
|
||||
.filter_map(|outcome| {
|
||||
outcome
|
||||
.failure
|
||||
.as_ref()
|
||||
.map(|failure| format!("{}:{}", outcome.name, failure.message))
|
||||
})
|
||||
.collect();
|
||||
Err(format!(
|
||||
"{} 个文件没下成(共 {} 个):{}",
|
||||
report.failed(),
|
||||
report.files.len(),
|
||||
details.join(";")
|
||||
))
|
||||
}
|
||||
|
||||
/// 安装目录的短显示(诊断/Toast 用)。
|
||||
pub fn game_root(env: &HostEnv) -> PathBuf {
|
||||
env.game.root().to_path_buf()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "install_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,188 @@
|
||||
//! 两条安装路线(原版 / 加载器)——M105 从 `install.rs` 搬出,守 400 行硬线。
|
||||
//!
|
||||
//! 搬移是**纯搬移**:函数体一行没改,只多了本文件自己的 `use`。
|
||||
//! 父模块(`install`)的私有项对子模块可见,所以 `Job` / `CANCELLED` / `job_error` /
|
||||
//! `ensure_complete` / `sink_for` 一律不重新导出,直接用。
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_core::addon::loader::{
|
||||
LoaderInstallPlan, LoaderKind as CoreLoaderKind, LoaderPlanInput, LoaderSource,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::download::cache::now_ms;
|
||||
use unlucky_core::download::DownloadEngine;
|
||||
use unlucky_core::game::arguments::Environment;
|
||||
use unlucky_core::game::install::{execute_with_cancel, InstallPlan, PlanInput, RemoteSource};
|
||||
|
||||
use crate::dto::{CreateRequest, InstallStage, LoaderKind};
|
||||
|
||||
use super::super::install_processors::{self, StageInput};
|
||||
use super::super::meter::sink_for;
|
||||
use super::{ensure_complete, install_error, job_error, Job, CANCELLED, CONCURRENCY};
|
||||
|
||||
/// 原版安装(`resolve → plan → [prefetch 资源索引] → execute`)。
|
||||
pub(super) fn install_vanilla(
|
||||
job: &Job<'_>,
|
||||
engine: &DownloadEngine,
|
||||
request: &CreateRequest,
|
||||
) -> Result<String, String> {
|
||||
let env = job.env;
|
||||
let version = request.version.trim();
|
||||
let meta_cache = env.layout.meta_cache();
|
||||
let now = now_ms();
|
||||
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
|
||||
block_on(async {
|
||||
let source = RemoteSource::new(engine, &env.game, &meta_cache, now);
|
||||
let resolved = source.resolve(version).await.map_err(install_error)?;
|
||||
let fetched = match resolved
|
||||
.asset_index
|
||||
.as_ref()
|
||||
.filter(|index| index.url.is_some())
|
||||
{
|
||||
Some(reference) => {
|
||||
let (index, bytes) = source.asset_index(reference).await.map_err(install_error)?;
|
||||
Some((index, env.game.asset_index(&reference.id), bytes))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let mut plan = InstallPlan::build(PlanInput {
|
||||
layout: &env.game,
|
||||
version_id: version,
|
||||
resolved: &resolved,
|
||||
asset_index: fetched.as_ref().map(|(index, _, _)| index),
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.map_err(|error| error.to_string())?;
|
||||
if let Some((_, dest, bytes)) = fetched {
|
||||
plan.prefetch(dest, bytes);
|
||||
}
|
||||
if !plan.is_installable() {
|
||||
return Err(format!(
|
||||
"版本 {version} 装不全:{}",
|
||||
plan.warnings.join(";")
|
||||
));
|
||||
}
|
||||
let (bytes_total, _) = plan.total_bytes();
|
||||
{
|
||||
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||
guard.progress.files_total = u64::try_from(plan.file_count()).unwrap_or(u64::MAX);
|
||||
guard.progress.bytes_total = bytes_total;
|
||||
guard.stage(
|
||||
InstallStage::Downloading,
|
||||
&format!("开始下载 {} 个文件…", plan.file_count()),
|
||||
);
|
||||
}
|
||||
if job.cancel.load(Ordering::SeqCst) {
|
||||
return Err(CANCELLED.to_string());
|
||||
}
|
||||
let report = execute_with_cancel(
|
||||
engine,
|
||||
&plan,
|
||||
CONCURRENCY,
|
||||
Some(sink),
|
||||
Some(job.cancel.as_ref()),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| job_error(job, error))?;
|
||||
job.journal_report(&report);
|
||||
ensure_complete(&report)?;
|
||||
Ok::<String, String>(version.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// 加载器安装(原版前置 → installer processors → 加载器库 → 版本 JSON 落盘)。
|
||||
pub(super) fn install_loader(
|
||||
job: &Job<'_>,
|
||||
engine: &DownloadEngine,
|
||||
request: &CreateRequest,
|
||||
) -> Result<String, String> {
|
||||
let env = job.env;
|
||||
let version = request.version.trim();
|
||||
let kind = CoreLoaderKind::parse(request.loader.as_str())
|
||||
.ok_or_else(|| format!("未知加载器 `{}`", request.loader.as_str()))?;
|
||||
let meta_cache = env.layout.meta_cache();
|
||||
let loader_version = request.loader_version.trim().to_string();
|
||||
let explicit = (!loader_version.is_empty()).then_some(loader_version.as_str());
|
||||
let release = block_on(async {
|
||||
LoaderSource::new(engine, &meta_cache)
|
||||
.resolve(kind, version, explicit)
|
||||
.await
|
||||
})
|
||||
.map_err(|error| error.to_string())?;
|
||||
job.journal.line(&format!(
|
||||
"加载器解析:{} {} → {}",
|
||||
release.kind.label(),
|
||||
release.loader_version,
|
||||
release.version_id
|
||||
));
|
||||
|
||||
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||
layout: &env.game,
|
||||
release: &release,
|
||||
environment: &Environment::host(),
|
||||
// 向导/下载页只能装「叠在原版上」的层(CreateRequest 没有父版本槽位),
|
||||
// 所以父版本恒等于原版版本;真装缺原版时由下面的 `vanilla_install_needed` 补装。
|
||||
parent_installed: env.game.is_version_installed(&release.parent_version_id),
|
||||
})
|
||||
.map_err(|error| error.to_string())?;
|
||||
if plan.vanilla_install_needed {
|
||||
let mut vanilla = request.clone();
|
||||
vanilla.loader = LoaderKind::Vanilla;
|
||||
install_vanilla(job, engine, &vanilla)?;
|
||||
}
|
||||
// 两段(加载器库 + 处理器依赖库)在动手前一次预记账:进度条只走一条单调曲线,
|
||||
// 处理器依赖库下完不会先满格、再在加载器库开始时回落到一半。
|
||||
{
|
||||
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||
let (bytes, _) = plan.install.total_bytes();
|
||||
guard.expect(plan.files().len(), bytes);
|
||||
}
|
||||
// Forge/NeoForge:installer processors 必须先跑完,`execute` 才允许写版本 JSON。
|
||||
if release.requires_installer() {
|
||||
let input = StageInput {
|
||||
kind: release.kind,
|
||||
mc_version: &release.mc_version,
|
||||
version_id: &release.version_id,
|
||||
profile: release.install_profile.as_ref(),
|
||||
installer: release.installer.as_deref(),
|
||||
installed: env.game.is_version_installed(&release.version_id),
|
||||
};
|
||||
// `None` = 这一版不需要跑处理器(版本 JSON 已在盘上 / profile 没有 client 侧步骤),
|
||||
// 进度与日志都由处理器段自己写全,这里只消费返回值。
|
||||
let _ = install_processors::stage(
|
||||
env,
|
||||
engine,
|
||||
&input,
|
||||
&install_processors::CORE,
|
||||
&job.reporter(),
|
||||
job.cancel,
|
||||
)?;
|
||||
}
|
||||
{
|
||||
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||
guard.stage(
|
||||
InstallStage::Downloading,
|
||||
&format!("开始下载加载器库({} 个文件)…", plan.files().len()),
|
||||
);
|
||||
}
|
||||
if job.cancel.load(Ordering::SeqCst) {
|
||||
return Err(CANCELLED.to_string());
|
||||
}
|
||||
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
|
||||
let report = block_on(async {
|
||||
execute_with_cancel(
|
||||
engine,
|
||||
&plan.install,
|
||||
CONCURRENCY,
|
||||
Some(sink),
|
||||
Some(job.cancel.as_ref()),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.map_err(|error| job_error(job, error))?;
|
||||
job.journal_report(&report);
|
||||
ensure_complete(&report)?;
|
||||
Ok(release.version_id)
|
||||
}
|
||||
@@ -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,382 @@
|
||||
//! 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 {
|
||||
progress: Box::new(guard.progress.clone()),
|
||||
epoch: guard.epoch,
|
||||
});
|
||||
}
|
||||
|
||||
/// 先把待下的库记账(文件数 + 已知字节),下载回调再逐个累加。
|
||||
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 {
|
||||
progress: Box::new(guard.progress.clone()),
|
||||
epoch: guard.epoch,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/// 一步的进度/日志文案(纯函数,单测直接断言)。
|
||||
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,273 @@
|
||||
//! 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: Arc<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, 1))),
|
||||
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,
|
||||
manifest: 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,214 @@
|
||||
//! 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,
|
||||
manifest: None,
|
||||
warnings: Vec::new(),
|
||||
};
|
||||
let text = libraries_failed(&report);
|
||||
assert!(text.contains("4 个失败,共 4 个"), "{text}");
|
||||
assert!(text.contains("lib0.jar"), "{text}");
|
||||
assert!(!text.contains("lib3.jar"), "最多列三条:{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_skips_when_the_version_json_is_already_there() {
|
||||
let h = Harness::new();
|
||||
let installer = installer();
|
||||
let profile = profile();
|
||||
let mut input = input(&profile, &installer);
|
||||
input.installed = true;
|
||||
let fake = Fake::new();
|
||||
let result = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel);
|
||||
assert!(matches!(result, Ok(None)));
|
||||
assert!(fake.calls().is_empty(), "已装完就不该再动网络/Java");
|
||||
assert!(h.log().contains("跳过 installer processors"), "{}", h.log());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_skips_an_empty_processor_list() {
|
||||
let h = Harness::new();
|
||||
let installer = installer();
|
||||
let profile = InstallProfile::default();
|
||||
let input = input(&profile, &installer);
|
||||
let fake = Fake::new();
|
||||
let result = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel);
|
||||
assert!(matches!(result, Ok(None)), "没有 processor 就没这段");
|
||||
assert!(fake.calls().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_reports_missing_installer_metadata() {
|
||||
let h = Harness::new();
|
||||
let input = StageInput {
|
||||
kind: CoreLoaderKind::Forge,
|
||||
mc_version: "1.20.1",
|
||||
version_id: "1.20.1-forge-47.2.0",
|
||||
profile: None,
|
||||
installer: None,
|
||||
installed: false,
|
||||
};
|
||||
let fake = Fake::new();
|
||||
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
|
||||
assert!(err.contains("install profile"), "{err}");
|
||||
assert!(h.log().contains("失败:"), "{}", h.log());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_builds_the_command_line_from_the_install_profile() {
|
||||
let h = Harness::new();
|
||||
let installer = installer();
|
||||
let profile = profile();
|
||||
let input = input(&profile, &installer);
|
||||
let fake = Fake::new();
|
||||
let run = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel)
|
||||
.expect("stage 成功")
|
||||
.expect("应当真的跑了 processors");
|
||||
assert_eq!(run.executed(), 1);
|
||||
let steps = fake.steps();
|
||||
assert_eq!(steps.len(), 1);
|
||||
let (index, label, args, classpath) = &steps[0];
|
||||
assert_eq!(*index, 1);
|
||||
assert_eq!(label, "net.minecraftforge:installertools:1.3.0");
|
||||
assert_eq!(args, &vec!["--task".to_string(), "MCP_DATA".to_string()]);
|
||||
assert_eq!(*classpath, 1, "jar 自己不在 -cp 里(拼命令行时才补)");
|
||||
assert_eq!(fake.calls(), vec!["libraries", "java", "run"]);
|
||||
let messages = h.messages();
|
||||
assert!(
|
||||
messages.iter().any(|m| m.contains("installer 处理器 1/1")),
|
||||
"面板要看到当前处理器:{messages:?}"
|
||||
);
|
||||
assert!(
|
||||
messages
|
||||
.last()
|
||||
.is_some_and(|m| m.contains("正在写入版本清单")),
|
||||
"{messages:?}"
|
||||
);
|
||||
assert!(
|
||||
h.log().contains("processors 完成:执行 1 步"),
|
||||
"{}",
|
||||
h.log()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_fails_loudly_without_a_usable_java() {
|
||||
let h = Harness::new();
|
||||
let installer = installer();
|
||||
let profile = profile();
|
||||
let input = input(&profile, &installer);
|
||||
let mut fake = Fake::new();
|
||||
fake.java = None;
|
||||
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
|
||||
assert!(err.contains("Java"), "{err}");
|
||||
assert!(err.contains("托管目录"), "{err}");
|
||||
assert_eq!(
|
||||
fake.calls(),
|
||||
vec!["libraries", "java"],
|
||||
"没 Java 就不该起进程"
|
||||
);
|
||||
assert!(h.log().contains("失败:"), "{}", h.log());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_fails_loudly_when_a_processor_exits_nonzero() {
|
||||
let h = Harness::new();
|
||||
let installer = installer();
|
||||
let profile = profile();
|
||||
let input = input(&profile, &installer);
|
||||
let mut fake = Fake::new();
|
||||
fake.run_error = Some(ProcessorError::failed(
|
||||
"net.minecraftforge:installertools:1.3.0",
|
||||
"1",
|
||||
"java.lang.NoSuchMethodError",
|
||||
));
|
||||
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
|
||||
assert!(err.contains("processor_failed"), "{err}");
|
||||
assert!(err.contains("NoSuchMethodError"), "{err}");
|
||||
let log = h.log();
|
||||
assert!(log.contains("失败:"), "{log}");
|
||||
assert!(log.contains("NoSuchMethodError"), "日志要能回溯:{log}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_reports_incomplete_library_downloads() {
|
||||
let h = Harness::new();
|
||||
let installer = installer();
|
||||
let profile = profile();
|
||||
let input = input(&profile, &installer);
|
||||
let mut fake = Fake::new();
|
||||
fake.libraries_ok = false;
|
||||
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
|
||||
assert!(err.contains("依赖库没下全"), "{err}");
|
||||
assert!(err.contains("连接超时"), "{err}");
|
||||
assert_eq!(fake.calls(), vec!["libraries"], "库没下全就别去选 Java");
|
||||
let progress = h.meter.lock().expect("进度锁").progress.clone();
|
||||
assert!(progress.files_total >= 2, "依赖库要计入文件总数");
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//! host/install.rs 的单测(拆文件守 400 行上限)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use unlucky_core::game::install::{FileOutcome, FileRole, FileStatus};
|
||||
|
||||
use super::{loader_label, run, supported, unsupported_reason, Job};
|
||||
use crate::dto::{CreateRequest, LoaderKind};
|
||||
use crate::host::env::HostEnv;
|
||||
use crate::host::install_journal::Journal;
|
||||
use crate::host::job::{channel, JobSender};
|
||||
use crate::host::meter::Meter;
|
||||
|
||||
static SEQ: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// 一个装在临时根里的任务上下文(只走「联网前就返回」的用例)。
|
||||
struct Fixture {
|
||||
dir: PathBuf,
|
||||
env: HostEnv,
|
||||
tx: JobSender,
|
||||
meter: Arc<Mutex<Meter>>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
journal: Journal,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new(request: &CreateRequest) -> Self {
|
||||
let seq = SEQ.fetch_add(1, Ordering::SeqCst);
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("ull-host-install-{}-{seq}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
let journal = Journal::open(&env);
|
||||
Self {
|
||||
dir,
|
||||
env,
|
||||
tx: channel().0,
|
||||
meter: Arc::new(Mutex::new(Meter::new(request, 1))),
|
||||
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, 1);
|
||||
|
||||
// 原版段:30 个文件全下完(install_vanilla 对总量是赋值,不是累加)
|
||||
meter.progress.files_total = 30;
|
||||
meter.progress.bytes_total = 3_000_000;
|
||||
for _ in 0..30 {
|
||||
meter.note(&downloaded(100_000));
|
||||
}
|
||||
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
|
||||
|
||||
// install_loader 的顺序:加载器库与处理器依赖库都在动手前一次预记账
|
||||
meter.expect(29, 10_000_000);
|
||||
meter.expect(36, 4_000_000);
|
||||
assert_eq!(meter.progress.files_total, 95, "两段之和一次到位");
|
||||
assert_eq!(
|
||||
meter.progress.bytes_total, 17_000_000,
|
||||
"字节是相加不是取最大"
|
||||
);
|
||||
assert!(
|
||||
meter.progress.ratio() < 0.7,
|
||||
"预记账后不能还停在满格:{}",
|
||||
meter.progress.ratio()
|
||||
);
|
||||
|
||||
// 处理器依赖库 36 个下完 → 仍没满格(加载器库还在后面)
|
||||
for _ in 0..36 {
|
||||
meter.note(&downloaded(110_000));
|
||||
}
|
||||
let ratio = meter.progress.ratio();
|
||||
assert!(
|
||||
(0.6..1.0).contains(&ratio),
|
||||
"回落到中间态而不是满格:{ratio}"
|
||||
);
|
||||
assert!(
|
||||
meter.progress.bytes_done < meter.progress.bytes_total,
|
||||
"字节总量要覆盖已下载量:{}/{}",
|
||||
meter.progress.bytes_done,
|
||||
meter.progress.bytes_total
|
||||
);
|
||||
|
||||
// 加载器库也下完 → 这时候才该是满格
|
||||
for _ in 0..29 {
|
||||
meter.note(&downloaded(340_000));
|
||||
}
|
||||
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_support_matrix_is_explicit() {
|
||||
// M45 语义变更:Forge/NeoForge 从「GUI 显式拒装」扩进受支持集合(走 installer processors)。
|
||||
for kind in [
|
||||
LoaderKind::Vanilla,
|
||||
LoaderKind::Fabric,
|
||||
LoaderKind::Quilt,
|
||||
LoaderKind::Forge,
|
||||
LoaderKind::NeoForge,
|
||||
// M222:LiteLoader 走「官方版本表 + 从零合成 launchwrapper 清单」,不跑外部进程。
|
||||
LoaderKind::LiteLoader,
|
||||
] {
|
||||
assert!(supported(kind), "{} 应当受支持", kind.label());
|
||||
}
|
||||
// OptiFine 仍没有安装管线(要跑外部 Patcher + 原地改 zip),显式拒装并说清可替代做法。
|
||||
let optifine = LoaderKind::OptiFine;
|
||||
assert!(!supported(optifine), "{} 不该被当成可装", optifine.label());
|
||||
let reason = unsupported_reason(optifine);
|
||||
assert!(reason.contains(optifine.label()), "{reason}");
|
||||
assert!(reason.contains("还没有接入"), "{reason}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forge_install_is_no_longer_refused_at_the_door() {
|
||||
// 旧文案是把用户赶去终端跑 `ull install`;M45 之后 GUI 与 CLI 同一条路径。
|
||||
assert!(supported(LoaderKind::Forge));
|
||||
assert!(supported(LoaderKind::NeoForge));
|
||||
assert_eq!(loader_label(LoaderKind::Forge, "47.2.0"), "Forge 47.2.0");
|
||||
assert_eq!(
|
||||
loader_label(LoaderKind::NeoForge, "21.1.80"),
|
||||
"NeoForge 21.1.80"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meter_counts_files_and_skips_skipped_bytes() {
|
||||
let request = CreateRequest {
|
||||
version: "1.20.4".to_string(),
|
||||
loader: LoaderKind::Fabric,
|
||||
loader_version: "0.16.5".to_string(),
|
||||
name: "生存".to_string(),
|
||||
};
|
||||
let mut meter = Meter::new(&request, 1);
|
||||
assert_eq!(meter.progress.loader, "Fabric 0.16.5");
|
||||
assert_eq!(meter.progress.instance, "生存");
|
||||
meter.progress.files_total = 2;
|
||||
|
||||
let mut outcome = FileOutcome {
|
||||
role: FileRole::Library,
|
||||
name: "a.jar".to_string(),
|
||||
dest: PathBuf::from("a.jar"),
|
||||
status: FileStatus::Downloaded,
|
||||
bytes: 100,
|
||||
source: Some("bmclapi".to_string()),
|
||||
failure: None,
|
||||
};
|
||||
meter.note(&outcome);
|
||||
assert_eq!(meter.progress.files_done, 1);
|
||||
assert_eq!(meter.progress.bytes_done, 100);
|
||||
|
||||
outcome.status = FileStatus::Skipped;
|
||||
outcome.bytes = 500;
|
||||
meter.note(&outcome);
|
||||
assert_eq!(meter.progress.files_done, 2);
|
||||
assert_eq!(meter.progress.bytes_done, 100, "跳过的文件不计入已下载字节");
|
||||
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_version_is_rejected_before_network() {
|
||||
let request = request(LoaderKind::Vanilla, "");
|
||||
let fixture = Fixture::new(&request);
|
||||
let err = run(&fixture.job(), &request).unwrap_err();
|
||||
assert!(err.contains("没有选择版本"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_loader_is_rejected_before_network() {
|
||||
let request = request(LoaderKind::OptiFine, "1.20.1");
|
||||
let fixture = Fixture::new(&request);
|
||||
let err = run(&fixture.job(), &request).unwrap_err();
|
||||
assert!(err.contains("还没有接入"), "{err}");
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Java 页:四家发行版 + 回退链可视化 + 本地运行时 + 托管 JDK 安装。
|
||||
//!
|
||||
//! 回退链的两态:
|
||||
//! - **计划链**(还没装):按生效顺序铺一排「待尝试」,让用户先看清会按什么顺序找;
|
||||
//! - **真实链**(装过一次/装失败):`core::game::java::ChainLog` 的每跳结局与原因。
|
||||
//!
|
||||
//! 安装走 `java::install`(core 自己跑回退链),host 只负责把 `ChainLog` 翻成 DTO。
|
||||
|
||||
use unlucky_core::game::java::probe::JavaOrigin;
|
||||
use unlucky_core::game::java::source::ChainLog;
|
||||
use unlucky_core::game::java::{self, JdkVendor};
|
||||
|
||||
use crate::dto::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
|
||||
|
||||
use super::env::HostEnv;
|
||||
use super::job::{spawn, HostTask, JobSender};
|
||||
|
||||
/// 托管 JDK 的默认目标大版本(D13 基准 JDK17)。
|
||||
pub const DEFAULT_JDK_MAJOR: u32 = 17;
|
||||
|
||||
/// 该装哪个大版本:取第一个实例版本的要求,没有实例就给 17。
|
||||
pub fn target_major(env: &HostEnv) -> u32 {
|
||||
let profiles = super::game::profiles(env);
|
||||
profiles
|
||||
.profiles
|
||||
.first()
|
||||
.map(|profile| {
|
||||
java::requirement_for_version(&env.game, &profile.version_id)
|
||||
.minimum
|
||||
.max(8)
|
||||
})
|
||||
.unwrap_or(DEFAULT_JDK_MAJOR)
|
||||
}
|
||||
|
||||
/// 四家发行版的展示行(镜像与吞吐口径与 CLI `ull java vendors` 一致)。
|
||||
pub fn vendors(env: &HostEnv, installed_vendors: &[String]) -> Vec<JavaVendorView> {
|
||||
let domestic = super::engine::domestic(env);
|
||||
let order = java::vendor::order(&super::engine::java_priority(env), domestic);
|
||||
let table = unlucky_core::download::MirrorTable::builtin();
|
||||
order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, vendor)| {
|
||||
let id = vendor.id();
|
||||
JavaVendorView {
|
||||
id: id.to_string(),
|
||||
display: vendor.display().to_string(),
|
||||
rank: u32::try_from(index + 1).unwrap_or(u32::MAX),
|
||||
majors: vendor
|
||||
.hint_majors()
|
||||
.iter()
|
||||
.map(u32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/"),
|
||||
sources: sources_of(&table, *vendor),
|
||||
installed: installed_vendors.iter().any(|known| known == id),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 一家的源清单(与 CLI 展示同形)。
|
||||
fn sources_of(table: &unlucky_core::download::MirrorTable, vendor: JdkVendor) -> Vec<String> {
|
||||
match vendor {
|
||||
JdkVendor::Temurin => table
|
||||
.adoptium
|
||||
.iter()
|
||||
.filter(|mirror| mirror.enabled)
|
||||
.map(|mirror| mirror.name.clone())
|
||||
.chain(std::iter::once(
|
||||
"GitHub 官方(国内套 gh-proxy)".to_string(),
|
||||
))
|
||||
.collect(),
|
||||
JdkVendor::Oracle => table
|
||||
.oracle_mirrors()
|
||||
.map(|mirror| format!("{} {}", mirror.name, mirror.base()))
|
||||
.collect(),
|
||||
JdkVendor::Microsoft => vec!["aka.ms → VS CDN(无国内镜像)".to_string()],
|
||||
JdkVendor::Zulu => table
|
||||
.zulu()
|
||||
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
|
||||
.unwrap_or_default(),
|
||||
JdkVendor::Termux => unlucky_core::game::java::termux::MIRRORS
|
||||
.iter()
|
||||
.map(|mirror| format!("{mirror}(系统包:pkg install openjdk-<major>)"))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地运行时(托管 / JAVA_HOME / PATH / 系统安装位置,同一路径只留优先级高者)。
|
||||
pub fn runtimes(env: &HostEnv) -> Vec<JavaRuntimeView> {
|
||||
let roots = super::engine::java_scan_roots(env);
|
||||
let candidates = java::discover::dedupe(java::candidates_in_roots(&roots));
|
||||
candidates
|
||||
.iter()
|
||||
.map(|runtime| JavaRuntimeView {
|
||||
binary: runtime.binary.display().to_string(),
|
||||
version: runtime
|
||||
.version
|
||||
.clone()
|
||||
.unwrap_or_else(|| "版本未知".to_string()),
|
||||
major: runtime.major.unwrap_or(java::DEFAULT_MAJOR),
|
||||
vendor: runtime.vendor.clone().unwrap_or_else(|| "—".to_string()),
|
||||
origin: runtime.origin.category().to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 托管目录(展示「装到哪」)。
|
||||
pub fn managed_roots(env: &HostEnv) -> Vec<String> {
|
||||
super::engine::java_scan_roots(env)
|
||||
.iter()
|
||||
.map(|root| root.display().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 计划链(按生效顺序铺「待尝试」)。
|
||||
pub fn planned_chain(env: &HostEnv) -> Vec<JavaChainStep> {
|
||||
let domestic = super::engine::domestic(env);
|
||||
let order = java::vendor::order(&super::engine::java_priority(env), domestic);
|
||||
order
|
||||
.iter()
|
||||
.map(|vendor| JavaChainStep {
|
||||
vendor: vendor.id().to_string(),
|
||||
outcome: JavaChainOutcome::Pending,
|
||||
detail: String::new(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 真实链(`ChainLog` → DTO)。
|
||||
pub fn chain_steps(log: &ChainLog) -> Vec<JavaChainStep> {
|
||||
log.attempts()
|
||||
.iter()
|
||||
.map(|attempt| JavaChainStep {
|
||||
vendor: attempt.vendor.id().to_string(),
|
||||
outcome: if attempt.ok {
|
||||
JavaChainOutcome::Hit
|
||||
} else if attempt.detail.contains("不支持") {
|
||||
JavaChainOutcome::Skip
|
||||
} else {
|
||||
JavaChainOutcome::Failed
|
||||
},
|
||||
detail: attempt.detail.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 整页快照。
|
||||
pub fn page(env: &HostEnv, chain: Vec<JavaChainStep>, busy: bool, message: &str) -> JavaPageView {
|
||||
let runtimes = runtimes(env);
|
||||
let installed_vendors: Vec<String> = runtimes
|
||||
.iter()
|
||||
.filter(|runtime| runtime.origin == JavaOrigin::Managed.category())
|
||||
.map(|runtime| runtime.vendor.clone())
|
||||
.collect();
|
||||
let vendor_rows = vendors(env, &installed_vendors);
|
||||
let domestic = super::engine::domestic(env);
|
||||
let priority_set = !super::engine::java_priority(env).is_empty();
|
||||
let chain = if chain.is_empty() {
|
||||
planned_chain(env)
|
||||
} else {
|
||||
chain
|
||||
};
|
||||
JavaPageView {
|
||||
region: if domestic { "domestic" } else { "global" }.to_string(),
|
||||
priority_source: if priority_set {
|
||||
"java.vendorPriority"
|
||||
} else {
|
||||
"region_default"
|
||||
}
|
||||
.to_string(),
|
||||
order_text: vendor_rows
|
||||
.iter()
|
||||
.map(|vendor| vendor.display.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → "),
|
||||
vendors: vendor_rows,
|
||||
chain,
|
||||
runtimes,
|
||||
managed_roots: managed_roots(env),
|
||||
busy,
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台装一个托管 JDK(`vendor` 为空 = 按回退链自己选)。
|
||||
pub fn spawn_install(env: HostEnv, tx: JobSender, vendor: Option<JdkVendor>) {
|
||||
spawn("java-install", move || {
|
||||
let major = target_major(&env);
|
||||
let mut busy = page(&env, Vec::new(), true, &format!("正在安装 Java {major}…"));
|
||||
busy.busy = true;
|
||||
let _ = tx.send(HostTask::Java(Box::new(busy)));
|
||||
|
||||
let engine = super::engine::engine(&env);
|
||||
let root = match super::engine::java_install_root(&env) {
|
||||
Ok(root) => root,
|
||||
Err(reason) => {
|
||||
let _ = tx.send(HostTask::Java(Box::new(page(
|
||||
&env,
|
||||
Vec::new(),
|
||||
false,
|
||||
&format!("解析 Java 安装位置失败:{reason}"),
|
||||
))));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let request = match vendor {
|
||||
Some(vendor) => java::InstallRequest::with_vendor(major, vendor)
|
||||
.with_priority(super::engine::java_priority(&env)),
|
||||
None => {
|
||||
java::InstallRequest::new(major).with_priority(super::engine::java_priority(&env))
|
||||
}
|
||||
};
|
||||
let result =
|
||||
unlucky_core::download::blocking::block_on(java::install(&engine, &root, request));
|
||||
match result {
|
||||
Ok(installed) => {
|
||||
let message = if installed.reused() {
|
||||
format!(
|
||||
"Java {}({})已就绪:{}",
|
||||
installed.major,
|
||||
installed.vendor.id(),
|
||||
installed.root.display()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"已安装 Java {}({})→ {} · {} 个文件",
|
||||
installed.major,
|
||||
installed.vendor.display(),
|
||||
installed.root.display(),
|
||||
installed.files
|
||||
)
|
||||
};
|
||||
let chain = chain_steps(&installed.chain);
|
||||
let _ = tx.send(HostTask::Java(Box::new(page(&env, chain, false, &message))));
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = tx.send(HostTask::Java(Box::new(page(
|
||||
&env,
|
||||
Vec::new(),
|
||||
false,
|
||||
&format!("Java 安装失败:{error}"),
|
||||
))));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 「安装一个 Java」的 UI 文案(下载页 Java 分组的按钮说明)。
|
||||
pub fn install_hint(env: &HostEnv) -> String {
|
||||
let major = target_major(env);
|
||||
format!("按回退链取一个 Java {major}(托管目录,装完自动被启动链选中)")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn planned_chain_covers_four_vendors() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-java-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
std::fs::create_dir_all(env.layout.config()).expect("config");
|
||||
let chain = planned_chain(&env);
|
||||
assert_eq!(chain.len(), 4);
|
||||
assert!(chain
|
||||
.iter()
|
||||
.all(|step| step.outcome == JavaChainOutcome::Pending));
|
||||
assert_eq!(chain[0].vendor, "temurin", "国内/国外默认都是 Temurin 第一");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_log_maps_to_steps() {
|
||||
let mut log = ChainLog::new();
|
||||
log.skip(
|
||||
JdkVendor::Microsoft,
|
||||
"不支持 Java 8(microsoft 只发 11/17/21/25)",
|
||||
);
|
||||
log.hit(JdkVendor::Temurin, "命中 Adoptium 17");
|
||||
let steps = chain_steps(&log);
|
||||
assert_eq!(steps.len(), 2);
|
||||
assert_eq!(steps[0].outcome, JavaChainOutcome::Skip);
|
||||
assert_eq!(steps[1].outcome, JavaChainOutcome::Hit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_reports_region_and_priority_source() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-host-java-page-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
std::fs::create_dir_all(env.layout.config()).expect("config");
|
||||
std::fs::write(
|
||||
env.launcher_config(),
|
||||
r#"{"java":{"vendorPriority":["zulu"]}}"#,
|
||||
)
|
||||
.expect("写配置");
|
||||
let page = page(&env, Vec::new(), false, "");
|
||||
assert_eq!(page.priority_source, "java.vendorPriority");
|
||||
assert_eq!(page.vendors[0].id, "zulu");
|
||||
assert_eq!(page.vendors[0].rank, 1);
|
||||
assert!(page.order_text.contains("Azul Zulu"));
|
||||
assert_eq!(page.vendors.len(), 4);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! 后台任务的回流消息:`HostTask` 是唯一「线程 → host」的载荷。
|
||||
//!
|
||||
//! 线程只做 core 调用(网络/磁盘),产出的都是 `crate::dto` 的 DTO;
|
||||
//! host 的 `tick` 把它们翻成 [`crate::dto::CoreEvent`]。
|
||||
//! 这样「跨线程传的是什么」在一处看全,也不会出现半成品领域类型漏回 UI。
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use crate::dto::{
|
||||
// 主线(M109 ContentResults / M105 LoaderKind / M113 PackEvent)与 M114 的账户轨并集。
|
||||
AccountView,
|
||||
AuthlibLoginView,
|
||||
ContentDetail,
|
||||
ContentFailure,
|
||||
ContentResults,
|
||||
DeviceLoginView,
|
||||
DiagnosisReport,
|
||||
InstallProgressView,
|
||||
InstanceId,
|
||||
InstanceRuntime,
|
||||
JavaPageView,
|
||||
LaunchFinished,
|
||||
LiveLogBatch,
|
||||
LoaderKind,
|
||||
LogLine,
|
||||
LogSession,
|
||||
NoticeView,
|
||||
PackEvent,
|
||||
SessionId,
|
||||
SkinImage,
|
||||
VersionSummary,
|
||||
};
|
||||
use crate::toast::Toast;
|
||||
|
||||
/// 后台任务 → host 的消息。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum HostTask {
|
||||
/// 版本清单(下载页)
|
||||
Versions(Result<Vec<VersionSummary>, String>),
|
||||
/// 加载器版本清单(下载页「安装选项」的下拉)
|
||||
LoaderVersions {
|
||||
/// 加载器种类
|
||||
kind: LoaderKind,
|
||||
/// 目标原版版本(缓存键的一部分,回传原样)
|
||||
mc_version: String,
|
||||
/// `Ok(最新在前)` / `Err(人读原因)`
|
||||
result: Result<Vec<String>, String>,
|
||||
},
|
||||
/// 内容库检索(M109;`Err` = 失败大类 + 一句人读原因)
|
||||
Content(Box<Result<ContentResults, ContentFailure>>),
|
||||
/// 内容库项目详情 + 版本列表(M162):项目 id 随载荷一起回来(迟到结果靠它判)
|
||||
ContentDetail {
|
||||
/// 请求时选中的项目 id
|
||||
project: String,
|
||||
/// `Ok` = 详情;`Err` = 失败大类 + 原因
|
||||
result: Box<Result<ContentDetail, ContentFailure>>,
|
||||
},
|
||||
// M113: import/export
|
||||
/// 导入/导出(M113):一条事件 + 任务代次
|
||||
///
|
||||
/// 代次是 **pack 自己的一条**(`host::addon::pack` 的 `pack_epoch()`,按 host 分开),**不**与
|
||||
/// [`HostTask::Install`] 的 `CoreHost::install_epoch` 共用 —— 共用会让「关一次导入面板」
|
||||
/// 把下载页正在跑的安装静默丢掉(复审 r1 P2-7)。取消/关面板之后回来的旧事件按这条代次
|
||||
/// 丢掉,面板不会被重新点亮。
|
||||
Pack {
|
||||
/// 事件载荷(进度 / 结果 / 探测诊断 / 预检 / 完成)
|
||||
event: Box<PackEvent>,
|
||||
/// 任务代次
|
||||
epoch: u64,
|
||||
},
|
||||
// M223: 导入已有版本
|
||||
/// 扫描 / 预检 / 确认的结果(一条事件 + 任务代次)
|
||||
///
|
||||
/// 代次是 adopt 自己的(`CoreHost::adopt_epoch`),**不**与安装或 pack 的代次共用:
|
||||
/// 关一次导入面板不该把下载页在跑的安装静默丢掉。
|
||||
Adopt {
|
||||
/// 事件载荷(扫描 / 预检 / 完成 / 失败)
|
||||
event: Box<crate::dto::AdoptEvent>,
|
||||
/// 任务代次
|
||||
epoch: u64,
|
||||
},
|
||||
/// 安装进度(高频,host 只做聚合与转发)
|
||||
Install {
|
||||
/// 进度快照
|
||||
progress: Box<InstallProgressView>,
|
||||
/// 任务代次([`super::CoreHost`] 的 `install_epoch`)
|
||||
///
|
||||
/// 取消/重新开工都会把代次 +1,于是**旧任务还在飞的事件**带着旧代次回来,
|
||||
/// host 一眼就能认出来并丢掉。没有这一层的话:取消后 `InstallProgress(None)` 刚把面板
|
||||
/// 收回,下一条「下载中」事件立刻把它点亮——用户看到的就是「点了取消没反应」(M105 实测)。
|
||||
epoch: u64,
|
||||
},
|
||||
/// 安装收尾(成功带实例名,失败带**大类 + 文案**)
|
||||
InstallDone {
|
||||
/// 最终进度快照
|
||||
progress: Box<InstallProgressView>,
|
||||
/// 成功时为 `Ok(实例名)`;失败时带分类(离线 ≠ 查无此项)
|
||||
result: Result<String, ContentFailure>,
|
||||
/// 任务代次(语义同 [`HostTask::Install`] 的 `epoch`)
|
||||
epoch: u64,
|
||||
},
|
||||
/// 账户列表
|
||||
Accounts(Vec<AccountView>),
|
||||
/// 设备码登录状态
|
||||
Login(Box<DeviceLoginView>),
|
||||
/// 第三方轨(authlib-injector)登录/校验状态
|
||||
Authlib(Box<AuthlibLoginView>),
|
||||
/// 账户皮肤抓取结果(`skin = None` = 没取到;UI 退回首字母块)
|
||||
Avatar {
|
||||
/// 账户 id
|
||||
id: String,
|
||||
/// 皮肤原图
|
||||
skin: Option<SkinImage>,
|
||||
},
|
||||
/// Java 页(发行版/回退链/运行时)
|
||||
Java(Box<JavaPageView>),
|
||||
/// 日志页快照(会话列表 + 选中会话的行 + 诊断)
|
||||
Logs {
|
||||
/// 会话列表
|
||||
sessions: Vec<LogSession>,
|
||||
/// 选中会话的行
|
||||
lines: Vec<LogLine>,
|
||||
/// 诊断结论
|
||||
diagnosis: Option<DiagnosisReport>,
|
||||
},
|
||||
/// 实时日志增量
|
||||
Tail(Box<LiveLogBatch>),
|
||||
/// 启动开始(pid + 会话 id)
|
||||
LaunchStarted {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// 会话 id
|
||||
session_id: SessionId,
|
||||
/// 进程号
|
||||
pid: u32,
|
||||
},
|
||||
/// 启动阶段推进(磁贴进度环)
|
||||
Runtime {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
/// 阶段状态
|
||||
runtime: InstanceRuntime,
|
||||
},
|
||||
/// 启动收尾
|
||||
LaunchEnd(Box<LaunchFinished>),
|
||||
/// 崩溃日志导入的分析结论
|
||||
Crash(Box<DiagnosisReport>),
|
||||
/// 横幅通知(隔离切换提示等)
|
||||
Notice(Box<NoticeView>),
|
||||
/// Toast
|
||||
Toast(Box<Toast>),
|
||||
/// 会话列表刷新(只换左栏)
|
||||
Sessions(Vec<LogSession>),
|
||||
/// 自更新(M159):检查/应用的回报(载荷已翻成 DTO,见 `host::update::Report`)
|
||||
Update(Box<crate::host::update::Report>),
|
||||
/// 本地 Mod 列表 / 启停(M224):列表快照或具名失败(见 `host::mods::Report`)
|
||||
Mods(Box<super::mods::Report>),
|
||||
/// 实例存档 / 截图的只读列表(M235):快照或具名失败(见 `host::saves::Report`)
|
||||
Saves(Box<super::saves::Report>),
|
||||
}
|
||||
|
||||
/// 后台线程用的发送端(`Sender` 克隆即可)。
|
||||
pub type JobSender = Sender<HostTask>;
|
||||
|
||||
/// host 侧的接收端。
|
||||
pub type JobReceiver = Receiver<HostTask>;
|
||||
|
||||
/// 建一对通道。
|
||||
pub fn channel() -> (JobSender, JobReceiver) {
|
||||
std::sync::mpsc::channel()
|
||||
}
|
||||
|
||||
/// 起一个带名字的后台线程(名字只进 panic 信息,方便定位)。
|
||||
pub fn spawn(name: &str, job: impl FnOnce() + Send + 'static) -> std::thread::JoinHandle<()> {
|
||||
std::thread::Builder::new()
|
||||
.name(format!("ull-host-{name}"))
|
||||
.spawn(job)
|
||||
.unwrap_or_else(|error| panic!("起不来后台线程 {name}: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn channel_carries_tasks_across_threads() {
|
||||
let (tx, rx) = channel();
|
||||
let handle = spawn("test", move || {
|
||||
tx.send(HostTask::Toast(Box::new(Toast {
|
||||
id: 1,
|
||||
kind: crate::toast::ToastKind::Info,
|
||||
text: "hi".to_string(),
|
||||
remaining_ms: 0,
|
||||
})))
|
||||
.expect("发送");
|
||||
});
|
||||
handle.join().expect("线程结束");
|
||||
let task = rx.recv().expect("收到");
|
||||
assert!(matches!(task, HostTask::Toast(toast) if toast.text == "hi"));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 1580 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user