diff --git a/crates/app-iced/src/app/update/content.rs b/crates/app-iced/src/app/update/content.rs index 26609b9..230f229 100644 --- a/crates/app-iced/src/app/update/content.rs +++ b/crates/app-iced/src/app/update/content.rs @@ -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 { 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` 的窗口事件转发到这里)。 diff --git a/crates/app-iced/src/pages/content.rs b/crates/app-iced/src/pages/content.rs index 71e4c80..736ee55 100644 --- a/crates/app-iced/src/pages/content.rs +++ b/crates/app-iced/src/pages/content.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; diff --git a/crates/app-iced/src/pages/content/packui.rs b/crates/app-iced/src/pages/content/packui.rs index 5dcb9e0..c734909 100644 --- a/crates/app-iced/src/pages/content/packui.rs +++ b/crates/app-iced/src/pages/content/packui.rs @@ -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; diff --git a/crates/app-iced/src/pages/content/packui/demo.rs b/crates/app-iced/src/pages/content/packui/demo.rs index 0a233f8..47ad0ea 100644 --- a/crates/app-iced/src/pages/content/packui/demo.rs +++ b/crates/app-iced/src/pages/content/packui/demo.rs @@ -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, }); diff --git a/crates/app-iced/src/pages/content/packui/model.rs b/crates/app-iced/src/pages/content/packui/model.rs index 8e1e4cb..46bfa9d 100644 --- a/crates/app-iced/src/pages/content/packui/model.rs +++ b/crates/app-iced/src/pages/content/packui/model.rs @@ -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, /// 探测失败诊断 pub probe: Option, + /// 用户取消过这次导入:面板收回但**后台还在跑**(core 没有取消令牌), + /// 所以要把这句话留在界面上,别让用户以为「没建成」(复审 r1 P1-2)。 + pub cancelled: bool, + /// 取消提示的正文 + pub cancelled_note: &'static str, /// 导入结果 pub report: Option, /// 导出块 @@ -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() } diff --git a/crates/app-iced/src/pages/content/packui/model/export.rs b/crates/app-iced/src/pages/content/packui/model/export.rs index 0131db3..d4e3f37 100644 --- a/crates/app-iced/src/pages/content/packui/model/export.rs +++ b/crates/app-iced/src/pages/content/packui/model/export.rs @@ -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; diff --git a/crates/app-iced/src/pages/content/packui/model/progress.rs b/crates/app-iced/src/pages/content/packui/model/progress.rs index d319bce..646056f 100644 --- a/crates/app-iced/src/pages/content/packui/model/progress.rs +++ b/crates/app-iced/src/pages/content/packui/model/progress.rs @@ -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::{ diff --git a/crates/app-iced/src/pages/content/packui/model/report.rs b/crates/app-iced/src/pages/content/packui/model/report.rs index 493fec2..c056317 100644 --- a/crates/app-iced/src/pages/content/packui/model/report.rs +++ b/crates/app-iced/src/pages/content/packui/model/report.rs @@ -84,6 +84,8 @@ pub struct Report { pub conflicts_title: Option, /// 冲突逐条 pub conflicts: Vec, + /// 被拒 component 的标题(带计数,与下面逐条行数一致) + pub rejected_title: Option, /// 被拒 component 逐条 pub rejected: Vec, /// 注记逐条 @@ -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()), } diff --git a/crates/app-iced/src/pages/content/packui/model/tests.rs b/crates/app-iced/src/pages/content/packui/model/tests.rs index 74edfd9..991fcbd 100644 --- a/crates/app-iced/src/pages/content/packui/model/tests.rs +++ b/crates/app-iced/src/pages/content/packui/model/tests.rs @@ -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(), "取消态不能被当成「什么都没有」"); +} diff --git a/crates/app-iced/src/pages/content/packui/view.rs b/crates/app-iced/src/pages/content/packui/view.rs index fb46c49..7e41d0a 100644 --- a/crates/app-iced/src/pages/content/packui/view.rs +++ b/crates/app-iced/src/pages/content/packui/view.rs @@ -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 { diff --git a/crates/app-iced/src/pages/content/packui/view/export.rs b/crates/app-iced/src/pages/content/packui/view/export.rs index 5906da0..0b3272c 100644 --- a/crates/app-iced/src/pages/content/packui/view/export.rs +++ b/crates/app-iced/src/pages/content/packui/view/export.rs @@ -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() } diff --git a/crates/app-iced/src/pages/content/packui/view/report.rs b/crates/app-iced/src/pages/content/packui/view/report.rs index 5615444..826cbc3 100644 --- a/crates/app-iced/src/pages/content/packui/view/report.rs +++ b/crates/app-iced/src/pages/content/packui/view/report.rs @@ -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 { diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index 7711093..0f492a8 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -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.", diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 8db20a0..013644f 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -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", "离线导入"), diff --git a/crates/presenter/src/dto/pack/event.rs b/crates/presenter/src/dto/pack/event.rs index fe0ddde..61b0038 100644 --- a/crates/presenter/src/dto/pack/event.rs +++ b/crates/presenter/src/dto/pack/event.rs @@ -57,6 +57,9 @@ pub enum PackEvent { Exported(Box), /// 导出/预检在 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(); diff --git a/crates/presenter/src/dto/pack/import.rs b/crates/presenter/src/dto/pack/import.rs index 59c128a..2109558 100644 --- a/crates/presenter/src/dto/pack/import.rs +++ b/crates/presenter/src/dto/pack/import.rs @@ -331,6 +331,9 @@ pub struct ProbeView { pub struct ImportState { /// 正在跑的进度(`None` = 没在跑)。 pub progress: Option, + /// 用户点了取消(M113 r1):**面板收回但后台还在跑**,所以这一位要留在界面上—— + /// 否则用户会以为「没建成」,回头再导一次就撞「directory already exists」。 + pub cancelled: bool, /// 最近一次的结果(`None` = 还没导过)。 pub report: Option, /// 探测失败的具名诊断(有它就让用户看清「像 X 缺 Y」,而不是一句「无法识别」)。 diff --git a/crates/presenter/src/host/addon.rs b/crates/presenter/src/host/addon.rs index 4da4a72..37e3b58 100644 --- a/crates/presenter/src/host/addon.rs +++ b/crates/presenter/src/host/addon.rs @@ -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; diff --git a/crates/presenter/src/host/addon/pack.rs b/crates/presenter/src/host/addon/pack.rs index 9516eac..b92cc70 100644 --- a/crates/presenter/src/host/addon/pack.rs +++ b/crates/presenter/src/host/addon/pack.rs @@ -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>> = 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 { + 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 { /// `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))); } } diff --git a/crates/presenter/src/host/addon/pack/convert.rs b/crates/presenter/src/host/addon/pack/convert.rs index 627aa81..ca6892e 100644 --- a/crates/presenter/src/host/addon/pack/convert.rs +++ b/crates/presenter/src/host/addon/pack/convert.rs @@ -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`), +/// **一旦折过东西**(中文/空格/符号)就补 `-`,用原名的哈希保住区分度。 +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(); diff --git a/crates/presenter/src/host/addon/pack/import.rs b/crates/presenter/src/host/addon/pack/import.rs index 6da93b8..fd378ae 100644 --- a/crates/presenter/src/host/addon/pack/import.rs +++ b/crates/presenter/src/host/addon/pack/import.rs @@ -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); + } +} + /// 导入目标:`(目录, 是否登记新实例)`。 /// /// - 选了实例 ⇒ 它的运行目录,**不登记**(已经是一条实例记录); /// - 没选实例 ⇒ `/instances/` 新目录 + 登记(实例名 = 用户给的名字或包名)。 /// 注意 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)) } } diff --git a/crates/presenter/src/host/addon/pack/tests.rs b/crates/presenter/src/host/addon/pack/tests.rs index 289bdb9..a6075ae 100644 --- a/crates/presenter/src/host/addon/pack/tests.rs +++ b/crates/presenter/src/host/addon/pack/tests.rs @@ -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); +} diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index c20b58e..ecc2038 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -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) { diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index 4bf68f3..a9130d3 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -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 的形状(代次丢包)为准:旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉。