feat(content): 安装失败的两档分组与「只重试这些」(M191)

M186 把「可只重试失败项」算到了 core/presenter(DependencyPlan::retry_request()),
但 DTO 没字段、界面没按钮,用户拿不到。这一轮补最后一公里:

- DTO:ContentFailureKind 加 Incompatible 一档(不再借 Unsupported,与「缺密钥」分开);
  InstallProgressView 加 retry: Vec<InstallFailureView>(project + 档位 + core 原话),
  档位是专门的 InstallFailureKind(Incompatible / Unreachable,穷尽映射 core 的 FailureKind);
  ContentInstallRequest 加 retry: bool(重试路径唯一读的字段是 instance)。
- presenter:plan.rs 把上一轮失败的计划按 host+实例记住(与 addon/pack.rs 同一手法,
  host/mod.rs 不在 scope);retry_request()/retry_plan() 就是「只重试这些」——
  调 core 的 retry_request()、只请求失败的那几个项目,present 重新扫目录。
  install.rs 的重试入口没有记录时回一条失败,不退化成整单重装;成功则 forget + 清名单。
- 界面:内容库页插入按档分组的失败面板(两段文案、各自条目)+「只重试这 N 个」实心按钮;
  判据只有一条且在模型里(stage==Failed && 名单非空),全成功时连同按钮一起不渲染。
- i18n:新增 6 条中英文案(不兼容 / 取不到 / 只重试这 N 个 / 标题 / 提示 / 关闭),金表同步。

守门(每条都做过变异验证,见报告):
① 把 retry_request() 换成根项目请求 ⇒ retrying_asks_for_the_failed_projects_only 红(roots 变 ["sodium"]);
② 两档合并 ⇒ the_retry_list_keeps_the_two_tiers_apart / the_two_tiers_are_two_groups_with_two_labels 红;
③ 恒渲染 ⇒ a_fully_successful_install_has_no_retry_surface_at_all 红。

数据包(datapack)本轮仍未做:ContentKind 没有这一项,界面上也没有半成品入口。
This commit is contained in:
AskaEth committed 2026-11-09 05:06:04 +08:00
1 parent 5505ea0f91
commit 5a52f38f35
24 files changed
+1072 -24

No files matched your search

