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

本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
This commit is contained in:
AskaEth committed 2026-11-11 21:41:38 +08:00
commit a5d1dec495
1580 files changed
+320938

No files matched your search

+50
View File
@@ -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
+311
View File
@@ -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);
}
}
+165
View File
@@ -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)
}
+393
View File
@@ -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)
}
+362
View File
@@ -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
}
+36
View File
@@ -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()),
},
}
}
+305
View File
@@ -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:?}"),
}
}
}
+400
View File
@@ -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",
}
}
}
+43
View File
@@ -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,
}
}
}
+29
View File
@@ -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(""));
}
}
+144
View File
@@ -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}");
}
}
+167
View File
@@ -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(), "?", "空名字也要有字,不能画白块");
}
+384
View File
@@ -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;
+136
View File
@@ -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());
}
+179
View File
@@ -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,
},
}
+397
View File
@@ -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;
+175
View File
@@ -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()
}
}
+50
View File
@@ -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,
}
}
}
+87
View File
@@ -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(), "装满一页 = 还有下一页");
}
+331
View File
@@ -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,
}
}
}
+87
View File
@@ -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"
);
}
+368
View File
@@ -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");
}
}
+28
View File
@@ -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,
}
+148
View File
@@ -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);
}
+234
View File
@@ -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]);
}
}
+136
View File
@@ -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"));
}
}
+152
View File
@@ -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 },
}
}
}
+78
View File
@@ -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,
}
+99
View File
@@ -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};
+238
View File
@@ -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());
}
}
+73
View File
@@ -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(&notice).expect("序列化");
let back: NoticeView = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, notice);
assert_eq!(back.level.label(), "注意");
}
}
+63
View File
@@ -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,
};
+298
View File
@@ -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,
}
+150
View File
@@ -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;
}
}
+221
View File
@@ -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>,
}
+47
View File
@@ -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,
}
}
}
+374
View File
@@ -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)
}
}
+310
View File
@@ -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;
+88
View File
@@ -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,
}
+192
View File
@@ -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());
}
+247
View File
@@ -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);
}
}
+334
View File
@@ -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);
}
}
+62
View File
@@ -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,
}
+358
View File
@@ -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
);
}
}
+161
View File
@@ -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 });
});
}
}
}
+362
View File
@@ -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"
);
}
+124
View File
@@ -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);
}
}
+321
View File
@@ -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();
}
}
+321
View File
@@ -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))
}
+340
View File
@@ -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);
}
+336
View File
@@ -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(),
})
}
+349
View File
@@ -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()
}
+385
View File
@@ -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);
}
+261
View File
@@ -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;
+217
View File
@@ -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");
}
+347
View File
@@ -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();
}
+314
View File
@@ -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);
}
}
}
}
}
+207
View File
@@ -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();
}
}
+307
View File
@@ -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();
}
}
+208
View File
@@ -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(&current);
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;
+189
View File
@@ -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();
}
+261
View File
@@ -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,
}));
}
}
+116
View File
@@ -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
}
}
+307
View File
@@ -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;
+188
View File
@@ -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, "依赖库要计入文件总数");
}
+219
View File
@@ -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}");
}
+308
View File
@@ -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();
}
}
+201
View File
@@ -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