fix(packui): M113 r1 复审整改 —— 2 处「界面说假话」+ 7 条必修 P2
复审 r1(p1-2items / fix-then-merge)逐条整改。**P2-6(core 的本地化契约)按塔台指示本轮不动**,
已登记为独立 mission 的债。
## P1
1. **「Save to」是死输入框**:`ExportRequestView` 的唯一构造点硬编码 `target: None`,用户敲的路径
从不进请求。现在构造收成纯函数 `packui::model::export::request_of`(预检与确认共用同一份),
`target` 由字段去空白后给出(空 ⇒ `None`,交给 host 给缺省)。
2. **取消导入的 toast 撒谎**:下载页那条说「安装已取消、实例未创建」,而导入会在后台跑完并
**登记实例**。现在 `cancel_pack()` 回报「刚才有没有导入在跑」,`dispatch` 据此改发
`PackEvent::Cancelled`(不再发 `InstallCancelled`),界面与提示改成
「导入会在后台继续跑完 —— 本次结果作废;已写下的文件会留下」,并把这句话**留在屏上**
(`ImportState.cancelled` + 面板位)。
## P2 必修(7 条)
3. **`Dismiss` 误伤下载页安装**(跨功能污染,最高优先):pack 原来共用 `CoreHost::install_epoch`,
关一次导入面板会把下载页正在跑的安装静默丢掉。现在 pack 有自己的
`PACK_EPOCH`(模块级 `AtomicU64`),`handle` 的 pack 分支只比它,`Dismiss`/取消只动它。
4. **中文实例名塌成 `pack`**:新增 `convert::dir_name` —— 名字全 ASCII 安全字符时保持原样
(`Better MC` → `Better-MC`),一旦折过东西(中文/空格/符号)就补 `-<hash8>`(FNV-1a),
保证两个中文名得到不同目录。
5. **失败回滚后空目录挡住重试**:`target_of` 对「已存在但空」的目录**复用**(非空才拒),
并在失败回滚后清掉自己建的空目录;`failed_report` 的 `target` 语义改正
(以前把包路径塞进「目标实例目录」)。
6. **新实例流程下「以包为准」必然失败**:`can_overwrite` 追加 `page.instance.is_some()` ——
新建实例那条路上不再显示一个必然失败的按钮。
7. **阶段词打架且文档说修好了**:`progress.rs` 的模块文档改成与屏幕一致(粗/细两层 + 已知重叠),
并写清彻底解决要靠「安装面板提成共享组件」那条 finding。
8. **导入侧标题计数与行数对不上**:被拒 component 有了自己的标题(`The client does not support
these: {count}`);冲突标题改成中性的 `Conflicts: {count}`(逐条自带成因与处置)。
9. **预检「没进包」列表没有有界滚动**:13 条是真实形态。列表改成有界滚动(110px),
并且**把动作按钮提到摘要下面**(`Write the pack` / `Back`)—— 明细再长也顶不掉按钮。
10. **导入成功后不刷新实例列表**:`handle` 的 pack 分支在收到 `Report` 时调 `refresh_instances()`
(与 `InstallDone` 同一条),新实例不用等下一次无关的 `RefreshAll`。
## 证据
- 新增测试(都跑真管线/真函数):`the_save_to_field_really_reaches_the_request`、
`pack_epoch_is_its_own_counter`、`chinese_instance_names_get_distinct_directories`、
`a_failed_new_instance_directory_can_be_reused_for_retry`、
`overwrite_is_offered_only_when_importing_into_an_existing_instance`、
`cancelled_state_is_visible_and_says_the_import_keeps_running`。
- 出图重出 12 张(6 态 × 浅深),新增 `06-cancelled-*`;`04-export-*` 用 13 条 skipped 的真实形态。
门禁:`cargo fmt --all --check` 通过;`cargo clippy -p unlucky-app-iced -p unlucky-presenter
-p unlucky-i18n --all-targets -- -D warnings` EXIT=0;`cargo test -p unlucky-app-iced --lib`
507 passed;`cargo test -p unlucky-presenter --lib` 184 passed;`cargo test -p unlucky-i18n --lib`
24 passed;全部改动文件 ≤400 行。
This commit is contained in:
1 parent
27bbc74eaa
commit
99538e45b9
23 files changed
+474
-74
No files matched your search
@@ -244,27 +244,14 @@ impl App {
|
||||
|
||||
/// 组一份导出请求(预检与写盘**共用同一份**,确认时原样发回)。
|
||||
///
|
||||
/// 没有选中实例就没有导出对象 ⇒ `None`。包名/版本留空时给缺省值:
|
||||
/// 包名 = 实例 id、版本 = 页面上的 `1.0.0`。
|
||||
/// 没有选中实例就没有导出对象 ⇒ `None`。**「Save to」必须真的进请求**
|
||||
/// (复审 r1 P1-1:以前这里硬编码 `target: None`,那个输入框是死的)。
|
||||
fn pack_export_request(&self) -> Option<ExportRequestView> {
|
||||
let instance = self.content.pack.instance.clone()?;
|
||||
let name = self.content.pack.export_name.trim();
|
||||
let version = self.content.pack.export_version.trim();
|
||||
Some(ExportRequestView {
|
||||
instance: instance.clone(),
|
||||
format: self.content.pack.format,
|
||||
name: if name.is_empty() {
|
||||
instance
|
||||
} else {
|
||||
name.to_string()
|
||||
},
|
||||
version: if version.is_empty() {
|
||||
"1.0.0".to_string()
|
||||
} else {
|
||||
version.to_string()
|
||||
},
|
||||
target: None,
|
||||
})
|
||||
Some(pages::content::request_export(
|
||||
&self.content.pack,
|
||||
&instance,
|
||||
))
|
||||
}
|
||||
|
||||
/// 拖入一个文件/目录(`app/update.rs` 的窗口事件转发到这里)。
|
||||
|
||||
@@ -63,7 +63,7 @@ mod tests;
|
||||
pub use message::Message;
|
||||
pub use model::{loader_name, Blocker, Content, ContentQueryView, Hit, PageState, Status, Target};
|
||||
// M113: import/export
|
||||
pub use packui::{Pack, PackMessage, PackPageState};
|
||||
pub use packui::{request_export, Pack, PackMessage, PackPageState};
|
||||
|
||||
use iced::Element;
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ mod view;
|
||||
mod tests;
|
||||
|
||||
pub use message::Message as PackMessage;
|
||||
pub use model::{Pack, PackPageState};
|
||||
pub use model::{request_export, Pack, PackPageState};
|
||||
|
||||
/// 区入口(内容库页的正列调它)。
|
||||
pub(crate) use view::section;
|
||||
|
||||
@@ -86,6 +86,8 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) {
|
||||
fatal.bytes_written = 0;
|
||||
import.report = Some(fatal);
|
||||
}
|
||||
// 取消态:面板收回了,但「后台还在跑」这句话要留在屏上
|
||||
"cancelled" => import.cancelled = true,
|
||||
"export" => {
|
||||
page.export_open = true;
|
||||
page.export_name = "Survival 1.20.1".to_string();
|
||||
@@ -210,10 +212,20 @@ fn preview() -> ExportPreviewView {
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
// 13 条「没进包」是真实形态:预检那块必须自己做有界滚动,否则把「确认写盘」顶出屏幕
|
||||
for index in 0..12 {
|
||||
files.push(ExportFileView {
|
||||
path: format!("mods/.cache/backup-{index:02}.jar"),
|
||||
class: ExportClassView::Skipped,
|
||||
reason: ExportReasonView::SymbolicLink,
|
||||
bytes: 0,
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
files.push(ExportFileView {
|
||||
path: "mods/.cache/backup.jar".to_string(),
|
||||
path: "mods/bad:name.jar".to_string(),
|
||||
class: ExportClassView::Skipped,
|
||||
reason: ExportReasonView::SymbolicLink,
|
||||
reason: ExportReasonView::UnsafePath,
|
||||
bytes: 0,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ mod export;
|
||||
mod progress;
|
||||
mod report;
|
||||
|
||||
pub use export::{format_label, Export, Preview};
|
||||
pub use export::{format_label, request_of as request_export, Export, Preview};
|
||||
pub use progress::Running;
|
||||
pub use report::{ConflictRow, Report, Row};
|
||||
|
||||
@@ -125,6 +125,11 @@ pub struct Pack {
|
||||
pub running: Option<Running>,
|
||||
/// 探测失败诊断
|
||||
pub probe: Option<Probe>,
|
||||
/// 用户取消过这次导入:面板收回但**后台还在跑**(core 没有取消令牌),
|
||||
/// 所以要把这句话留在界面上,别让用户以为「没建成」(复审 r1 P1-2)。
|
||||
pub cancelled: bool,
|
||||
/// 取消提示的正文
|
||||
pub cancelled_note: &'static str,
|
||||
/// 导入结果
|
||||
pub report: Option<Report>,
|
||||
/// 导出块
|
||||
@@ -170,6 +175,8 @@ impl Pack {
|
||||
.as_ref()
|
||||
.map(|progress| progress::running_of(progress, catalog)),
|
||||
probe: import.probe.as_ref().map(|probe| probe_of(probe, catalog)),
|
||||
cancelled: import.cancelled,
|
||||
cancelled_note: catalog.text(Key::PackCancelled),
|
||||
report: import
|
||||
.report
|
||||
.as_ref()
|
||||
@@ -183,6 +190,7 @@ impl Pack {
|
||||
self.running.is_none()
|
||||
&& self.probe.is_none()
|
||||
&& self.report.is_none()
|
||||
&& !self.cancelled
|
||||
&& !self.export.open
|
||||
&& self.draft.is_empty()
|
||||
}
|
||||
|
||||
@@ -18,12 +18,38 @@
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{
|
||||
human_bytes, ExportClassView, ExportFileView, ExportPreviewView, ExportReasonView, ExportState,
|
||||
PackFormatView,
|
||||
human_bytes, ExportClassView, ExportFileView, ExportPreviewView, ExportReasonView,
|
||||
ExportRequestView, ExportState, PackFormatView,
|
||||
};
|
||||
|
||||
use super::{PackPageState, PackTarget, Row};
|
||||
|
||||
/// 页内选择 → 导出请求(预检与写盘**共用同一份**)。
|
||||
///
|
||||
/// `target` 必须从这里进去(复审 r1 P1-1):之前全仓唯一构造点硬编码 `target: None`,
|
||||
/// 于是用户敲的「Save to」永远不进请求 —— 输入框是死的。
|
||||
#[must_use]
|
||||
pub fn request_of(page: &PackPageState, instance: &str) -> ExportRequestView {
|
||||
let name = page.export_name.trim();
|
||||
let version = page.export_version.trim();
|
||||
let output = page.output.trim();
|
||||
ExportRequestView {
|
||||
instance: instance.to_string(),
|
||||
format: page.format,
|
||||
name: if name.is_empty() {
|
||||
instance.to_string()
|
||||
} else {
|
||||
name.to_string()
|
||||
},
|
||||
version: if version.is_empty() {
|
||||
"1.0.0".to_string()
|
||||
} else {
|
||||
version.to_string()
|
||||
},
|
||||
target: (!output.is_empty()).then(|| std::path::PathBuf::from(output)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 预检里「内联文件样例」最多列几条(其余用一句「还有 N 个」收口)。
|
||||
const INLINE_SAMPLE: usize = 6;
|
||||
|
||||
|
||||
@@ -8,13 +8,16 @@
|
||||
//! `InstallProgressView` → [`InstallView`]);
|
||||
//! 2. 画法交给 `pages::download::install_panel`(同一个函数、同一套几何,进度条 / 计数 /
|
||||
//! 取消按钮都归它);
|
||||
//! 3. 任务书要的「解包 / overrides / 补文件」三档由**一行步骤说明**([`stage_line`])承载,
|
||||
//! 面板右边那个阶段词**留空**。
|
||||
//! 3. 任务书要的「解包 / overrides / 补文件」三档由**一行步骤说明**([`stage_line`])承载。
|
||||
//!
|
||||
//! 第 3 点的理由:面板自带的四个阶段词是**安装**语义(规划 / 下载 / 生成版本 / 登记实例),
|
||||
//! 套到导入上会出现「Step 2/5 · Unpacking overrides」旁边写着「Building the version」——
|
||||
//! 同一块地方两种说法(出图时实测到了)。留空 + 由步骤行说真话,是**不动那个组件**的最小解;
|
||||
//! 多出来的只有一句文字,没有第二条进度条。
|
||||
//! **已知重叠(如实写,别再说成修好了)**:面板自带的阶段词是**安装**语义
|
||||
//! (`Resolving version` / `Downloading files` / `Building the version` / `Registering instance`),
|
||||
//! 而 `install_panel_model` 永远会填它(M109 合流 M105 后 `InstallView` 不再对外导出,
|
||||
//! 没法从外面塞一个自定义标签或清空)。所以屏幕上同时出现「Step 2/5 · Unpacking overrides」
|
||||
//! 与「Building the version」:**粗/细两层,不是两句矛盾的话**,但 `Extract`/`Commit` 都映射到
|
||||
//! `Installing`,那一档的用词偏「安装」。
|
||||
//!
|
||||
//! 要彻底解决得让面板接受调用方给的阶段词(已报 finding:把安装面板提成共享组件)。
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{
|
||||
|
||||
@@ -84,6 +84,8 @@ pub struct Report {
|
||||
pub conflicts_title: Option<String>,
|
||||
/// 冲突逐条
|
||||
pub conflicts: Vec<ConflictRow>,
|
||||
/// 被拒 component 的标题(带计数,与下面逐条行数一致)
|
||||
pub rejected_title: Option<String>,
|
||||
/// 被拒 component 逐条
|
||||
pub rejected: Vec<Row>,
|
||||
/// 注记逐条
|
||||
@@ -144,6 +146,12 @@ pub(super) fn report_of(
|
||||
.iter()
|
||||
.map(|file| conflict_row(file, page.expanded.as_deref(), catalog))
|
||||
.collect(),
|
||||
rejected_title: (!report.rejected.is_empty()).then(|| {
|
||||
catalog.render(
|
||||
Key::PackReportRejected,
|
||||
&[("count", &report.rejected.len().to_string())],
|
||||
)
|
||||
}),
|
||||
rejected: report
|
||||
.rejected
|
||||
.iter()
|
||||
@@ -153,7 +161,12 @@ pub(super) fn report_of(
|
||||
})
|
||||
.collect(),
|
||||
warnings: report.warnings.clone(),
|
||||
can_overwrite: !report.conflicts.is_empty() && report.status == ImportStatusView::Partial,
|
||||
// 「以包为准重新导入」只在**导入到已有实例**时有意义(复审 r1 P2-2):新建实例那条路上
|
||||
// 目标目录已经建好,再点一次必然撞「directory already exists」——一个必然失败的按钮
|
||||
// 等于死按钮,所以那种流程下不显示。
|
||||
can_overwrite: !report.conflicts.is_empty()
|
||||
&& report.status == ImportStatusView::Partial
|
||||
&& page.instance.is_some(),
|
||||
overwrite_note: (!report.conflicts.is_empty())
|
||||
.then(|| catalog.text(Key::PackOverwriteNote).to_owned()),
|
||||
}
|
||||
|
||||
@@ -261,7 +261,11 @@ fn conflicts_default_to_keeping_the_user_copy() {
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
};
|
||||
let page = PackPageState::default();
|
||||
// 「以包为准」只在导入到**已有实例**时给(复审 r1 P2-2),所以这里选一个实例
|
||||
let page = PackPageState {
|
||||
instance: Some("survival".to_string()),
|
||||
..PackPageState::default()
|
||||
};
|
||||
let block = report_of(&report, &page, catalog());
|
||||
assert_eq!(block.conflicts.len(), 1);
|
||||
assert!(!block.conflicts[0].expanded, "默认收起");
|
||||
@@ -276,7 +280,7 @@ fn conflicts_default_to_keeping_the_user_copy() {
|
||||
// 展开的那一条才画三方哈希
|
||||
let expanded = PackPageState {
|
||||
expanded: Some("config/x.json".to_string()),
|
||||
..PackPageState::default()
|
||||
..page.clone()
|
||||
};
|
||||
let block = report_of(&report, &expanded, catalog());
|
||||
assert!(block.conflicts[0].expanded);
|
||||
@@ -294,3 +298,97 @@ fn an_idle_pack_has_nothing_to_draw_but_the_entry() {
|
||||
assert!(!model.drop_hint.is_empty());
|
||||
assert!(!model.no_dialog_note.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_save_to_field_really_reaches_the_request() {
|
||||
// 复审 r1 P1-1:这个字段以前是死的(构造点硬编码 target: None)
|
||||
let page = PackPageState {
|
||||
output: " D:/packs/my.mrpack ".to_string(),
|
||||
..PackPageState::default()
|
||||
};
|
||||
let request = super::export::request_of(&page, "survival");
|
||||
assert_eq!(
|
||||
request.target.as_deref(),
|
||||
Some(std::path::Path::new("D:/packs/my.mrpack")),
|
||||
"用户敲的路径必须真的进请求(去首尾空白)"
|
||||
);
|
||||
|
||||
// 留空 ⇒ None(由 host 给缺省路径),不是空串路径
|
||||
let blank = PackPageState {
|
||||
output: " ".to_string(),
|
||||
..PackPageState::default()
|
||||
};
|
||||
assert!(super::export::request_of(&blank, "survival")
|
||||
.target
|
||||
.is_none());
|
||||
|
||||
// 包名/版本留空时的缺省值也钉一下(预检与写盘共用同一份请求,不能各算一套)
|
||||
let request = super::export::request_of(&blank, "survival");
|
||||
assert_eq!(request.name, "survival");
|
||||
assert_eq!(request.version, "1.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrite_is_offered_only_when_importing_into_an_existing_instance() {
|
||||
// 复审 r1 P2-2:新建实例那条路上再点「以包为准」必然撞「directory already exists」
|
||||
let report = unlucky_presenter::dto::ImportReportView {
|
||||
status: ImportStatusView::Partial,
|
||||
format: Some(PackFormatView::Modrinth),
|
||||
pack_name: Some("Pack".to_string()),
|
||||
pack_version: None,
|
||||
game_version: None,
|
||||
loader: None,
|
||||
version_id: None,
|
||||
target: std::path::PathBuf::from("C:/tmp"),
|
||||
files: Vec::new(),
|
||||
conflicts: vec![ConflictFileView {
|
||||
path: "config/x.json".to_string(),
|
||||
reason: ConflictReasonView::UserModified,
|
||||
choice: ConflictChoiceView::KeepUser,
|
||||
pack_sha1: "abc".to_string(),
|
||||
base_sha1: None,
|
||||
disk_sha1: None,
|
||||
pack_size: 1,
|
||||
}],
|
||||
missing: Vec::new(),
|
||||
rejected: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
error: None,
|
||||
registered: None,
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
};
|
||||
|
||||
let new_instance = PackPageState::default();
|
||||
assert!(
|
||||
!super::report::report_of(&report, &new_instance, catalog()).can_overwrite,
|
||||
"新建实例流程下不该出现「以包为准」(那按钮必然失败)"
|
||||
);
|
||||
|
||||
let existing = PackPageState {
|
||||
instance: Some("survival".to_string()),
|
||||
..PackPageState::default()
|
||||
};
|
||||
assert!(
|
||||
super::report::report_of(&report, &existing, catalog()).can_overwrite,
|
||||
"导入到已有实例时才给翻档动作"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_state_is_visible_and_says_the_import_keeps_running() {
|
||||
// 复审 r1 P1-2:面板收回之后,界面上必须留一句「后台还在跑」
|
||||
let import = unlucky_presenter::dto::ImportState {
|
||||
cancelled: true,
|
||||
..unlucky_presenter::dto::ImportState::default()
|
||||
};
|
||||
let export = unlucky_presenter::dto::ExportState::default();
|
||||
let model = Pack::of(&import, &export, &PackPageState::default(), &[], catalog());
|
||||
assert!(model.cancelled);
|
||||
assert!(
|
||||
model.cancelled_note.to_lowercase().contains("background"),
|
||||
"取消提示必须说清后台还在跑:{}",
|
||||
model.cancelled_note
|
||||
);
|
||||
assert!(!model.is_idle(), "取消态不能被当成「什么都没有」");
|
||||
}
|
||||
@@ -92,6 +92,25 @@ pub(crate) fn section<'a>(
|
||||
block = block.push(running_block(ui, catalog, running));
|
||||
} else if let Some(report) = &model.report {
|
||||
block = block.push(report_block(ui, catalog, report));
|
||||
} else if model.cancelled {
|
||||
// 取消过、还没有新结果:把「后台还在跑」这句话留在屏上(复审 r1 P1-2)
|
||||
block = block.push(
|
||||
column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
model.cancelled_note,
|
||||
ui.color(Role::Tertiary),
|
||||
),
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(catalog.text(Key::PackDismiss), intent(PackMessage::Dismiss)),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill),
|
||||
);
|
||||
} else {
|
||||
block = block.push(entry(ui, catalog, model));
|
||||
if let Some(probe) = &model.probe {
|
||||
|
||||
@@ -139,14 +139,8 @@ pub(super) fn export_block<'a>(
|
||||
));
|
||||
}
|
||||
if let Some(preview) = &export.preview {
|
||||
// 动作在 `preview_block` 里紧跟摘要(明细再长也不会把「确认写盘」顶出屏幕)
|
||||
block = block.push(preview_block(ui, catalog, preview));
|
||||
block = block.push(buttons::filled(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::PackExportConfirm),
|
||||
intent(PackMessage::Confirm),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +220,29 @@ pub(super) fn preview_block<'a>(
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
|
||||
// **动作紧跟摘要**:明细(13 条 skipped + 样例 + 已知损失)再长,也不会把
|
||||
// 「Write the pack」顶出 900px(复审 r1 P2-3 的正解:先给结论与动作,明细往下滚)
|
||||
block = block.push(
|
||||
row![
|
||||
buttons::filled(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::PackExportConfirm),
|
||||
intent(PackMessage::Confirm),
|
||||
),
|
||||
),
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::PackExportAbort),
|
||||
intent(PackMessage::AbortExport)
|
||||
),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
|
||||
if !preview.skipped.is_empty() {
|
||||
// 标题写成「Not in the pack: 13」而不是「逐条列出」:13 条**确实**全列出来了,
|
||||
// 但下面那个「…还有 N 个(这里不展开)」说的是**内联样例**,两句挨着容易被读成矛盾
|
||||
@@ -236,7 +253,14 @@ pub(super) fn preview_block<'a>(
|
||||
&[("count", &preview.skipped.len().to_string())],
|
||||
),
|
||||
));
|
||||
block = block.push(rows_block(ui, catalog, &preview.skipped));
|
||||
// 13 条是真实形态:摊开会把「Write the pack」顶出 900px(复审 r1 P2-3)
|
||||
block = block.push(bounded(
|
||||
ui,
|
||||
rows_block(ui, catalog, &preview.skipped),
|
||||
preview.skipped.len(),
|
||||
3,
|
||||
110.0,
|
||||
));
|
||||
}
|
||||
|
||||
// 样例 + 已知损失一起套有界滚动:预检是「摊开给你看」的块,条目多时它会把
|
||||
@@ -292,7 +316,7 @@ pub(super) fn preview_block<'a>(
|
||||
}
|
||||
}
|
||||
let extras_rows = preview.inline_sample.len() + preview.notes.len();
|
||||
block = block.push(bounded(ui, extras, extras_rows, 4, 160.0));
|
||||
block = block.push(bounded(ui, extras, extras_rows, 3, 120.0));
|
||||
|
||||
block.into()
|
||||
}
|
||||
@@ -131,8 +131,17 @@ pub(super) fn report_block<'a>(
|
||||
.width(Length::Fill),
|
||||
);
|
||||
}
|
||||
if !report.rejected.is_empty() {
|
||||
block = block.push(rows_block(ui, catalog, &report.rejected));
|
||||
if let Some(title) = &report.rejected_title {
|
||||
// 没有这句的话,「Could not fetch (tolerated): 3」下面会多出一行没有归属的
|
||||
// 「not supported by the client」(复审 r1 P2-4)
|
||||
block = block.push(
|
||||
column![
|
||||
tolerable_title(ui, title.clone()),
|
||||
rows_block(ui, catalog, &report.rejected),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(title) = &report.conflicts_title {
|
||||
|
||||
@@ -135,10 +135,18 @@ macro_rules! content_entries {
|
||||
"有 {count} 个文件没搞到(可容忍)"
|
||||
),
|
||||
PackReportConflicts => (
|
||||
"You had changed (yours kept by default): {count}",
|
||||
"{count} 个文件你改过(默认保留你的)"
|
||||
"Conflicts: {count}",
|
||||
"冲突:{count} 个"
|
||||
),
|
||||
PackReportRejected => (
|
||||
"The client does not support these: {count}",
|
||||
"这些组件客户端不支持:{count} 个"
|
||||
),
|
||||
PackReportRejectedNote => ("not supported by the client", "客户端不支持"),
|
||||
PackCancelled => (
|
||||
"Import keeps running in the background — this result is discarded. Files already written stay.",
|
||||
"导入会在后台继续跑完 —— 本次结果作废;已经写下的文件会留下。"
|
||||
),
|
||||
PackOverwriteAction => ("Re-import, the pack wins", "以包为准重新导入"),
|
||||
PackOverwriteNote => (
|
||||
"Keeping both (renamed) is not supported yet: the import pipeline has one global conflict policy and no per-file decision channel.",
|
||||
|
||||
@@ -248,8 +248,10 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
||||
("PackStatusFailed", "Import failed — rolled back", "导入失败 —— 已整包回滚"),
|
||||
("PackReportWritten", "Files written: {count} · {bytes}", "{count} 个文件已写盘 · {bytes}"),
|
||||
("PackReportMissing", "Could not fetch (tolerated): {count}", "有 {count} 个文件没搞到(可容忍)"),
|
||||
("PackReportConflicts", "You had changed (yours kept by default): {count}", "{count} 个文件你改过(默认保留你的)"),
|
||||
("PackReportConflicts", "Conflicts: {count}", "冲突:{count} 个"),
|
||||
("PackReportRejected", "The client does not support these: {count}", "这些组件客户端不支持:{count} 个"),
|
||||
("PackReportRejectedNote", "not supported by the client", "客户端不支持"),
|
||||
("PackCancelled", "Import keeps running in the background — this result is discarded. Files already written stay.", "导入会在后台继续跑完 —— 本次结果作废;已经写下的文件会留下。"),
|
||||
("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"),
|
||||
("PackOverwriteNote", "Keeping both (renamed) is not supported yet: the import pipeline has one global conflict policy and no per-file decision channel.", "「两者都留(改名)」暂不支持:导入管线只有一套全局冲突策略,没有逐文件决策通道。"),
|
||||
("PackMissingOffline", "offline import", "离线导入"),
|
||||
|
||||
@@ -57,6 +57,9 @@ pub enum PackEvent {
|
||||
Exported(Box<ExportDoneView>),
|
||||
/// 导出/预检在 host 侧失败(实例找不到、目录派生失败……一句人读原因)
|
||||
ExportFailed(String),
|
||||
/// 用户取消了导入:面板收回,但后台那次导入会跑完(core 没有取消令牌),
|
||||
/// 所以留一位给界面说清「还在跑、结果作废」。
|
||||
Cancelled,
|
||||
/// 关掉所有面板
|
||||
Cleared,
|
||||
}
|
||||
@@ -105,6 +108,10 @@ impl PackState {
|
||||
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();
|
||||
|
||||
@@ -331,6 +331,9 @@ pub struct ProbeView {
|
||||
pub struct ImportState {
|
||||
/// 正在跑的进度(`None` = 没在跑)。
|
||||
pub progress: Option<ImportProgressView>,
|
||||
/// 用户点了取消(M113 r1):**面板收回但后台还在跑**,所以这一位要留在界面上——
|
||||
/// 否则用户会以为「没建成」,回头再导一次就撞「directory already exists」。
|
||||
pub cancelled: bool,
|
||||
/// 最近一次的结果(`None` = 还没导过)。
|
||||
pub report: Option<ImportReportView>,
|
||||
/// 探测失败的具名诊断(有它就让用户看清「像 X 缺 Y」,而不是一句「无法识别」)。
|
||||
|
||||
@@ -38,7 +38,7 @@ mod install;
|
||||
mod pack;
|
||||
mod sources;
|
||||
|
||||
pub(super) use pack::cancel_pack;
|
||||
pub(super) use pack::{cancel_pack, pack_epoch};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -32,7 +32,11 @@
|
||||
//! 2. 这一轮之后回来的事件因代次不符被 `handle` 丢掉;
|
||||
//! 3. **后台那次导入会继续跑到结束**——core 的 `ImportRequest` 没有取消令牌,进度回调也拿不到
|
||||
//! 控制流。所以「取消」的真实含义是「不再显示、结果作废」,不是「把它停手」。要真停必须给
|
||||
//! core 加 cancel token(已登记 finding,报告里同样划出来)。
|
||||
//! core 加 cancel token(已登记 finding,报告里同样划出来);
|
||||
//! 4. **提示语不能借用安装那条**(复审 r1 P1-2):下载页的取消 toast 写着「实例未创建」,
|
||||
//! 而导入会把实例**登记上**。所以 `cancel_pack()` 回一个「刚才有没有导入在跑」,
|
||||
//! `dispatch` 据此改发 [`crate::dto::PackEvent::Cancelled`],界面上换成
|
||||
//! 「导入会在后台继续跑完 —— 本次结果作废」这句。
|
||||
|
||||
mod convert;
|
||||
mod diagnose;
|
||||
@@ -43,7 +47,7 @@ mod import;
|
||||
mod tests;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use unlucky_core::addon::export::{read_instance_snapshot, InstanceSnapshot};
|
||||
@@ -59,6 +63,27 @@ use crate::dto::{
|
||||
use super::super::env::HostEnv;
|
||||
use super::super::job::{HostTask, JobSender};
|
||||
|
||||
/// **pack 自己的**任务代次(与安装那条计数器完全分开)。
|
||||
///
|
||||
/// 为什么必须分开(复审 r1 P2-7):`HostTask::Install` / `InstallDone` 按 `CoreHost::install_epoch`
|
||||
/// 丢包。如果 pack 也共用那个数,用户在内容页关一次导入面板就会把代次 +1 —— 下载页**正在装版本**
|
||||
/// 的进度与收尾会被静默丢弃(面板卡住、不刷实例、没有失败提示)。所以 pack 用自己的一条,
|
||||
/// `CoreHost` 的字段不在本 mission 的 scope,这条计数器就落在模块里。
|
||||
static PACK_EPOCH: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// 当前 pack 代次(`handle` 用它判断「这条事件是不是已经被取消/关面板作废了」)。
|
||||
pub(in crate::host) fn pack_epoch() -> u64 {
|
||||
PACK_EPOCH.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 作废在飞的一切 pack 事件(取消 / 关面板都走它)。
|
||||
pub(in crate::host) fn bump_pack_epoch() -> u64 {
|
||||
PACK_EPOCH.fetch_add(1, Ordering::SeqCst).wrapping_add(1)
|
||||
}
|
||||
|
||||
/// 有一次导入正在跑吗(取消时要据此决定弹哪条提示)。
|
||||
static PACK_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 正在跑的那次导入的取消标记(模块级槽:`CoreHost` 的字段不在本 mission 的 scope)。
|
||||
///
|
||||
/// 语义见模块文档「取消是界面级的」——它挡的是**事件**,不是管线。
|
||||
@@ -67,16 +92,28 @@ use super::super::job::{HostTask, JobSender};
|
||||
static PACK_CANCEL: Mutex<Option<Arc<AtomicBool>>> = Mutex::new(None);
|
||||
|
||||
/// 取消正在跑的导入(`CancelInstall` 与「关面板」都走它;没在跑时是 no-op)。
|
||||
pub(in crate::host) fn cancel_pack() {
|
||||
///
|
||||
/// 返回值 = 「刚才确实有一次导入在跑」。调用方据此决定提示语:
|
||||
/// **导入的取消不是 install 的取消**(后台会跑完并登记实例,见模块文档),所以不能复用
|
||||
/// 下载页那条「安装已取消、实例未创建」的提示。
|
||||
pub(in crate::host) fn cancel_pack() -> bool {
|
||||
if let Ok(slot) = PACK_CANCEL.lock() {
|
||||
if let Some(cancel) = slot.as_ref() {
|
||||
cancel.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
cancel.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
bump_pack_epoch();
|
||||
PACK_RUNNING.swap(false, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 这次导入跑完了(正常收尾也走它):`PACK_RUNNING` 归零,之后 `cancel_pack()` 会回 false。
|
||||
pub(super) fn finished() {
|
||||
PACK_RUNNING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 新一次导入的取消标记(同时把它挂进模块级槽,供 [`cancel_pack`] 用)。
|
||||
fn fresh_cancel() -> Arc<AtomicBool> {
|
||||
PACK_RUNNING.store(true, Ordering::SeqCst);
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
if let Ok(mut slot) = PACK_CANCEL.lock() {
|
||||
*slot = Some(Arc::clone(&cancel));
|
||||
@@ -87,7 +124,7 @@ fn fresh_cancel() -> Arc<AtomicBool> {
|
||||
/// `AppCommand::Pack` 的落点(M113)。
|
||||
impl super::super::CoreHost {
|
||||
pub(crate) fn pack(&mut self, command: PackCommand) {
|
||||
let epoch = self.install_epoch;
|
||||
let epoch = pack_epoch();
|
||||
match command {
|
||||
PackCommand::Import(request) => {
|
||||
let cancel = fresh_cancel();
|
||||
@@ -109,9 +146,8 @@ impl super::super::CoreHost {
|
||||
export::spawn_write(self.env.clone(), self.tx.clone(), *request, epoch);
|
||||
}
|
||||
PackCommand::Dismiss => {
|
||||
// 只作废 pack 自己的代次:**不碰** `install_epoch`(那是下载页安装的命门)
|
||||
cancel_pack();
|
||||
// 代次 +1:这次之后回来的旧事件一律作废
|
||||
self.install_epoch = self.install_epoch.wrapping_add(1);
|
||||
self.send(CoreEvent::Pack(Box::new(PackEvent::Cleared)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,8 +167,10 @@ fn loader_label(target: &LoaderTarget) -> String {
|
||||
/// 「命令跑起来了,但目标实例不可用」这类**host 侧**失败的报告。
|
||||
///
|
||||
/// 仍然走三态里的 `Failed`:界面只有一种失败形状,用户不用学第二套。
|
||||
/// `target` 是**目标实例目录**(不知道就给空路径),不是包路径(复审 r1 P2-9 顺手纠的 DTO 语义)。
|
||||
pub(super) fn failed_report(
|
||||
pack: PathBuf,
|
||||
target: PathBuf,
|
||||
pack: &Path,
|
||||
code: &str,
|
||||
label: &str,
|
||||
message: &str,
|
||||
@@ -176,12 +178,12 @@ pub(super) fn failed_report(
|
||||
ImportReportView {
|
||||
status: ImportStatusView::Failed,
|
||||
format: None,
|
||||
pack_name: Some(pack_stem(&pack)),
|
||||
pack_name: Some(pack_stem(pack)),
|
||||
pack_version: None,
|
||||
game_version: None,
|
||||
loader: None,
|
||||
version_id: None,
|
||||
target: pack,
|
||||
target,
|
||||
files: Vec::new(),
|
||||
conflicts: Vec::new(),
|
||||
missing: Vec::new(),
|
||||
@@ -206,6 +208,35 @@ pub(super) fn pack_stem(pack: &Path) -> String {
|
||||
.unwrap_or_else(|| "imported-pack".to_string())
|
||||
}
|
||||
|
||||
/// **实例目录名**:ASCII 化 + 必要时补一段内容哈希。
|
||||
///
|
||||
/// 为什么不能只用 [`slug`](复审 r1 P2-8):它只保留 ASCII,于是「我的整合包」「另一个整合包」
|
||||
/// 都会得到 `pack`,第二个中文名导入直接撞「directory already exists」。这个仓的主要受众就是
|
||||
/// 中文用户,所以:名字本身就全是 ASCII 安全字符时保持原样(`Better MC` → `Better-MC`),
|
||||
/// **一旦折过东西**(中文/空格/符号)就补 `-<hash8>`,用原名的哈希保住区分度。
|
||||
pub(super) fn dir_name(name: &str) -> String {
|
||||
let base = slug(name);
|
||||
let faithful = !name.is_empty()
|
||||
&& name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_');
|
||||
if faithful && base != "pack" {
|
||||
base
|
||||
} else {
|
||||
format!("{base}-{}", hash8(name))
|
||||
}
|
||||
}
|
||||
|
||||
/// FNV-1a 64 → 8 位十六进制(只要稳定、短、不需要密码学强度)。
|
||||
fn hash8(text: &str) -> String {
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for byte in text.as_bytes() {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
format!("{:08x}", hash as u32)
|
||||
}
|
||||
|
||||
/// 目录/文件名用的 slug(只留 ASCII 字母数字与 `-` / `_`,其余折成 `-`)。
|
||||
pub(super) fn slug(text: &str) -> String {
|
||||
let mut out = String::new();
|
||||
|
||||
@@ -17,6 +17,7 @@ use unlucky_core::addon::import::{
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
|
||||
use crate::dto::{ImportRequestView, PackEvent};
|
||||
use unlucky_core::addon::import::ImportStatus;
|
||||
|
||||
use super::super::super::env::HostEnv;
|
||||
use super::super::super::job::{spawn, JobSender};
|
||||
@@ -55,7 +56,9 @@ pub(super) fn run(
|
||||
tx,
|
||||
epoch,
|
||||
PackEvent::Report(Box::new(convert::failed_report(
|
||||
request.pack.clone(),
|
||||
// 目标目录不可用 ⇒ 如实给空路径(不把包路径冒充成实例目录)
|
||||
PathBuf::new(),
|
||||
&request.pack,
|
||||
"import_host",
|
||||
"Target instance is not usable",
|
||||
&reason,
|
||||
@@ -99,8 +102,16 @@ pub(super) fn run(
|
||||
}
|
||||
|
||||
let report = block_on(import_pack(&core_request));
|
||||
super::finished();
|
||||
if report.status == ImportStatus::Failed && register {
|
||||
// 失败已回滚,但**目录是我们建的**、core 只清文件不清目录 ⇒ 不清掉的话
|
||||
// 下一次重试会撞「directory already exists」(复审 r1 P2-9)。
|
||||
remove_if_empty(&target);
|
||||
}
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
// 取消之后不再打扰界面(面板已经收回,代次也已经作废)
|
||||
// 取消之后不再打扰界面(面板已经收回,代次也已经作废)。
|
||||
// 注意:后台这一步**已经跑完**(core 没有取消令牌),实例可能已被登记 ——
|
||||
// 所以取消的提示语必须说「还在后台跑」,不能说「实例未创建」。
|
||||
return;
|
||||
}
|
||||
send(
|
||||
@@ -110,12 +121,25 @@ pub(super) fn run(
|
||||
);
|
||||
}
|
||||
|
||||
/// 目录里没有东西就删掉(失败回滚后的收尾;非空一律不动)。
|
||||
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/<slug>` 新目录 + 登记(实例名 = 用户给的名字或包名)。
|
||||
/// 注意 M111 的既有边界:导入**不装加载器**,所以新实例报告的 `版本 id` 还要另外装。
|
||||
fn target_of(env: &HostEnv, request: &ImportRequestView) -> Result<(PathBuf, bool), String> {
|
||||
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 => {
|
||||
@@ -128,12 +152,21 @@ fn target_of(env: &HostEnv, request: &ImportRequestView) -> Result<(PathBuf, boo
|
||||
.layout
|
||||
.data()
|
||||
.join("instances")
|
||||
.join(convert::slug(&name));
|
||||
.join(convert::dir_name(&name));
|
||||
if target.exists() {
|
||||
return Err(format!("directory already exists: {}", target.display()));
|
||||
// 空目录(多半是上一次失败的残留)**可以复用**,否则重试永远报「已存在」
|
||||
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()
|
||||
));
|
||||
}
|
||||
} else {
|
||||
std::fs::create_dir_all(&target)
|
||||
.map_err(|error| format!("create dir failed: {error}"))?;
|
||||
}
|
||||
std::fs::create_dir_all(&target)
|
||||
.map_err(|error| format!("create dir failed: {error}"))?;
|
||||
Ok((target, true))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,3 +239,69 @@ fn a_real_pack_runs_through_the_pipeline_end_to_end() {
|
||||
|
||||
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}");
|
||||
|
||||
// 本来就全是 ASCII 安全字符的名字保持干净(不无端加哈希)
|
||||
assert_eq!(super::convert::dir_name("Better-MC"), "Better-MC");
|
||||
}
|
||||
|
||||
#[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()),
|
||||
overwrite: false,
|
||||
};
|
||||
|
||||
// 第一次:目录不存在 ⇒ 建出来
|
||||
let (first, register) = super::import::target_of(&env, &request).expect("首次应当可以建目录");
|
||||
assert!(register);
|
||||
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_its_own_counter() {
|
||||
// 复审 r1 P2-7:pack 的代次必须与安装的 `CoreHost::install_epoch` 分开。
|
||||
// 这里钉的是模块计数器的行为;「不再读写 install_epoch」由代码结构保证
|
||||
// (`pack.rs` 里已无 `self.install_epoch` 引用,只有这里自己的计数器)。
|
||||
let before = super::pack_epoch();
|
||||
super::bump_pack_epoch();
|
||||
assert_eq!(super::pack_epoch(), before.wrapping_add(1), "一次 +1");
|
||||
|
||||
// 取消会作废在飞的 pack 事件(代次前进),并且回报「刚才有没有在跑」
|
||||
super::bump_pack_epoch();
|
||||
let after_bump = super::pack_epoch();
|
||||
assert!(after_bump > before);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.env` / `self.tx` 等。
|
||||
|
||||
use crate::dto::{AppCommand, Backend, CoreEvent, CreateRequest, LoaderKind};
|
||||
use crate::dto::{AppCommand, Backend, CoreEvent, CreateRequest, LoaderKind, PackEvent};
|
||||
use crate::toast::ToastKind;
|
||||
|
||||
use super::accounts::{self, LoginJob};
|
||||
@@ -70,14 +70,22 @@ impl CoreHost {
|
||||
if let Some(cancel) = &self.install_cancel {
|
||||
cancel.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
// M113: import/export —— 同一块面板的「取消」对导入也生效:立标记 + 代次 +1
|
||||
addon::cancel_pack();
|
||||
// M113: import/export —— 同一块面板的「取消」对导入也生效:立标记 + 作废 pack 代次
|
||||
// 返回值告诉我们「刚才有没有导入在跑」:有的话**不能**再发 install 那条取消信号
|
||||
// (它说的是「安装已取消、实例未创建」,而导入会在后台跑完并登记实例)。
|
||||
let pack_was_running = addon::cancel_pack();
|
||||
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` 现求。
|
||||
self.send(CoreEvent::InstallCancelled);
|
||||
// 导入在跑时不发这条:下载页那条文案说的是「实例未创建」,对导入是撒谎。
|
||||
if !pack_was_running {
|
||||
self.send(CoreEvent::InstallCancelled);
|
||||
}
|
||||
}
|
||||
AppCommand::SetIsolation { id, mode } => {
|
||||
match game::set_isolation(&self.env, &id, &mode) {
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
//!
|
||||
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。
|
||||
|
||||
use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LogOutcome};
|
||||
use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LogOutcome, PackEvent};
|
||||
use crate::toast::ToastKind;
|
||||
|
||||
use super::{logs, CoreHost, HostTask, LiveLaunch};
|
||||
use super::{addon, logs, CoreHost, HostTask, LiveLaunch};
|
||||
|
||||
impl CoreHost {
|
||||
/// 后台消息 → 事件。
|
||||
@@ -42,9 +42,16 @@ impl CoreHost {
|
||||
},
|
||||
// M113: import/export —— 导入/导出事件带代次,取消后回来的旧事件一律丢掉
|
||||
HostTask::Pack { event, epoch } => {
|
||||
if epoch != self.install_epoch {
|
||||
// pack 用**自己**的代次(复审 r1 P2-7):共用安装的计数器会让
|
||||
// 「关一次导入面板」把下载页正在跑的安装静默丢掉。
|
||||
if epoch != addon::pack_epoch() {
|
||||
return;
|
||||
}
|
||||
// 导入成功可能登记了新实例:与 `InstallDone` 同一条刷新(复审 r1 P2-1),
|
||||
// 否则新实例要等下一次无关的 `RefreshAll` 才出现在实例页与目标 chips 里。
|
||||
if matches!(*event, PackEvent::Report(_)) {
|
||||
self.refresh_instances();
|
||||
}
|
||||
self.send(CoreEvent::Pack(event));
|
||||
}
|
||||
// M105 的形状(代次丢包)为准:旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉。
|
||||
|
||||
Reference in new issue
Block a user