+11
View File
@@ -104,6 +104,15 @@ impl App {
self.dispatch(AppCommand::InstallContent(Box::new(request))); self.dispatch(AppCommand::InstallContent(Box::new(request)));
} }
} }
// M191:「只重试上次失败的那几项」。请求构造在 `pages/content/model.rs`
// (`retry_install_request`:载荷带 `retry: true`),这里只做转发。
pages::content::Message::RetryFailed => {
let catalog = unlucky_i18n::Lang::En.catalog();
let model = Content::from_snapshot(&self.state, &self.content, catalog);
if let Some(request) = model.retry_install_request() {
self.dispatch(AppCommand::InstallContent(Box::new(request)));
}
}
// M113: import/export —— 导入/导出区(细节在 `on_pack`) // M113: import/export —— 导入/导出区(细节在 `on_pack`)
pages::content::Message::Pack(message) => self.on_pack(message), pages::content::Message::Pack(message) => self.on_pack(message),
} }
@@ -222,6 +231,8 @@ impl App {
instance: target.id.clone(), instance: target.id.clone(),
game_version: target.mc_version.clone(), game_version: target.mc_version.clone(),
loader: target.loader, loader: target.loader,
// 普通装入:从根项目解起(「只重试这些」是另一条消息,`retry: true`)
retry: false,
}) })
} }
} }
+14 -1
View File
@@ -47,6 +47,16 @@
//! | 3 | 整合包的「装入」是禁用态 | core 没有远程整合包的安装管线 | 导入管线落地后接同一入口 | //! | 3 | 整合包的「装入」是禁用态 | core 没有远程整合包的安装管线 | 导入管线落地后接同一入口 |
//! | 5 | CurseForge 的「装入」同样是禁用态(`Blocker::SourceNotWired`) | 检索通了,**文件端点**还没接(CF 的文件列表要带 key) | 接上 CF 文件端点后删掉这条原因 | //! | 5 | CurseForge 的「装入」同样是禁用态(`Blocker::SourceNotWired`) | 检索通了,**文件端点**还没接(CF 的文件列表要带 key) | 接上 CF 文件端点后删掉这条原因 |
//! | 4 | 安装进度只有阶段级(无字节条) | presenter 依赖表里没有 tokio/futures,拿不到 `ProgressEmitter` 的接收端 | presenter 补依赖后换成字节级 | //! | 4 | 安装进度只有阶段级(无字节条) | presenter 依赖表里没有 tokio/futures,拿不到 `ProgressEmitter` 的接收端 | presenter 补依赖后换成字节级 |
//!
//! ## 安装失败之后:两档分组 + 「只重试这些」(M191)
//!
//! 装入失败且**有可重试项**时,页底那块失败面板换成 [`parts::retry_panel`]:按
//! `InstallFailureKind` 分两段(不兼容 / 取不到,各有自己的词表文案),并给一个「只重试这 N 个」
//! 的实心按钮。按钮发 [`Message::RetryFailed`],app 层翻成 `AppCommand::InstallContent{retry: true}`,
//! host 拿上一轮的计划调 `DependencyPlan::retry_request()` ⇒ **只请求失败的那几个项目**。
//!
//! 判据只有一条且在模型里(`RetryPanel::of`):`stage == Failed && !retry.is_empty()`。
//! 全成功、没有失败项、还在跑,三种情况都得到 `None`——**不渲染**,不是画一个灰按钮。
mod demo; mod demo;
mod layout; mod layout;
@@ -61,7 +71,10 @@ mod view;
mod tests; mod tests;
pub use message::Message; pub use message::Message;
pub use model::{loader_name, Blocker, Content, ContentQueryView, Hit, PageState, Status, Target}; pub use model::{
loader_name, Blocker, Content, ContentQueryView, Hit, PageState, RetryGroup, RetryPanel,
Status, Target,
};
// M113: import/export // M113: import/export
pub use packui::{request_export, Pack, PackMessage, PackPageState}; pub use packui::{request_export, Pack, PackMessage, PackPageState};
+62 -2
View File
@@ -11,7 +11,7 @@
//! | 变量 | 取值 | 作用 | //! | 变量 | 取值 | 作用 |
//! |---|---|---| //! |---|---|---|
//! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) | //! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) |
//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` / `install` / `cf` / `offline` | 选状态(默认 `ok`;`offline` = M162 的未联网态) | //! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` / `install` / `cf` / `offline` / `retryfail` / `retryok` | 选状态(默认 `ok`;`offline` = M162 的未联网态;`retryfail` = M191 的「有失败项」,`retryok` = M191 的「全部成功」) |
//! //!
//! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。 //! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。
@@ -19,10 +19,47 @@ use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind}; use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind};
use super::model::{ use super::model::{
blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target, VersionRow, blockers_of, Content, ContentQueryView, Hit, HitFacts, RetryPanel, Status, Target, VersionRow,
}; };
use crate::pages::download::Download; use crate::pages::download::Download;
/// M191「有失败项」那一屏:两档各几个项目,面板按档分组。
///
/// **走真判据**([`RetryPanel::of`]),不手搓面板字段:出图与产品路径读同一个函数,
/// 判据一改这条演示就跟着变(否则演示图能一直画着一个已经不存在的面板)。
fn retry_failure(instance: &str, catalog: Catalog) -> Option<RetryPanel> {
use unlucky_presenter::dto::{
InstallFailureKind, InstallFailureView, InstallProgressView, InstallStage,
};
let mut progress = InstallProgressView {
version: "Sodium".to_string(),
instance: instance.to_string(),
loader: "Fabric".to_string(),
stage: InstallStage::Failed,
files_done: 1,
files_total: 4,
..InstallProgressView::new()
};
progress.retry = vec![
InstallFailureView::new(
"P7dR8mSH",
InstallFailureKind::Incompatible,
"没有匹配所选游戏版本/加载器(1.20.1 / fabric)的版本",
),
InstallFailureView::new(
"1eAoo2KR",
InstallFailureKind::Unreachable,
"取版本失败(online_not_found):HTTP 404",
),
InstallFailureView::new(
"AANobbMI",
InstallFailureKind::Unreachable,
"下载 fabric-api.jar 失败(online_not_connected):连不上 cdn.modrinth.com",
),
];
RetryPanel::of(&progress, catalog)
}
/// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。 /// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。
fn targets() -> Vec<Target> { fn targets() -> Vec<Target> {
vec![ vec![
@@ -254,6 +291,12 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
} else { } else {
None None
}, },
// M191:`retryfail` 画「有失败项」,`retryok` 画「全部成功」——后者走同一条判据,
// 得到 `None`(连按钮都不渲染)。
retry: match state.as_str() {
"retryfail" => retry_failure("survival", catalog),
_ => None,
},
versions: vec!["1.20.1".to_string(), "1.21.1".to_string()], versions: vec!["1.20.1".to_string(), "1.21.1".to_string()],
available_versions: demo_versions, available_versions: demo_versions,
versions_note: None, versions_note: None,
@@ -265,3 +308,20 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
pack, pack,
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// 「有失败项」那一屏的两档走**真判据**(`RetryPanel::of`),不是手搓的面板字段。
#[test]
fn the_failure_screen_shows_both_tiers() {
let panel = retry_failure("survival", Lang::En.catalog()).expect("演示必须有面板");
assert_eq!(panel.total, 3);
assert!(panel.has_both_tiers(), "{:?}", panel.groups);
assert_eq!(
panel.items_of(unlucky_presenter::dto::InstallFailureKind::Unreachable),
Some(["1eAoo2KR".to_string(), "AANobbMI".to_string()].as_slice())
);
}
}
@@ -37,6 +37,11 @@ pub enum Message {
Retry, Retry,
/// 装进目标实例 /// 装进目标实例
Install, Install,
/// **只重试上次失败的那几项**(M191;不是「整单重装」)
///
/// app 层的落点:`AppCommand::InstallContent` 带 `retry: true`——host 拿上一轮的依赖计划
/// 调 `DependencyPlan::retry_request()`,只重解失败项。
RetryFailed,
/// 导入/导出区(M113:整合包导入与导出;一切细节在 `packui::Message`) /// 导入/导出区(M113:整合包导入与导出;一切细节在 `packui::Message`)
Pack(super::packui::PackMessage), Pack(super::packui::PackMessage),
} }
@@ -32,10 +32,12 @@ use unlucky_i18n::{Catalog, Key};
use crate::pages::download::Download; use crate::pages::download::Download;
mod items; mod items;
mod retry;
pub(crate) use items::HitFacts; pub(crate) use items::HitFacts;
pub(crate) use items::{capitalize, thousands}; pub(crate) use items::{capitalize, thousands};
pub use items::{loader_name, Blocker, Hit, PageState, Status, Target, VersionRow}; pub use items::{loader_name, Blocker, Hit, PageState, Status, Target, VersionRow};
pub use retry::{RetryGroup, RetryPanel};
/// 内容库页要画的东西。 /// 内容库页要画的东西。
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -69,6 +71,11 @@ pub struct Content {
/// 存整个 `Download` 而不是只存一帧视图:面板的入参就是 `&Download`,而 iced 的控件树 /// 存整个 `Download` 而不是只存一帧视图:面板的入参就是 `&Download`,而 iced 的控件树
/// 借用它 ⇒ 被借的东西必须活得比这一帧长(局部临时值不行)。 /// 借用它 ⇒ 被借的东西必须活得比这一帧长(局部临时值不行)。
pub panel: Option<Download>, pub panel: Option<Download>,
/// 「只重试这些」面板(M191):`None` = 不渲染(全成功 / 没有失败项 / 还在跑)。
///
/// 与 `panel` 互斥地画:失败且有可重试项时,这块**顶替**下载页那块通用失败面板,
/// 一屏只有一个失败块(两个会说同一件事,还各自给一个「关闭」)。
pub retry: Option<RetryPanel>,
/// 游戏版本筛选的候选(来自实例;去重、保持实例顺序) /// 游戏版本筛选的候选(来自实例;去重、保持实例顺序)
pub versions: Vec<String>, pub versions: Vec<String>,
/// 关键词草稿 /// 关键词草稿
@@ -187,6 +194,11 @@ impl Content {
target_label, target_label,
blockers, blockers,
panel: content_install(state, catalog), panel: content_install(state, catalog),
// M191:失败且有可重试项时这块才存在(全成功 ⇒ `None` ⇒ 视图里连按钮都没有)
retry: state
.install
.as_ref()
.and_then(|progress| RetryPanel::of(progress, catalog)),
versions, versions,
draft: page.draft.clone(), draft: page.draft.clone(),
pack, pack,
@@ -0,0 +1,149 @@
//! 「只重试这些」面板的视图模型(M191)。
//!
//! ## 它画的是什么
//!
//! 安装失败之后,界面要说清「哪几项没装上、分别是哪种没装上」:
//!
//! ```text
//! 安装未完成 —— 3 项没装上
//! 不兼容所选版本/加载器
//! P7dR8mSH
//! 取不到(网络 / 项目不存在)
//! 1eAoo2KR、AANobbMI
//! [只重试这 3 个] [关闭]
//! ```
//!
//! ## 三条判据(都落在 [`RetryPanel::of`],纯函数,用例直接钉)
//!
//! 1. **全成功不渲染**:`retry` 名单空 ⇒ `None` ⇒ 视图里连按钮都不存在(不是置灰);
//! 2. **两档不混**:分组按 [`InstallFailureKind`] 分开,两档各有自己的标题 key;
//! 3. **只列名单里的**:条目原样来自 DTO(`presenter` 从 `DependencyPlan::retry_request()`
//! 的同一份失败名单产出),页面不自己挑、也不补「顺便重装根」。
//!
//! ## 为什么这里不拼文案
//!
//! 分组标题是 [`Key`](i18n),条目是项目键(站点数据,不是文案)。拼一句带量的中文句子
//! 会把语序写死——英文与中文的量词位置不同(`Retry just these 3` / `只重试这 3 个`)。
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{
ContentInstallRequest, ContentSource, InstallFailureKind, InstallProgressView, InstallStage,
LoaderKind,
};
/// 面板上的一个档位分组。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RetryGroup {
/// 哪一档(顺序固定,见 [`InstallFailureKind::ALL`])
pub kind: InstallFailureKind,
/// 这一档的标题 key(不硬编码中文)
pub title: Key,
/// 这一档的项目键(顺序 = 计划里的顺序)
pub items: Vec<String>,
}
/// 「只重试这些」面板(`None` = 不渲染:没有失败项、或已经全成功)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RetryPanel {
/// 失败的那次装入的目标实例(重试只对这个实例生效)
pub instance: String,
/// 标题(已按词表渲染:带条数)
pub title: String,
/// 主按钮文字(已按词表渲染:`只重试这 N 个`)
///
/// 存在这里而不是画的时候现拼:`Button::new` 要的是 `&'a str`,而词表渲染出来的是
/// `String`——存进模型(与 `Hit` / `VersionRow.line` 同一手法)就不必为了一个标签
/// 去改组件库的入参类型。
pub action: String,
/// 按档分组(空组不出现)
pub groups: Vec<RetryGroup>,
/// 失败项总数(标题与按钮都用这个数)
pub total: usize,
}
impl RetryPanel {
/// 进度快照 → 面板(**唯一**判据;`None` = 视图里不该出现这块)。
///
/// 只认 `Failed` 这一态:`Done` 没有可重试的东西,`Cancelled` 是用户自己停的
/// (那条路上根本没有失败名单),`Planning`/`Downloading` 还在跑。
#[must_use]
pub fn of(progress: &InstallProgressView, catalog: Catalog) -> Option<Self> {
if progress.stage != InstallStage::Failed || !progress.has_retry() {
return None;
}
let total = progress.retry.len();
let count = total.to_string();
let groups = InstallFailureKind::ALL
.into_iter()
.map(|kind| RetryGroup {
kind,
title: title_of(kind),
items: progress
.retry_of(kind)
.map(|item| item.project.clone())
.collect(),
})
.filter(|group| !group.items.is_empty())
.collect();
Some(Self {
instance: progress.instance.clone(),
title: catalog.render(Key::ContentInstallFailedTitle, &[("count", count.as_str())]),
action: catalog.render(Key::ContentInstallRetryOnly, &[("count", count.as_str())]),
groups,
total,
})
}
/// 两档都在吗(「分组」这件事本身的判据:都出现时界面上是两段,不是一段)。
#[must_use]
pub fn has_both_tiers(&self) -> bool {
self.groups.len() == 2
}
/// 某一档的条目(用例断言用)。
#[must_use]
pub fn items_of(&self, kind: InstallFailureKind) -> Option<&[String]> {
self.groups
.iter()
.find(|group| group.kind == kind)
.map(|group| group.items.as_slice())
}
}
/// 档位 → 标题 key(**穷尽 match**:合并两档会少一条文案,用例会红)。
#[must_use]
pub const fn title_of(kind: InstallFailureKind) -> Key {
match kind {
InstallFailureKind::Incompatible => Key::ContentInstallRetryIncompatible,
InstallFailureKind::Unreachable => Key::ContentInstallRetryUnreachable,
}
}
impl super::Content {
/// 「只重试这些」那一次请求(M191;`None` = 没有可重试的面板 ⇒ 这个动作不存在)。
///
/// 载荷里 `retry: true` 是**唯一**的语义开关:host 收到它就不解根项目,而是拿上一轮的计划
/// 调 `DependencyPlan::retry_request()`(只请求失败的那几个项目)。其余字段照旧填,
/// 是因为 `ContentInstallRequest` 的判据仍要能读(`project` / `title` 也用于日志);
/// 这些字段在重试路径上**不被 host 读取**——过滤条件与落盘目录都来自那份计划。
///
/// 实例取**失败那次的**实例,不是页面上此刻选中的那个:用户可能已经换了目标实例。
pub fn retry_install_request(&self) -> Option<ContentInstallRequest> {
let retry = self.retry.as_ref()?;
let hit = self.selected_hit();
let target = self
.targets
.iter()
.find(|target| target.id == retry.instance);
Some(ContentInstallRequest {
project: hit.map(|hit| hit.id.clone()).unwrap_or_default(),
title: hit.map(|hit| hit.title.clone()).unwrap_or_default(),
kind: self.kind,
source: hit.map_or(ContentSource::Modrinth, |hit| hit.source),
instance: retry.instance.clone(),
game_version: target.map_or_else(String::new, |target| target.mc_version.clone()),
loader: target.map_or(LoaderKind::Vanilla, |target| target.loader),
retry: true,
})
}
}
@@ -355,3 +355,10 @@ fn loader_names_are_ascii_so_the_english_baseline_stays_english() {
"不能画「原版」" "不能画「原版」"
); );
} }
// M191:安装失败的两档分组与「只重试这些」(按主题分卷,父文件已贴 400 行硬线)
//
// `#[path]` 显式给出:本文件自己是 `#[path = "model/tests.rs"]` 挂上去的,子模块默认按
// 「父模块所在目录」找,会指到 `model/retry.rs`(视图模型本体)而不是这里的 `tests/retry.rs`。
#[path = "tests/retry.rs"]
mod retry;
@@ -0,0 +1,129 @@
//! M191:安装失败的两档分组 + 「只重试这些」(`super` 按主题分卷,父文件已贴 400 行硬线)。
//!
//! 三条变异守门:两档不许合并 / 全成功不许渲染 / 重试只针对失败那次的实例。
use super::super::{Content, PageState, RetryPanel};
use super::{hit, results, state_with};
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{
InstallFailureKind, InstallFailureView, InstallProgressView, InstallStage,
};
/// 一份「两档都有」的失败进度快照。
fn failed_progress() -> unlucky_presenter::dto::InstallProgressView {
let mut progress = InstallProgressView {
version: "Sodium".to_string(),
instance: "survival".to_string(),
stage: InstallStage::Failed,
files_done: 1,
files_total: 4,
..InstallProgressView::new()
};
progress.retry = vec![
InstallFailureView::new(
"P7dR8mSH",
InstallFailureKind::Incompatible,
"没有匹配的版本",
),
InstallFailureView::new("1eAoo2KR", InstallFailureKind::Unreachable, "HTTP 404"),
InstallFailureView::new("AANobbMI", InstallFailureKind::Unreachable, "连不上 CDN"),
];
progress
}
/// 两档**分组**画,各有自己的标题;条目按档分开,不混成一段。
///
/// 变异守门②:把两档合并(`kind_of` 都映成 `Unreachable`,或 `title_of` 都返回同一个 key)
/// ⇒ `groups.len()` 变 1 / 两个 title 相同 ⇒ 这条红。
#[test]
fn the_two_tiers_are_two_groups_with_two_labels() {
let panel = RetryPanel::of(&failed_progress(), Lang::En.catalog()).expect("有失败项就有面板");
assert_eq!(panel.total, 3);
assert!(panel.has_both_tiers(), "{:?}", panel.groups);
assert_eq!(
panel.items_of(InstallFailureKind::Incompatible),
Some(["P7dR8mSH".to_string()].as_slice())
);
assert_eq!(
panel.items_of(InstallFailureKind::Unreachable),
Some(["1eAoo2KR".to_string(), "AANobbMI".to_string()].as_slice())
);
let titles: Vec<Key> = panel.groups.iter().map(|group| group.title).collect();
assert_ne!(titles[0], titles[1], "两档的标题 key 不许是同一个");
// 文案来自词表(不是硬编码中文):英文目录下这两句必须不同且不含中文
let incompatible = Lang::En.catalog().text(titles[0]);
let unreachable = Lang::En.catalog().text(titles[1]);
assert_ne!(incompatible, unreachable);
assert!(incompatible.is_ascii(), "{incompatible}");
assert!(unreachable.is_ascii(), "{unreachable}");
// 按钮文字带条数,也来自词表
assert!(panel.action.contains('3'), "{}", panel.action);
assert!(panel.action.is_ascii(), "{}", panel.action);
}
/// 只有一档失败时只画一段(空组不出现——空的「取不到」标题会让用户以为那里也坏了)。
#[test]
fn an_empty_tier_does_not_get_a_group() {
let mut progress = InstallProgressView {
stage: InstallStage::Failed,
..InstallProgressView::new()
};
progress.retry = vec![InstallFailureView::new(
"P7dR8mSH",
InstallFailureKind::Incompatible,
"没有匹配的版本",
)];
let panel = RetryPanel::of(&progress, Lang::En.catalog()).expect("有失败项就有面板");
assert!(!panel.has_both_tiers());
assert!(panel.items_of(InstallFailureKind::Unreachable).is_none());
}
/// **全成功时不渲染**:`Done` 且没有失败项 ⇒ 面板不存在(不是置灰,是连构造都没有)。
///
/// 变异守门③:把 `RetryPanel::of` 的判据去掉(恒返回 `Some`)⇒ 这条红。
#[test]
fn a_fully_successful_install_has_no_retry_surface_at_all() {
let done = InstallProgressView {
version: "Sodium".to_string(),
instance: "survival".to_string(),
stage: InstallStage::Done,
files_done: 4,
files_total: 4,
..InstallProgressView::new()
};
assert!(RetryPanel::of(&done, Lang::En.catalog()).is_none());
// 页面上也一样:状态是 Done ⇒ 页底那块既没有重试面板,也没有失败面板
let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false);
state.install = Some(done);
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
assert!(model.retry.is_none(), "全成功还在页面上画重试面板");
assert!(model.panel.is_none());
assert!(model.retry_install_request().is_none());
// 失败但没有名单(例如整合包那条本地失败)同样不画:没有可重试的东西
let mut failed = InstallProgressView {
stage: InstallStage::Failed,
..InstallProgressView::new()
};
failed.retry.clear();
assert!(RetryPanel::of(&failed, Lang::En.catalog()).is_none());
}
/// 「只重试这些」发出去的是**失败那次的实例** + `retry: true`(不是页面此刻选中的实例,
/// 也不是一次新的根项目安装)。
#[test]
fn the_retry_action_targets_the_failed_attempt() {
let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false);
// 名单要有,否则这块入口根本不存在
state.install = Some(failed_progress());
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
assert!(model.retry.is_some());
let request = model.retry_install_request().expect("有面板就该有请求");
assert!(
request.retry,
"重试必须是 retry: true(否则 host 会从根解起)"
);
assert_eq!(request.instance, "survival");
}
@@ -100,6 +100,8 @@ pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView {
eta_secs: 0, eta_secs: 0,
// 整合包导入的失败不产生内容库分类(它有自己的报告视图)。 // 整合包导入的失败不产生内容库分类(它有自己的报告视图)。
failure_kind: None, failure_kind: None,
// 导入没有「只重试这些」这条路径(重试整个导入由导入区自己管)。
retry: Vec::new(),
message: progress.current.clone().unwrap_or_default(), message: progress.current.clone().unwrap_or_default(),
} }
} }
@@ -30,9 +30,11 @@ use super::model::{Content, Hit};
mod detail; mod detail;
mod filters; mod filters;
mod retry;
pub(crate) use detail::detail; pub(crate) use detail::detail;
pub(crate) use filters::{filters, labelled}; pub(crate) use filters::{filters, labelled};
pub(crate) use retry::maybe as retry_panel;
use super::Message as ContentMessage; use super::Message as ContentMessage;
use crate::pages::frame; use crate::pages::frame;
@@ -0,0 +1,139 @@
//! 安装失败后的「只重试这些」面板(M191)。
//!
//! ## 为什么这块画在内容库,而不是复用下载页那块失败面板
//!
//! 下载页的失败面板讲的是「停在哪一步 + 打开日志」,而这里要讲的是**哪几项没装上、属于哪种
//! 没装上、以及能不能只重试它们**。两件事混在一个块里会得到「失败 + 一堆条目 + 两个关闭」。
//! 所以:有可重试名单时([`RetryPanel::of`] 给了 `Some`)这块**顶替**通用失败面板,
//! 一屏只有一个失败块。
//!
//! ## 三个控件行为
//!
//! | 控件 | 消息 | 落点 |
//! |---|---|---|
//! | 只重试这 N 个 | [`ContentMessage::RetryFailed`] | `AppCommand::InstallContent{retry: true}`(host 只重解失败项) |
//! | 关闭 | [`PageMessage::CancelInstall`] | 清 install 快照(对没在跑的安装是安全 no-op,与下载页同一个出口) |
//!
//! 两个按钮都**按不动就不画**:没有名单时整块不存在(不是画一个灰按钮)。
use iced::widget::{column, container, row};
use iced::{Alignment, Background, Border, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::buttons::{self, Button};
use crate::theme::Ui;
use super::super::model::{Content, RetryPanel};
use super::intent;
use crate::pages::content::Message as ContentMessage;
use crate::pages::frame;
use crate::pages::Message as PageMessage;
/// 整块面板(`model.retry` 为 `None` 时调用方不该调它——判据在模型里,不在这里)。
pub(crate) fn retry_panel<'a>(
ui: &'a Ui,
catalog: Catalog,
panel: &'a RetryPanel,
) -> Element<'a, PageMessage> {
let head = row![
frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX),
frame::text_role(
ui,
TypeRole::TitleSmall,
true,
panel.title.as_str(),
ui.color(Role::Error),
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
let mut body = column![head]
.spacing(ui.space(Space::X2))
.width(Length::Fill);
// 两档分成两段:**各自的标题 + 各自的条目**(合并成一段就说不出「换版本还是检查网络」)。
// 条目之间用 ASCII 逗号:项目键是站点数据,句读不是文案,不该在这里挑中文的「、」。
for group in &panel.groups {
let items = group.items.join(", ");
body = body.push(
column![
frame::text_role(
ui,
TypeRole::LabelMedium,
true,
catalog.text(group.title),
ui.color(Role::OnErrorContainer),
),
frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
items,
ui.color(Role::OnErrorContainer),
),
]
.spacing(ui.space(Space::X1)),
);
}
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentInstallRetryHint),
ui.color(Role::OnErrorContainer),
));
let actions = row![
// 主按钮:只重试这些(这个块存在的**唯一**理由)
buttons::filled(
ui,
Button::new(panel.action.as_str(), intent(ContentMessage::RetryFailed))
.leading(IconKind::Refresh),
),
buttons::text(
ui,
Button::new(
catalog.text(Key::ContentInstallDismiss),
PageMessage::CancelInstall
),
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
body = body.push(actions);
container(body)
.width(Length::Fill)
.padding(ui.space(Space::X4))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::ErrorContainer))),
border: Border {
radius: ui.radius_dp(ShapeScale::Large),
..Border::default()
},
..container::Style::default()
})
.into()
}
/// 整页底部那块(判据在 [`Content::retry`];`None` ⇒ 一帧都不画)。
pub(crate) fn maybe<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
) -> Option<Element<'a, PageMessage>> {
model
.retry
.as_ref()
.map(|panel| retry_panel(ui, catalog, panel))
}
@@ -85,3 +85,16 @@ fn the_demo_selection_is_always_one_of_the_hits() {
); );
} }
} }
/// M191:「全部成功」那一屏(默认态)**没有**重试面板——出图与判据同源,不是手搓的。
#[test]
fn the_default_demo_screen_has_no_retry_surface() {
for kind in ContentKind::ALL {
let model = demo(kind, Lang::En);
assert!(
model.retry.is_none(),
"{} 默认态不该有重试面板",
kind.slug()
);
}
}
+6 -1
View File
@@ -98,7 +98,12 @@ fn body<'a>(
.spacing(ui.space(Space::X3)) .spacing(ui.space(Space::X3))
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill); .height(Length::Fill);
if let Some(panel) = &model.panel { // M191:有可重试的失败项时,这块**顶替**下载页那块通用失败面板(一屏只有一个失败块);
// 没有名单时它连构造都不存在(`None`),不是画一个灰按钮。
let retry = parts::retry_panel(ui, catalog, model);
if let Some(panel) = retry {
stack = stack.push(divider::full(ui)).push(panel);
} else if let Some(panel) = &model.panel {
// **同一块安装面板**(下载页那个函数):内容库不另画一套进度/失败/取消。 // **同一块安装面板**(下载页那个函数):内容库不另画一套进度/失败/取消。
stack = stack stack = stack
.push(divider::full(ui)) .push(divider::full(ui))
@@ -297,6 +297,8 @@ fn install(stage: InstallStage, ratio: f32, catalog: Catalog) -> InstallView {
message: String::new(), message: String::new(),
// 演示里这次失败不是内容库那条路来的,不给分类(面板照旧画「失败 + 文案」)。 // 演示里这次失败不是内容库那条路来的,不给分类(面板照旧画「失败 + 文案」)。
failure_kind: None, failure_kind: None,
// 同理:没有失败名单 ⇒ 内容库那块「只重试这些」不出现(下载页也不画它)。
retry: Vec::new(),
}, },
catalog, catalog,
) )
+24
View File
@@ -95,6 +95,30 @@ macro_rules! content_entries {
"Installing from CurseForge is not available yet", "Installing from CurseForge is not available yet",
"CurseForge 来源的安装尚未开放" "CurseForge 来源的安装尚未开放"
), ),
// --- M191:安装失败的两档分组与「只重试这些」---
// 两档**各一条**:不兼容要换版本/加载器,取不到要检查网络,合并成一句会让用户
// 对着一条重试必然失败的项反复点重试。
ContentInstallFailedTitle => (
"Install did not finish — {count} items failed",
"安装未完成 —— {count} 项没装上"
),
ContentInstallRetryIncompatible => (
"Not available for this version/loader",
"不兼容所选版本/加载器"
),
ContentInstallRetryUnreachable => (
"Could not fetch (network or missing project)",
"取不到(网络 / 项目不存在)"
),
ContentInstallRetryOnly => (
"Retry just these {count}",
"只重试这 {count} 个"
),
ContentInstallRetryHint => (
"Retry asks again only for the items above; files that already landed stay put.",
"重试只重新解析上面这几项,已落盘的文件不动。"
),
ContentInstallDismiss => ("Dismiss", "关闭"),
// --- M113: pack import / export(PACKUI)--- // --- M113: pack import / export(PACKUI)---
PackTitle => ("Modpack import & export", "整合包导入 / 导出"), PackTitle => ("Modpack import & export", "整合包导入 / 导出"),
PackImportTitle => ("Import a modpack", "导入整合包"), PackImportTitle => ("Import a modpack", "导入整合包"),
+6
View File
@@ -386,6 +386,12 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("ContentModpackNotReady", "Installing modpacks from the library into an instance is not available yet", "从内容库一键安装整合包到实例还没做(本地的包可以直接导入)"), ("ContentModpackNotReady", "Installing modpacks from the library into an instance is not available yet", "从内容库一键安装整合包到实例还没做(本地的包可以直接导入)"),
("ContentSourceUnavailable", "CurseForge needs an API key: add one in Settings", "CurseForge 需要在设置里配置密钥才能搜索"), ("ContentSourceUnavailable", "CurseForge needs an API key: add one in Settings", "CurseForge 需要在设置里配置密钥才能搜索"),
("ContentSourceNotWired", "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放"), ("ContentSourceNotWired", "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放"),
("ContentInstallFailedTitle", "Install did not finish — {count} items failed", "安装未完成 —— {count} 项没装上"),
("ContentInstallRetryIncompatible", "Not available for this version/loader", "不兼容所选版本/加载器"),
("ContentInstallRetryUnreachable", "Could not fetch (network or missing project)", "取不到(网络 / 项目不存在)"),
("ContentInstallRetryOnly", "Retry just these {count}", "只重试这 {count} 个"),
("ContentInstallRetryHint", "Retry asks again only for the items above; files that already landed stay put.", "重试只重新解析上面这几项,已落盘的文件不动。"),
("ContentInstallDismiss", "Dismiss", "关闭"),
("PackTitle", "Modpack import & export", "整合包导入 / 导出"), ("PackTitle", "Modpack import & export", "整合包导入 / 导出"),
("PackImportTitle", "Import a modpack", "导入整合包"), ("PackImportTitle", "Import a modpack", "导入整合包"),
("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"), ("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"),
+10
View File
@@ -284,6 +284,16 @@ pub struct ContentInstallRequest {
pub game_version: String, pub game_version: String,
/// 目标实例的加载器(选文件用) /// 目标实例的加载器(选文件用)
pub loader: LoaderKind, pub loader: LoaderKind,
/// **只重试上次失败的那些项目**(M191)。
///
/// `true` 时 host 不再解根项目:它拿上一次装入留下的依赖计划调
/// `DependencyPlan::retry_request()`,只重解失败项(`present` 重新扫一遍目录),于是
/// 「已装好的不重下、没失败的不重发」。这种模式下本结构里**只有 `instance` 会被读**——
/// 过滤条件与落盘目录都来自那份计划。
///
/// 没有可重试的计划时(换进程 / 那一次是别的实例)一律回一条失败,**不**退化成「整单重装」:
/// 那正是本字段要避免的事。
pub retry: bool,
} }
/// 内容库页的状态(M109):**检索条件 + 最近一批结果 + 请求态**。 /// 内容库页的状态(M109):**检索条件 + 最近一批结果 + 请求态**。
@@ -34,6 +34,12 @@ pub enum ContentFailureKind {
Server, Server,
/// 这条路走不通(缺密钥 / 没有可用下载地址 / 摘要格式不对) /// 这条路走不通(缺密钥 / 没有可用下载地址 / 摘要格式不对)
Unsupported, Unsupported,
/// **站点有这个项目,但目标版本/加载器下没有条目**(M191;core 的 `FailureKind::Incompatible`)
///
/// 与 [`Self::NotFound`] / [`Self::Offline`] 是**两种用户动作**:这里换版本或换加载器才有救,
/// 重试同样的条件只会得到同一条结论。以前它落在 `Unsupported`(与「缺密钥」共用一档),
/// 于是界面没法单独说「换个版本」——M191 把这一档单独拎出来。
Incompatible,
/// 其它(解析失败、本地 IO……) /// 其它(解析失败、本地 IO……)
Other, Other,
} }
@@ -47,10 +53,17 @@ impl ContentFailureKind {
Self::NotFound => "not_found", Self::NotFound => "not_found",
Self::Server => "server", Self::Server => "server",
Self::Unsupported => "unsupported", Self::Unsupported => "unsupported",
Self::Incompatible => "incompatible",
Self::Other => "other", Self::Other => "other",
} }
} }
/// 是不是「所选版本/加载器下没有条目」(换版本/加载器才有救,重试没用)。
#[must_use]
pub const fn is_incompatible(self) -> bool {
matches!(self, Self::Incompatible)
}
/// 是不是「没联网」(页面据此画独立的离线态,而不是通用失败态)。 /// 是不是「没联网」(页面据此画独立的离线态,而不是通用失败态)。
#[must_use] #[must_use]
pub const fn is_offline(self) -> bool { pub const fn is_offline(self) -> bool {
+83
View File
@@ -47,6 +47,65 @@ impl InstallStage {
} }
} }
/// 一条可只重试的失败的**档位**(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()` 用的同一份)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstallFailureView {
/// 请求它用的项目键(slug 或 id;重试也用它)
pub project: String,
/// 哪一档
pub kind: InstallFailureKind,
/// core 给的人读说明(引擎原话 + 上下文,**不是**界面文案)
pub detail: String,
}
impl InstallFailureView {
/// 构造。
#[must_use]
pub fn new(
project: impl Into<String>,
kind: InstallFailureKind,
detail: impl Into<String>,
) -> Self {
Self {
project: project.into(),
kind,
detail: detail.into(),
}
}
}
/// 一次安装的进度快照。 /// 一次安装的进度快照。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstallProgressView { pub struct InstallProgressView {
@@ -77,6 +136,12 @@ pub struct InstallProgressView {
/// 放在进度快照里而不是只塞进文案:`message` 是给用户看的一句话,"没联网" 与 /// 放在进度快照里而不是只塞进文案:`message` 是给用户看的一句话,"没联网" 与
/// "这个项目不存在" 该画两种提示,判据不能靠那句话里有没有"网络"两个字。 /// "这个项目不存在" 该画两种提示,判据不能靠那句话里有没有"网络"两个字。
pub failure_kind: Option<ContentFailureKind>, pub failure_kind: Option<ContentFailureKind>,
/// 可只重试的失败项(M191):空 = 没有失败项 ⇒ 界面**不渲染**那条入口。
///
/// 每条自带档位([`InstallFailureKind`]),界面按档分组;`project` 就是
/// `DependencyPlan::retry_request()` 会发出去的那几个项目键——名单由 plan 产出,
/// 界面不自己拼(拼一份就会与重试实际请求的那几个不一致)。
pub retry: Vec<InstallFailureView>,
} }
impl Default for InstallProgressView { impl Default for InstallProgressView {
@@ -101,6 +166,7 @@ impl InstallProgressView {
eta_secs: 0, eta_secs: 0,
message: String::new(), message: String::new(),
failure_kind: None, failure_kind: None,
retry: Vec::new(),
} }
} }
@@ -159,6 +225,23 @@ impl InstallProgressView {
} }
format!("{}/{} 文件", self.files_done, self.files_total) 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`)。 /// 字节数 → 人读(`1.2 MB`)。
+4 -1
View File
@@ -45,7 +45,10 @@ pub use content::{
ContentSource, ContentState, ContentVersionView, ContentSource, ContentState, ContentVersionView,
}; };
pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch}; pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch};
pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage}; pub use install::{
derive_speed, human_bytes, InstallFailureKind, InstallFailureView, InstallProgressView,
InstallStage,
};
pub use instances::{ pub use instances::{
CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchFailureCause, CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchFailureCause,
LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, Progress, LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, Progress,
+92 -8
View File
@@ -14,12 +14,18 @@
//! | 整合包 | 一律 `Failed` + 一句原因 | **未做**:core 没有「远程整合包装进实例」的管线(M111) | //! | 整合包 | 一律 `Failed` + 一句原因 | **未做**:core 没有「远程整合包装进实例」的管线(M111) |
//! | CurseForge 文件解析 | 一律 `Failed` + 一句原因 | **未做**:需要带 key 的文件端点,本轮只做了 CF 的**检索** | //! | CurseForge 文件解析 | 一律 `Failed` + 一句原因 | **未做**:需要带 key 的文件端点,本轮只做了 CF 的**检索** |
//! //!
//! ## 前置的两档失败与重试名单(M186) //! ## 前置的两档失败与重试名单(M186 / M191)
//! //!
//! 解析在 [`super::plan`] + `core::addon::online::resolve`:某个前置「不兼容所选版本/加载器」 //! 解析在 [`super::plan`] + `core::addon::online::resolve`:某个前置「不兼容所选版本/加载器」
//! 与「取不到(网络 / 项目不存在)」是**两条**记录,本文件把两档分开写进失败文案,并把失败项 //! 与「取不到(网络 / 项目不存在)」是**两条**记录,本文件把两档分开写进失败文案,并把失败项
//! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户(或将来 UI 的按钮)再发一次, //! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户点(M191 的界面入口)。
//! `DependencyPlan::retry_request()` 已经把请求备好了。 //!
//! ## 「只重试这些」真的只重试这些(M191)
//!
//! 上一轮失败时,那份 `DependencyPlan` 被 [`plan::remember`] 记下来;`ContentInstallRequest.retry`
//! 为真的第二次请求走 [`run`] 的重试入口——它调 core 的 `DependencyPlan::retry_request()`,
//! 于是重解**只请求失败的那几个项目**(`present` 重新扫一遍目录,已装好的不会被重下)。
//! 没有记录时回一条失败,**不**退化成「整单重装」:那会把已装好的再下一遍、请求数放大。
//! //!
//! 下载阶段的失败**也进同一套名单**(M186 r1):以前 `download_verified` 一失败就整单中断, //! 下载阶段的失败**也进同一套名单**(M186 r1):以前 `download_verified` 一失败就整单中断,
//! 用户既看不到「主文件已装、缺 fabric-api」,也拿不到可重试的项目。现在逐个文件下、失败记进 //! 用户既看不到「主文件已装、缺 fabric-api」,也拿不到可重试的项目。现在逐个文件下、失败记进
@@ -40,11 +46,11 @@
//! 面板在「进度未知」时本来就只画一行说明、不画条(`download/install.rs` 的既定口径)。 //! 面板在「进度未知」时本来就只画一行说明、不画条(`download/install.rs` 的既定口径)。
//! 已登记为后续项:presenter 补 `futures-util` 之后换成字节级。 //! 已登记为后续项:presenter 补 `futures-util` 之后换成字节级。
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use unlucky_core::addon::online::{ use unlucky_core::addon::online::{
resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode, resolve, DependencyPlan, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode,
}; };
use unlucky_core::download::blocking::block_on; use unlucky_core::download::blocking::block_on;
use unlucky_core::game::install::instance::read_instance_config; use unlucky_core::game::install::instance::read_instance_config;
@@ -78,7 +84,8 @@ pub(super) fn install(
// 整合包:core 没有这条管线(M111),**不**假装能装。UI 侧按钮本来就是禁用的, // 整合包:core 没有这条管线(M111),**不**假装能装。UI 侧按钮本来就是禁用的,
// 这里再挡一次是为了「绕过 UI 直接发指令」也不会静默做半件事。 // 这里再挡一次是为了「绕过 UI 直接发指令」也不会静默做半件事。
if request.kind.dir().is_none() { // 重试模式不看 `kind`:那种模式下落盘目录来自上一次的计划(见 `run`)。
if !request.retry && request.kind.dir().is_none() {
fail( fail(
tx, tx,
progress, progress,
@@ -134,6 +141,13 @@ fn fail(tx: &JobSender, mut progress: InstallProgressView, failure: ContentFailu
/// ///
/// 返回「装了哪些 / 跳过了哪些」的人读描述;解析阶段的两档失败由 /// 返回「装了哪些 / 跳过了哪些」的人读描述;解析阶段的两档失败由
/// [`plan::failure_of_plan`] 变成一条带大类的失败(**前置缺了不算装成**)。 /// [`plan::failure_of_plan`] 变成一条带大类的失败(**前置缺了不算装成**)。
///
/// 两条解析入口,别混:
///
/// | 入口 | 请求从哪来 | 发几次 |
/// |---|---|---|
/// | 首次装入 | 根项目 + 过滤条件([`plan::request`]) | 一次,从根开始 BFS |
/// | **只重试失败项** | 上一轮计划的 `retry_request()`([`plan::retry_plan`]) | 一次,**只含失败的那几个项目** |
fn run( fn run(
env: &HostEnv, env: &HostEnv,
request: &ContentInstallRequest, request: &ContentInstallRequest,
@@ -143,6 +157,38 @@ fn run(
epoch: u64, epoch: u64,
) -> Result<String, ContentFailure> { ) -> Result<String, ContentFailure> {
let instance = find_instance(env, &request.instance)?; let instance = find_instance(env, &request.instance)?;
// 「只重试这些」:输入是上一轮那份计划,**不**重新发起根项目安装。
// 没有记录时(换了实例 / 进程重启过)回一条失败,而不是退化成「整单重装」。
if request.retry {
let Some((target_dir, previous)) = plan::remembered(env, &request.instance) else {
return Err(local(RETRY_LOST));
};
progress.version = previous
.request
.roots
.first()
.cloned()
.unwrap_or_else(|| request.instance.clone());
let present = plan::present_files(&target_dir);
let core_engine = super::super::engine::engine(env);
let dependency_plan = plan::retry_plan(&previous, present, |retry| {
let transport = EngineTransport::new(&core_engine);
let client = Modrinth::new(&transport);
block_on(resolve(&client, retry))
});
return collect(
env,
request,
dependency_plan,
&target_dir,
cancel,
progress,
tx,
epoch,
);
}
let game_dir = game_dir_of(env, &instance)?; let game_dir = game_dir_of(env, &instance)?;
let dir = request let dir = request
.kind .kind
@@ -165,12 +211,41 @@ fn run(
plan::present_files(&target_dir), plan::present_files(&target_dir),
); );
let core_engine = super::super::engine::engine(env); let core_engine = super::super::engine::engine(env);
let mut dependency_plan = { let dependency_plan = {
let transport = EngineTransport::new(&core_engine); let transport = EngineTransport::new(&core_engine);
let client = Modrinth::new(&transport); let client = Modrinth::new(&transport);
block_on(resolve(&client, &resolve_request)) block_on(resolve(&client, &resolve_request))
}; };
collect(
env,
request,
dependency_plan,
&target_dir,
cancel,
progress,
tx,
epoch,
)
}
/// 解析之后**两条入口共用**的尾巴:进度 → 逐个下载 → 收口。
///
/// 收口的两件事在同一个地方做,是因为它们必须同时发生:
///
/// | 结果 | 计划 | 可重试名单 |
/// |---|---|---|
/// | 有失败 | 记下(`plan::remember`,重试的输入) | 填(界面据此画「只重试这 N 个」) |
/// | 全成功 | 忘掉(`plan::forget`) | 清空(界面**不渲染**那条入口) |
fn collect(
env: &HostEnv,
request: &ContentInstallRequest,
mut dependency_plan: DependencyPlan,
target_dir: &Path,
cancel: &AtomicBool,
progress: &mut InstallProgressView,
tx: &JobSender,
epoch: u64,
) -> Result<String, ContentFailure> {
progress.stage = InstallStage::Downloading; progress.stage = InstallStage::Downloading;
progress.files_total = dependency_plan.nodes.len() as u64; progress.files_total = dependency_plan.nodes.len() as u64;
progress.files_done = 0; progress.files_done = 0;
@@ -192,11 +267,12 @@ fn run(
epoch, epoch,
}); });
let core_engine = super::super::engine::engine(env);
let fetcher = EngineArtifactFetcher::new(&core_engine); let fetcher = EngineArtifactFetcher::new(&core_engine);
// 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。 // 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。
let installed = downloads::download_stage( let installed = downloads::download_stage(
&mut dependency_plan, &mut dependency_plan,
&target_dir, target_dir,
&fetcher, &fetcher,
cancel, cancel,
progress, progress,
@@ -207,11 +283,19 @@ fn run(
// 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」), // 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」),
// 并列出「可只重试这 N 个」。 // 并列出「可只重试这 N 个」。
if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) { if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) {
progress.retry = plan::retry_items(&dependency_plan);
plan::remember(env, &request.instance, target_dir, &dependency_plan);
return Err(failure); return Err(failure);
} }
progress.retry.clear();
plan::forget(env, &request.instance);
Ok(plan::summary(&dependency_plan, &installed)) Ok(plan::summary(&dependency_plan, &installed))
} }
/// 没有可重试的记录时那句话(**不**退化成「整单重装」)。
const RETRY_LOST: &str =
"没有可重试的记录:这次失败发生在别的实例上、或进程重启过。请重新装入那一次。";
/// 完成态:`files_done` 对齐 `files_total`。 /// 完成态:`files_done` 对齐 `files_total`。
/// ///
/// 别在这里另写数字:以前写死 `1`,装 3 个文件后终态是 `1/3`,下载页只要「已知」就画确定态 /// 别在这里另写数字:以前写死 `1`,装 3 个文件后终态是 `1/3`,下载页只要「已知」就画确定态
+130 -10
View File
@@ -17,23 +17,29 @@
//! 所以这里既不合并计数也不合并文案;DTO 大类按「用户接下来该做什么」选:离线优先说 //! 所以这里既不合并计数也不合并文案;DTO 大类按「用户接下来该做什么」选:离线优先说
//! 检查网络,其次查无此项,只有**全是**不兼容时才说「这条路走不通」(换版本/加载器才有救)。 //! 检查网络,其次查无此项,只有**全是**不兼容时才说「这条路走不通」(换版本/加载器才有救)。
//! //!
//! # DTO 没有「不兼容」这一档,所以它落在 `Unsupported`(说清楚,别当它是笔误) //! # 「可只重试这些」的名单也在这里(M191)
//! //!
//! `ContentFailureKind` 的五档里没有「站点有这个项目、但所选版本/加载器下没有条目」这一档 //! [`retry_items`] 把计划里的失败项摊成 DTO 的 [`InstallFailureView`](项目键 + 档位 + 原话),
//! (M186 已提 finding)。它**不是** `NotFound`(那是「站点说没这个项目」)也**不是** `Offline`, //! 界面据此分组画两段;[`retry_plan`] 是**真正重试**那一步——它调 core 的
//! 最贴的只有 `Unsupported`(「这条路走不通」)——所以根项目「没有匹配版本」的 DTO 大类由 //! `DependencyPlan::retry_request()`,于是重试只请求失败的那几个项目,**不是**重新发起根项目安装。
//! M162 的 `NotFound` 改成了 `Unsupported`,语义矫正而非回归;文案里的 //!
//! 「不兼容所选版本/加载器」才是给用户看的那句话。UI 当前只按 `is_offline()` 分流,无可见差异。 //! # 不兼容有自己的一档(M191 起)
//!
//! M186 把它映成 `Unsupported`(当时 DTO 没有这一档,与「缺密钥」共用),于是界面没法单独说
//! 「换个版本/加载器」。M191 给 `ContentFailureKind` 加了 `Incompatible`,[`kind_of`] 直接映射,
//! **不再借 `Unsupported`**。
use std::path::Path; use std::path::{Path, PathBuf};
use std::sync::Mutex;
use unlucky_core::addon::online::{ use unlucky_core::addon::online::{
DependencyFailure, DependencyPlan, FailureKind, OnlineError, PlannedNode, PresentFile, DependencyFailure, DependencyPlan, FailureKind, OnlineError, PlannedNode, PresentFile,
ResolveRequest, ResolveRequest,
}; };
use crate::dto::ContentFailureKind; use crate::dto::{ContentFailureKind, InstallFailureKind, InstallFailureView};
use super::super::env::HostEnv;
use super::sources::failure_of; use super::sources::failure_of;
/// 扫目标目录(不递归)里已存在的文件名。 /// 扫目标目录(不递归)里已存在的文件名。
@@ -98,7 +104,8 @@ fn overall_kind(plan: &DependencyPlan) -> ContentFailureKind {
return wanted; return wanted;
} }
} }
ContentFailureKind::Unsupported // 只剩「不兼容」这一档:换版本/加载器才有救(M191 起它有自己的大类,不再借 `Unsupported`)
ContentFailureKind::Incompatible
} }
/// 单条失败的 DTO 大类(**复用** `sources::failure_of` 的映射表,不另写一份判据)。 /// 单条失败的 DTO 大类(**复用** `sources::failure_of` 的映射表,不另写一份判据)。
@@ -106,10 +113,66 @@ fn kind_of(failure: &DependencyFailure) -> ContentFailureKind {
match failure.cause { match failure.cause {
Some(cause) => failure_of(OnlineError::new(cause, failure.message.clone())).kind, Some(cause) => failure_of(OnlineError::new(cause, failure.message.clone())).kind,
// 不兼容那一档没有网络成因:它要用户换版本/加载器,不是「再试一次」 // 不兼容那一档没有网络成因:它要用户换版本/加载器,不是「再试一次」
None => ContentFailureKind::Unsupported, None => ContentFailureKind::Incompatible,
} }
} }
/// 可只重试的失败项 → DTO 名单(M191;界面按档分组画的那几条)。
///
/// 档位**一一映射** core 的 [`FailureKind`]:两档在 DTO 上分开,界面才有两段文案。
/// 顺序沿用计划里的顺序(`record_failure` 已按项目键去重,同一项目只出现一次)。
#[must_use]
pub(super) fn retry_items(plan: &DependencyPlan) -> Vec<InstallFailureView> {
plan.failures
.iter()
.map(|failure| {
InstallFailureView::new(
failure.requested_as.clone(),
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 { fn report(plan: &DependencyPlan, installed: usize) -> String {
let mut lines: Vec<String> = Vec::new(); let mut lines: Vec<String> = Vec::new();
@@ -199,6 +262,63 @@ fn unresolved_note(plan: &DependencyPlan) -> Option<String> {
)) ))
} }
/// 「上一次装入留下的计划」表(M191):`只重试这些` 的输入。
///
/// ## 为什么要一张静态表(说清楚这笔债)
///
/// 重试要的输入是**上一轮那份 `DependencyPlan`**(`retry_request()` 是它的方法)。它现在活在
/// `CoreHost`(`host/dispatch.rs`)之外,因为那个文件不在本 mission 的 scope;与
/// `addon/pack.rs` 的 `PACK_STATE` 同一手法:每 host 一格(键 = 数据根),于是测试里两个 host
/// 互不干扰。`host/mod.rs` 一旦进 scope,把这张表搬成一个字段即可(这表就是那个字段)。
///
/// 键里带 `instance`:同一台机器上往两个实例装过东西,重试只该重试这个实例那一次的计划。
/// 成功之后由 [`forget`] 清掉——「全成功」时不该留下任何可重试的东西。
struct Remembered {
host: PathBuf,
instance: String,
/// 落盘目录(重试要按它重新扫 `present`,且**不许**换目录)
dir: PathBuf,
plan: DependencyPlan,
}
static REMEMBERED: Mutex<Vec<Remembered>> = Mutex::new(Vec::new());
/// 记下这一轮的计划(**覆盖**同一个 host + 实例的上一份)。
pub(super) fn remember(env: &HostEnv, instance: &str, dir: &Path, 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(Remembered {
host,
instance: instance.to_string(),
dir: dir.to_path_buf(),
plan: plan.clone(),
});
}
/// 忘掉这个实例的计划(装成功 / 没有失败项时调:没有可重试的东西了)。
pub(super) 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));
}
/// 这个实例上一次失败的计划(+ 当时的落盘目录);`None` = 没有可重试的记录。
pub(super) fn remembered(env: &HostEnv, instance: &str) -> Option<(PathBuf, DependencyPlan)> {
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| (item.dir.clone(), item.plan.clone()))
}
/// 全部装成之后的落盘描述(含跳过与前置的条数、「为什么装」的父项目)。 /// 全部装成之后的落盘描述(含跳过与前置的条数、「为什么装」的父项目)。
/// ///
/// `installed` 是**真落盘**的那些(下载失败的不在里面),所以条数与文件名都从它来, /// `installed` 是**真落盘**的那些(下载失败的不在里面),所以条数与文件名都从它来,
+7 -1
View File
@@ -192,7 +192,10 @@ fn an_incompatible_only_plan_is_not_reported_as_a_download_failure() {
Vec::new(), Vec::new(),
); );
let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条"); let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条");
assert_eq!(failure.kind, ContentFailureKind::Unsupported); // 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 个"));
assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH")); assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH"));
assert!(!failure.detail.contains("下载失败"), "{}", failure.detail); assert!(!failure.detail.contains("下载失败"), "{}", failure.detail);
@@ -377,3 +380,6 @@ fn a_required_dependency_without_a_project_id_is_reported() {
"{message}" "{message}"
); );
} }
// M191:「只重试这些」的名单与重试路径(按主题分卷,父文件已贴 400 行硬线)
mod retry;
@@ -0,0 +1,150 @@
//! M191:「只重试这些」的两档名单 + 真重试路径(`super` 的用例按主题分卷,这一卷专管重试)。
//!
//! 三条判据对应的变异守门在各自的文档里写清了「改了什么这条会红」。
use super::{failure, node, plan};
use crate::dto::{InstallFailureKind, InstallFailureView};
use crate::host::addon::plan::{
failure_of_plan, forget, remember, remembered, retry_items, retry_plan, retry_request,
};
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());
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).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, &plan);
let (saved_dir, saved) = remembered(&env, "survival").expect("记住了这一次");
assert_eq!(saved_dir, dir);
assert_eq!(saved.failures.len(), 2);
// 别的实例不受影响(同一台机器上装过两个实例)
assert!(remembered(&env, "modded").is_none());
// 重试请求从「记住的计划」里取:仍然只含失败项
let request = retry_request(&saved, 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);
let request = retry_request(&plan, Vec::new());
assert_eq!(
items
.iter()
.map(|item| item.project.clone())
.collect::<Vec<_>>(),
request.roots
);
}