From 9ab909d01cbc132f91abd9e4c1c6d11820f2bf1c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 13:44:20 +0800 Subject: [PATCH 1/9] =?UTF-8?q?refactor(presenter):=20=E4=B8=A4=E5=A4=84?= =?UTF-8?q?=E7=BA=AF=E6=90=AC=E7=A7=BB=E8=85=BE=E5=87=BA=20400=20=E8=A1=8C?= =?UTF-8?q?=E4=BD=99=E9=87=8F=EF=BC=88events=20=E7=94=A8=E4=BE=8B=20/=20AI?= =?UTF-8?q?=20=E6=A0=8F=E4=BA=8B=E4=BB=B6=E6=8A=95=E9=80=92=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit events.rs 399 行、state.rs 400 行(AGENTS §3.1 与 §3.9 的零余量贴线文件)。M223 要往 AppCommand/CoreEvent 各加一条「导入已有版本」的变体、往 AppState 加一份事件态,都得先腾地方。 纯搬移、一行未改: - events.rs 尾部 #[cfg(test)] mod tests(80 行)→ dto/events/tests.rs(399 → 321); - state.rs 的 apply_agent + agent_rail_visible(28 行)→ state/agent.rs(400 → 376)。 方法路径不变(impl 写在子模块、pub use 无需新增),断言一条未动,搬移 diff 可逐行对。 --- crates/presenter/src/dto/events.rs | 80 +--------------------- crates/presenter/src/dto/events/tests.rs | 87 ++++++++++++++++++++++++ crates/presenter/src/state.rs | 28 +------- crates/presenter/src/state/agent.rs | 39 +++++++++++ 4 files changed, 129 insertions(+), 105 deletions(-) create mode 100644 crates/presenter/src/dto/events/tests.rs create mode 100644 crates/presenter/src/state/agent.rs diff --git a/crates/presenter/src/dto/events.rs b/crates/presenter/src/dto/events.rs index d611b2e..7fcd695 100644 --- a/crates/presenter/src/dto/events.rs +++ b/crates/presenter/src/dto/events.rs @@ -318,82 +318,4 @@ pub trait Backend { } #[cfg(test)] -mod tests { - use super::*; - use crate::dto::{AccountKind, DeviceLoginPhase, InstallStage}; - - #[test] - fn events_are_serializable_ipc_contracts() { - let event = AppEvent::Core(CoreEvent::InstallProgress(Some(InstallProgressView { - stage: InstallStage::Downloading, - files_done: 3, - files_total: 9, - ..InstallProgressView::new() - }))); - let text = serde_json::to_string(&event).expect("序列化"); - let back: AppEvent = serde_json::from_str(&text).expect("反序列化"); - assert_eq!(back, event); - - let account = AppEvent::Core(CoreEvent::AccountsChanged(vec![AccountView { - id: "account:1".to_string(), - name: "Steve".to_string(), - kind: AccountKind::Offline, - profile_id: "0f8f".to_string(), - login_name: String::new(), - created: "2026-10-31 20:12".to_string(), - active: true, - server_url: String::new(), - skin: None, - }])); - let text = serde_json::to_string(&account).expect("序列化"); - let back: AppEvent = serde_json::from_str(&text).expect("反序列化"); - assert_eq!(back, account); - // 凭据永不跨界:序列化文本里没有 token 类字段 - assert!(!text.to_lowercase().contains("token")); - } - - #[test] - fn commands_round_trip() { - let command = AppCommand::CreateInstance(CreateRequest { - version: "1.20.4".to_string(), - loader: crate::dto::LoaderKind::Fabric, - loader_version: "0.16.5".to_string(), - name: "生存".to_string(), - }); - let text = serde_json::to_string(&command).expect("序列化"); - let back: AppCommand = serde_json::from_str(&text).expect("反序列化"); - assert_eq!(back, command); - - let login = AppCommand::StartDeviceLogin; - assert_eq!( - serde_json::from_str::(&serde_json::to_string(&login).expect("序列化")) - .expect("反序列化"), - login - ); - assert!(DeviceLoginPhase::Waiting.busy()); - - // 第三方轨(M114):请求进、状态出,两边都不带 token 字段 - let authlib = AppCommand::AddAuthlibAccount(AuthlibRequest::new( - "https://littleskin.cn/api/yggdrasil/", - "me@example.com", - "hunter2", - )); - let text = serde_json::to_string(&authlib).expect("序列化"); - assert_eq!( - serde_json::from_str::(&text).expect("反序列化"), - authlib - ); - - let view = AppEvent::Core(CoreEvent::AuthlibLoginChanged(AuthlibLoginView::reachable( - "https://littleskin.cn/api/yggdrasil/", - "LittleSkin", - ))); - let text = serde_json::to_string(&view).expect("序列化"); - let back: AppEvent = serde_json::from_str(&text).expect("反序列化"); - assert_eq!(back, view); - assert!( - !text.to_lowercase().contains("token"), - "回程视图里不该有 token" - ); - } -} +mod tests; diff --git a/crates/presenter/src/dto/events/tests.rs b/crates/presenter/src/dto/events/tests.rs new file mode 100644 index 0000000..8853d68 --- /dev/null +++ b/crates/presenter/src/dto/events/tests.rs @@ -0,0 +1,87 @@ +//! `dto::events` 的 IPC 契约用例(M223 从 `events.rs` 尾部**纯搬移**,一行未改)。 +//! +//! 为什么搬:M223 要给 `AppCommand` / `CoreEvent` 各加一条变体(「导入已有版本」的指令与事件), +//! 而 `events.rs` 当时正好 399 行——加两行变体 + 两行 import 就顶破 400 行硬线(AGENTS §3.1)。 +//! 拆分口径与仓库其它拆分一致:**纯搬移、一行不改**,`mod tests;` 只是把位置挪到 +//! `dto/events/tests.rs`(`events.rs` 是非 `mod.rs` 文件,子模块目录就是 `events/`)。 +//! +//! 搬移后 `events.rs` 只剩契约本体(枚举 + 构造器 + `Backend` trait),本文件的断言一条没动, +//! 所以「搬移零行为变化」可以用逐行 diff 对。 + +use super::*; +use crate::dto::{AccountKind, DeviceLoginPhase, InstallStage}; + +#[test] +fn events_are_serializable_ipc_contracts() { + let event = AppEvent::Core(CoreEvent::InstallProgress(Some(InstallProgressView { + stage: InstallStage::Downloading, + files_done: 3, + files_total: 9, + ..InstallProgressView::new() + }))); + let text = serde_json::to_string(&event).expect("序列化"); + let back: AppEvent = serde_json::from_str(&text).expect("反序列化"); + assert_eq!(back, event); + + let account = AppEvent::Core(CoreEvent::AccountsChanged(vec![AccountView { + id: "account:1".to_string(), + name: "Steve".to_string(), + kind: AccountKind::Offline, + profile_id: "0f8f".to_string(), + login_name: String::new(), + created: "2026-10-31 20:12".to_string(), + active: true, + server_url: String::new(), + skin: None, + }])); + let text = serde_json::to_string(&account).expect("序列化"); + let back: AppEvent = serde_json::from_str(&text).expect("反序列化"); + assert_eq!(back, account); + // 凭据永不跨界:序列化文本里没有 token 类字段 + assert!(!text.to_lowercase().contains("token")); +} + +#[test] +fn commands_round_trip() { + let command = AppCommand::CreateInstance(CreateRequest { + version: "1.20.4".to_string(), + loader: crate::dto::LoaderKind::Fabric, + loader_version: "0.16.5".to_string(), + name: "生存".to_string(), + }); + let text = serde_json::to_string(&command).expect("序列化"); + let back: AppCommand = serde_json::from_str(&text).expect("反序列化"); + assert_eq!(back, command); + + let login = AppCommand::StartDeviceLogin; + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&login).expect("序列化")) + .expect("反序列化"), + login + ); + assert!(DeviceLoginPhase::Waiting.busy()); + + // 第三方轨(M114):请求进、状态出,两边都不带 token 字段 + let authlib = AppCommand::AddAuthlibAccount(AuthlibRequest::new( + "https://littleskin.cn/api/yggdrasil/", + "me@example.com", + "hunter2", + )); + let text = serde_json::to_string(&authlib).expect("序列化"); + assert_eq!( + serde_json::from_str::(&text).expect("反序列化"), + authlib + ); + + let view = AppEvent::Core(CoreEvent::AuthlibLoginChanged(AuthlibLoginView::reachable( + "https://littleskin.cn/api/yggdrasil/", + "LittleSkin", + ))); + let text = serde_json::to_string(&view).expect("序列化"); + let back: AppEvent = serde_json::from_str(&text).expect("反序列化"); + assert_eq!(back, view); + assert!( + !text.to_lowercase().contains("token"), + "回程视图里不该有 token" + ); +} diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index d0187ed..94463e3 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -8,7 +8,6 @@ use crate::dialogs::DialogQueue; use crate::dto::{ // 主线(M105 的 `LoaderKind` / M113 的 `PackState`)与 M114 的 `AuthlibLoginView` 并集。 AccountView, - AgentEvent, AppEvent, AuthlibLoginView, CoreEvent, @@ -42,6 +41,8 @@ use crate::wizard::WizardState; // 所以这里没有 `mod ai;` / `pub use ai::AiSession;`——M114 r1 曾把旧结构搬到 `state/ai.rs`, // 该文件已随 rebase 删除,**以 main 的 agent 契约为准**。 mod accounts; +// M223:AI 栏的事件投递与可见性(纯搬移,守 400 行硬线) +mod agent; mod live; mod tiles; @@ -304,31 +305,6 @@ impl AppState { } } - /// agent 事件 → AI 栏视图模型(投影规则见 [`crate::agent::rail`])。 - /// - /// 未读规则:栏不可见时,助手侧新内容(助手/工具消息、错误、待确认请求)逐条累加 `unread`; - /// 用户自己说的话与纯增量不算。栏可见时到达的内容直接算已读(红点不该在眼前亮着)。 - pub fn apply_agent(&mut self, event: AgentEvent) { - let visible = self.agent_rail_visible(); - let fresh = self.ai.ingest(&event); - if fresh { - if visible { - self.ai.mark_read(); - } else { - self.ai.unread = self.ai.unread.saturating_add(1); - } - } - } - - /// AI 栏此刻是否「在用户眼前」。 - /// - /// 独立页形态(栈顶是 `Route::Agent`)必然可见;停靠形态下只有收成 icon 轨才算离开—— - /// 右侧常驻栏在任何一级页面都挂着,用户扫一眼就能看到。 - #[must_use] - pub fn agent_rail_visible(&self) -> bool { - self.ai_mode() == AiPanelMode::Page || !self.prefs.ai_collapsed - } - /// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。 pub fn tiles(&self) -> Vec> { let query = self.instance_query.trim().to_lowercase(); diff --git a/crates/presenter/src/state/agent.rs b/crates/presenter/src/state/agent.rs new file mode 100644 index 0000000..8392325 --- /dev/null +++ b/crates/presenter/src/state/agent.rs @@ -0,0 +1,39 @@ +//! AI 栏的事件投递与可见性判定(M223 从 `state.rs` **纯搬移**,一行未改)。 +//! +//! 为什么搬:M223 要往 `AppState` 加「导入已有版本」的状态字段与一条 `CoreEvent::Adopt` 分支, +//! 而 `state.rs` 当时正好 400 行(零余量,AGENTS §3.9)。拆分口径与 [`super::tiles`] 一致: +//! **纯搬移、一行不改**;`impl AppState` 写在子模块里,方法路径 +//! `AppState::apply_agent` / `AppState::agent_rail_visible` 对调用方完全不变 +//! (子模块看得见父模块的私有字段,所以这里直接用 `self.ai` / `self.prefs`)。 + +use crate::dto::AgentEvent; +use crate::router::AiPanelMode; + +use super::AppState; + +impl AppState { + /// agent 事件 → AI 栏视图模型(投影规则见 [`crate::agent::rail`])。 + /// + /// 未读规则:栏不可见时,助手侧新内容(助手/工具消息、错误、待确认请求)逐条累加 `unread`; + /// 用户自己说的话与纯增量不算。栏可见时到达的内容直接算已读(红点不该在眼前亮着)。 + pub fn apply_agent(&mut self, event: AgentEvent) { + let visible = self.agent_rail_visible(); + let fresh = self.ai.ingest(&event); + if fresh { + if visible { + self.ai.mark_read(); + } else { + self.ai.unread = self.ai.unread.saturating_add(1); + } + } + } + + /// AI 栏此刻是否「在用户眼前」。 + /// + /// 独立页形态(栈顶是 `Route::Agent`)必然可见;停靠形态下只有收成 icon 轨才算离开—— + /// 右侧常驻栏在任何一级页面都挂着,用户扫一眼就能看到。 + #[must_use] + pub fn agent_rail_visible(&self) -> bool { + self.ai_mode() == AiPanelMode::Page || !self.prefs.ai_collapsed + } +} From 26e4fbb91d03243b7dfc50cbe8f284175b59041e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 13:59:59 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat(presenter):=20=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=B7=B2=E6=9C=89=E7=89=88=E6=9C=AC=E7=9A=84=20DTO=20=E4=B8=8E?= =?UTF-8?q?=E4=B8=89=E6=9D=A1=E5=90=8E=E5=8F=B0=E7=BA=BF=E7=A8=8B=EF=BC=88?= =?UTF-8?q?M223=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core 的能力(M221)接进 presenter 的 IPC 面: - dto/adopt.rs:AdoptCommand / AdoptEvent / 六种视图 / AdoptState + AdoptStage。 全部 Serialize+Deserialize,**不 import 任何 core 类型**(契约 §5.4); - host/adopt.rs:spawn_scan(adopt::scan)/ spawn_adopt(plan 或 adopt),结果经 HostTask::Adopt 回流;core 的候选/计划/结果/失败逐字段搬进 DTO,problems[] 一条不少; - CoreHost 加 adopt_epoch(每条命令 +1、关面板也 +1),连点与迟到的回程不会点亮面板; - 登记成功后补一次 refresh_instances(新实例这一拍就出现在列表里)。 运行目录只从 plan()/adopt() 的返回值取(缺省档可能带 -2 后缀,只有 core 知道用了哪个); target_notes() 的原文随 AdoptPlanView::notes 上屏,界面不另写一份后果话术。 --- crates/presenter/src/dto/adopt.rs | 384 +++++++++++++++++++++++ crates/presenter/src/dto/adopt/tests.rs | 136 ++++++++ crates/presenter/src/dto/mod.rs | 7 + crates/presenter/src/host/adopt.rs | 261 +++++++++++++++ crates/presenter/src/host/adopt/tests.rs | 217 +++++++++++++ crates/presenter/src/host/mod.rs | 7 + 6 files changed, 1012 insertions(+) create mode 100644 crates/presenter/src/dto/adopt.rs create mode 100644 crates/presenter/src/dto/adopt/tests.rs create mode 100644 crates/presenter/src/host/adopt.rs create mode 100644 crates/presenter/src/host/adopt/tests.rs diff --git a/crates/presenter/src/dto/adopt.rs b/crates/presenter/src/dto/adopt.rs new file mode 100644 index 0000000..1ad8e30 --- /dev/null +++ b/crates/presenter/src/dto/adopt.rs @@ -0,0 +1,384 @@ +//! 「导入已有版本」的 IPC 面(M223 GUIADOPT):扫描 → 预检 → 确认三段。 +//! +//! 能力本身在 core(`unlucky_core::game::adopt`,M221 的契约见 +//! `DevDocs/SubAgentsTalk/adoptcore-契约.md`);本文件只有**可序列化的视图**, +//! 一个 `unlucky_core::*` 类型都不出现(AGENTS §6 的跨边界纪律)。 +//! +//! ## 三段:前两段零写盘,第三段只写 ULL 自己的配置 +//! +//! | 段 | 指令 | 回程 | 写盘 | +//! |---|---|---|---| +//! | 扫描 | [`AdoptCommand::Scan`] | [`AdoptEvent::Scan`] | 零(不建目录、不改 mtime) | +//! | 预检 | [`AdoptCommand::Preview`] | [`AdoptEvent::Planned`] / [`AdoptEvent::Failed`] | 零 | +//! | 确认 | [`AdoptCommand::Confirm`] | [`AdoptEvent::Done`] / [`AdoptEvent::Failed`] | 实例设置 + `profiles.json`(+ 缺省运行目录) | +//! +//! ## 三条硬纪律(契约 §5 的 1/2/3) +//! +//! 1. **运行目录只认回程**:[`AdoptPlanView::run_dir`] / [`AdoptDoneView::run_dir`] 是唯一来源。 +//! 缺省档会带 `-2`/`-3` 后缀消歧(两个不同展示名可能落到同一个物理目录),**只有 core 知道 +//! 最后用了哪个**;页面拿展示名自己派生一次就是错的(会与实例真正启动时用的目录分叉)。 +//! 2. **后果话术只有一份**:`Shared` / `VersionDir` 的后果**不在本文件里另写**一句, +//! 由预检把 core `target_notes()` 的原文放进 [`AdoptPlanView::notes`]——于是界面上的 +//! 「mods/saves/config 会落进别人的 `.minecraft`」与 CLI 打的是同一句。 +//! 想显示后果就必须先预检(预检零写盘,所以这不是成本)。 +//! 3. **问题逐条**:core 的结构化问题([`AdoptProblemView`],带稳定 `code`)与整次失败 +//! ([`AdoptFailureView`])都原样搬过来,**不折叠成一句「导入失败」**。 +//! +//! ## 缺省档为什么是 `InstanceDir` 而不是 `isolated` +//! +//! 全局缺省 `isolated` 的运行目录就是 `versions//`——那是**别人的版本目录**。沿用它会让第一次 +//! 启动就在别人的 `.minecraft` 里长出 `mods/` `saves/` `logs/`。所以 [`AdoptTargetView`] 的 +//! 缺省是 `InstanceDir`(ULL 数据区新建独立目录),另两档必须**明确点选**。 + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// app → host 的导入意图。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum AdoptCommand { + /// 扫当前游戏根的 `versions/`(只读;顺带得到每个版本能不能登记、已登记给谁) + Scan, + /// **预检**:按请求算落点与后果(零写盘)。页面拿回 [`AdoptEvent::Planned`] 才画确认按钮。 + Preview(Box), + /// 确认登记(唯一写盘入口)。 + Confirm(Box), + /// 关掉面板(扫描结果 / 预检 / 结果卡都收起来) + Dismiss, +} + +/// host → app 的回程事件。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum AdoptEvent { + /// 扫描结果(`versions/` 里的全部条目,逐项带问题) + Scan(Box), + /// 预检结果(落点 + 后果原文 + 将写的展示名) + Planned(Box), + /// 失败:`code` 稳定、`message` 人读、`problems` 逐条(可能为空) + Failed(Box), + /// 登记完成 + Done(Box), + /// 面板已关(状态复位) + Cleared, +} + +/// 登记后的**运行目录从哪来**(隔离档由它决定)。 +/// +/// GUI 只开三档:[`Self::InstanceDir`](缺省)/[`Self::Shared`] / [`Self::VersionDir`]。 +/// `custom`(用户手输绝对路径)留在 CLI:GUI 要它就得配目录选择器,不在本 mission 的交付面里。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum AdoptTargetView { + /// 缺省:ULL 数据区新建独立运行目录(`custom` 档,与别人的 `.minecraft` 隔离) + #[default] + InstanceDir, + /// 共用游戏根(别人的 `.minecraft`)——必须明确点选 + Shared, + /// 版本目录 `versions//`(`isolated` 档)——会在别人的版本目录里长玩家数据 + VersionDir, +} + +impl AdoptTargetView { + /// 三档的声明顺序(= 面板上的先后,缺省在最前)。 + pub const ALL: [AdoptTargetView; 3] = [ + AdoptTargetView::InstanceDir, + AdoptTargetView::Shared, + AdoptTargetView::VersionDir, + ]; + + /// 稳定 slug(与 core 的 `AdoptTarget::as_str` 同词表;`instance_dir` 不进 CLI 词表)。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::InstanceDir => "instance_dir", + Self::Shared => "shared", + Self::VersionDir => "version_dir", + } + } + + /// 从 slug 反解。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + Self::ALL.into_iter().find(|target| target.slug() == slug) + } + + /// 这一档会不会把玩家数据写进**别人的**目录(界面据此加警示标记)。 + #[must_use] + pub const fn touches_foreign_dir(self) -> bool { + matches!(self, Self::Shared | Self::VersionDir) + } +} + +/// 生效的隔离档(`core::game::install::instance::Isolation` 的视图形态)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AdoptIsolationView { + /// `versions//` 当运行目录 + Isolated, + /// 游戏根当运行目录 + Shared, + /// 指定/新建目录当运行目录(缺省档就是这一档) + Custom, +} + +impl AdoptIsolationView { + /// 稳定 slug(与 core 的 `Isolation::as_str` 同一套字面量)。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::Isolated => "isolated", + Self::Shared => "shared", + Self::Custom => "custom", + } + } + + /// 从 slug 反解(未知值返回 `None`,界面照原样上屏而不是猜)。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + match slug { + "isolated" => Some(Self::Isolated), + "shared" => Some(Self::Shared), + "custom" => Some(Self::Custom), + _ => None, + } + } +} + +/// 一次预检/登记的请求(页面表单 → host)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdoptRequestView { + /// 要接进来的版本 id(= `versions//` 的目录名) + pub version_id: String, + /// 展示名(`None` / 空串 = 用版本 id) + pub name: Option, + /// 运行目录选择(缺省 [`AdoptTargetView::InstanceDir`]) + pub target: AdoptTargetView, +} + +impl AdoptRequestView { + /// 最小请求(缺省名 + 缺省运行目录)。 + #[must_use] + pub fn new(version_id: impl Into) -> Self { + Self { + version_id: version_id.into(), + name: None, + target: AdoptTargetView::default(), + } + } +} + +/// 一条**结构化**的不合格原因(`code` 稳定,`message` 人读)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdoptProblemView { + /// 稳定机读码(`manifest_missing` / `missing_parent` / `outside_game_root` …) + pub code: String, + /// 人读原因(core 的 `Display` 原文:带路径、行列号、缺哪个父版本) + pub message: String, +} + +/// 盘上的一个候选版本。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdoptCandidateView { + /// 版本 id(= 目录名,登记后写进 `profiles.json`) + pub version_id: String, + /// 客户端 jar 在不在(只报事实,不校验内容) + pub jar_present: bool, + /// `inheritsFrom` 链(自身在前、最祖先在后) + pub inherits: Vec, + /// 已经绑定这个版本的实例名(空 = 还没登记过) + pub registered: Vec, + /// 问题清单(空 = 可登记) + pub problems: Vec, +} + +impl AdoptCandidateView { + /// 能不能登记(没有任何问题)。 + #[must_use] + pub fn adoptable(&self) -> bool { + self.problems.is_empty() + } + + /// 已登记过(再登记会得到第二个实例,共用同一份版本文件)。 + #[must_use] + pub fn is_registered(&self) -> bool { + !self.registered.is_empty() + } +} + +/// 一次扫描的结果。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdoptScanView { + /// 被扫的目录(`<游戏根>/versions`) + pub versions_dir: PathBuf, + /// 候选(按版本 id 字典序) + pub candidates: Vec, +} + +impl AdoptScanView { + /// 候选条数。 + #[must_use] + pub fn total(&self) -> usize { + self.candidates.len() + } + + /// 可登记的条数。 + #[must_use] + pub fn adoptable(&self) -> usize { + self.candidates + .iter() + .filter(|candidate| candidate.adoptable()) + .count() + } + + /// `versions/` 存在但一个候选都没有(新数据根,或只剩别的启动器的空目录)。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.candidates.is_empty() + } +} + +/// 预检结果:落点 + 后果原文 + 将写的展示名。**零写盘**。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdoptPlanView { + /// 版本 id + pub version_id: String, + /// 将写进档案的展示名(空名已回落到版本 id) + pub name: String, + /// 运行目录选择 + pub target: AdoptTargetView, + /// 生效隔离档 + pub isolation: AdoptIsolationView, + /// **生效运行目录**(可能是顺延后的 `-2`/`-3`;页面不许自己派生) + pub run_dir: PathBuf, + /// 人读提示(含 `target_notes()` 的原文:共享档/版本目录档会把玩家数据写进别人的目录) + pub notes: Vec, +} + +/// 登记完成。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdoptDoneView { + /// 实例 id(稳定 UUID) + pub instance_id: String, + /// 展示名 + pub name: String, + /// 绑定的版本 id + pub version_id: String, + /// 生效隔离档 + pub isolation: AdoptIsolationView, + /// 生效运行目录(**唯一来源**:启动时用的就是它) + pub run_dir: PathBuf, + /// 是否新建了缺省运行目录(`false` = 目录已存在但为空、直接复用) + pub dir_created: bool, + /// 人读提示(与预检同一份) + pub notes: Vec, +} + +/// 一次失败(整次调用,不是单条候选问题)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdoptFailureView { + /// 稳定机读码(`adopt_not_adoptable` / `adopt_duplicate_name` / `adopt_target_not_empty` …) + pub code: String, + /// 人读原因(core 的 `Display` 原文) + pub message: String, + /// 候选问题清单(`adopt_not_adoptable` 时逐条给出;其它失败为空) + pub problems: Vec, +} + +/// 向导的**事件态**(`AppState::adopt` 的全部内容)。 +/// +/// 只有从 [`AdoptEvent`] 来的事实:扫描结果、预检结果、失败、完成。页面的表单字段 +/// (选中的版本、展示名输入、目标选择、面板开没开)属于 UI 本地态,**不在这里**—— +/// 状态只有一个写入点,才不会有「页面显示的落点和真正写盘的落点不是一回事」。 +#[derive(Debug, Clone, PartialEq, Default)] +pub struct AdoptState { + /// 最近一次扫描(`None` = 还没扫过 / 已被新一轮顶掉) + pub scan: Option, + /// 最近一次预检(`None` = 还没预检 / 已过期) + pub plan: Option, + /// 最近一次失败 + pub failure: Option, + /// 最近一次完成的登记 + pub done: Option, +} + +impl AdoptState { + /// 事件 → 状态(**唯一**写入点,与 `PackState::apply` 同一条纪律)。 + pub fn apply(&mut self, event: AdoptEvent) { + match event { + AdoptEvent::Scan(scan) => { + // 新一轮扫描:上一轮的预检/失败/完成都作废(候选集合可能已经变了) + self.scan = Some(*scan); + self.plan = None; + self.failure = None; + self.done = None; + } + AdoptEvent::Planned(plan) => { + self.plan = Some(*plan); + self.failure = None; + self.done = None; + } + AdoptEvent::Failed(failure) => { + // 预检失败时上一份预检必须消失:留着会让确认按钮继续亮着 + self.plan = None; + self.failure = Some(*failure); + } + AdoptEvent::Done(done) => { + // 登记成功:预检与失败都过期(这一轮已经有结论) + self.plan = None; + self.failure = None; + self.done = Some(*done); + } + AdoptEvent::Cleared => { + self.scan = None; + self.plan = None; + self.failure = None; + self.done = None; + } + } + } + + /// 面板此刻该画的那一段(扫描 / 选版本 / 预检 / 结果)。 + /// + /// 优先级:完成 > 失败 > 预检 > 扫描列表。写成一个方法而不是让页面各判一遍, + /// 是为了「同一时刻只画一段」在单测里可断言。 + #[must_use] + pub fn stage(&self) -> AdoptStage { + if self.done.is_some() { + return AdoptStage::Done; + } + if self.failure.is_some() { + return AdoptStage::Failed; + } + if self.plan.is_some() { + return AdoptStage::Preview; + } + AdoptStage::Pick + } +} + +/// 面板当前画哪一段([`AdoptState::stage`] 的产物)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AdoptStage { + /// 扫出来的候选列表(选版本) + Pick, + /// 预检结果(落点 + 后果原文 + 确认按钮) + Preview, + /// 失败(原因逐条) + Failed, + /// 完成 + Done, +} + +impl AdoptStage { + /// 稳定 slug(进日志/测试断言用)。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::Pick => "pick", + Self::Preview => "preview", + Self::Failed => "failed", + Self::Done => "done", + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/presenter/src/dto/adopt/tests.rs b/crates/presenter/src/dto/adopt/tests.rs new file mode 100644 index 0000000..3d4be0a --- /dev/null +++ b/crates/presenter/src/dto/adopt/tests.rs @@ -0,0 +1,136 @@ +//! `dto::adopt` 的 IPC 契约用例(M223 从 `dto/adopt.rs` **纯搬移**,断言一条未改)。 +//! +//! 为什么搬:`dto/adopt.rs` 落笔 512 行,越过 400 行硬线(AGENTS §3.1);口径与 +//! [`crate::dto::events::tests`] 完全一致——**纯搬移、一行不改**。`adopt.rs` 是非 `mod.rs` +//! 文件,`mod tests;` 的落点就是本文件。 + +use super::*; + +fn candidate(version_id: &str, problems: Vec) -> AdoptCandidateView { + AdoptCandidateView { + version_id: version_id.to_string(), + jar_present: true, + inherits: Vec::new(), + registered: Vec::new(), + problems, + } +} + +fn scan_view() -> AdoptScanView { + AdoptScanView { + versions_dir: PathBuf::from("/game/versions"), + candidates: vec![ + candidate("1.20.1", Vec::new()), + candidate( + "1.19.4", + vec![AdoptProblemView { + code: "manifest_missing".to_string(), + message: "版本 1.19.4 没有清单文件".to_string(), + }], + ), + ], + } +} + +#[test] +fn events_round_trip_through_json() { + let event = AdoptEvent::Failed(Box::new(AdoptFailureView { + code: "adopt_not_adoptable".to_string(), + message: "版本 1.19.4 不能登记".to_string(), + problems: vec![AdoptProblemView { + code: "manifest_missing".to_string(), + message: "清单缺了".to_string(), + }], + })); + let text = serde_json::to_string(&event).expect("序列化"); + assert_eq!( + serde_json::from_str::(&text).expect("反序列化"), + event + ); + + let command = AdoptCommand::Preview(Box::new(AdoptRequestView { + version_id: "1.20.1".to_string(), + name: Some("生存".to_string()), + target: AdoptTargetView::Shared, + })); + let text = serde_json::to_string(&command).expect("序列化"); + assert_eq!( + serde_json::from_str::(&text).expect("反序列化"), + command + ); +} + +#[test] +fn scan_summarises_adoptable_and_empty() { + let view = scan_view(); + assert_eq!(view.total(), 2); + assert_eq!(view.adoptable(), 1); + assert!(!view.is_empty()); + assert!(AdoptScanView { + versions_dir: PathBuf::from("/game/versions"), + candidates: Vec::new(), + } + .is_empty()); +} + +#[test] +fn target_slugs_round_trip_and_mark_foreign_dirs() { + for target in AdoptTargetView::ALL { + assert_eq!(AdoptTargetView::from_slug(target.slug()), Some(target)); + } + assert_eq!(AdoptTargetView::default(), AdoptTargetView::InstanceDir); + assert!(!AdoptTargetView::InstanceDir.touches_foreign_dir()); + assert!(AdoptTargetView::Shared.touches_foreign_dir()); + assert!(AdoptTargetView::VersionDir.touches_foreign_dir()); +} + +#[test] +fn state_follows_the_four_stages() { + let mut state = AdoptState::default(); + assert_eq!(state.stage(), AdoptStage::Pick); + + state.apply(AdoptEvent::Scan(Box::new(scan_view()))); + assert_eq!(state.stage(), AdoptStage::Pick); + assert_eq!(state.scan.as_ref().map(AdoptScanView::adoptable), Some(1)); + + let plan = AdoptPlanView { + version_id: "1.20.1".to_string(), + name: "1.20.1".to_string(), + target: AdoptTargetView::InstanceDir, + isolation: AdoptIsolationView::Custom, + run_dir: PathBuf::from("/ull/data/instances/1.20.1"), + notes: vec!["运行目录 = /ull/data/instances/1.20.1".to_string()], + }; + state.apply(AdoptEvent::Planned(Box::new(plan.clone()))); + assert_eq!(state.stage(), AdoptStage::Preview); + assert_eq!( + state.plan.as_ref().map(|plan| &plan.run_dir), + Some(&plan.run_dir) + ); + + // 失败会把预检顶掉:留着预检,确认按钮就会在「不能登记」的结论上继续亮着 + state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView { + code: "adopt_duplicate_name".to_string(), + message: "已有同名实例".to_string(), + problems: Vec::new(), + }))); + assert_eq!(state.stage(), AdoptStage::Failed); + assert!(state.plan.is_none()); + + state.apply(AdoptEvent::Done(Box::new(AdoptDoneView { + instance_id: "id-1".to_string(), + name: "1.20.1".to_string(), + version_id: "1.20.1".to_string(), + isolation: AdoptIsolationView::Custom, + run_dir: PathBuf::from("/ull/data/instances/1.20.1"), + dir_created: true, + notes: Vec::new(), + }))); + assert_eq!(state.stage(), AdoptStage::Done); + assert!(state.failure.is_none()); + + // 关面板 = 全清(再开要重新扫,否则画的是上一轮的候选) + state.apply(AdoptEvent::Cleared); + assert_eq!(state.stage(), AdoptStage::Pick); + assert!(state.scan.is_none() && state.done.is_none()); +} diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 7963d5e..5a5e8c9 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -15,6 +15,8 @@ //! 序列化能力由 derive 钉住,`dto` 的单测做 round-trip 断言。 mod accounts; +// M223: 导入已有版本(从别的启动器只读登记 versions/) +mod adopt; mod agent; mod content; mod events; @@ -38,6 +40,11 @@ pub use accounts::{ AccountView, AuthlibLoginView, AuthlibRequest, BrowserLoginNote, DeviceLoginPhase, DeviceLoginView, LoginFailure, SkinImage, REDACTED, }; +pub use adopt::{ + AdoptCandidateView, AdoptCommand, AdoptDoneView, AdoptEvent, AdoptFailureView, + AdoptIsolationView, AdoptPlanView, AdoptProblemView, AdoptRequestView, AdoptScanView, + AdoptStage, AdoptState, AdoptTargetView, +}; pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView}; pub use content::{ loader_facet, ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind, diff --git a/crates/presenter/src/host/adopt.rs b/crates/presenter/src/host/adopt.rs new file mode 100644 index 0000000..6658cd2 --- /dev/null +++ b/crates/presenter/src/host/adopt.rs @@ -0,0 +1,261 @@ +//! 「导入已有版本」的宿主侧(M223 GUIADOPT):扫描 / 预检 / 确认三条后台线程。 +//! +//! 能力全在 core(`unlucky_core::game::adopt`,M221):本模块只做三件事——**起线程、翻 DTO、 +//! 按代次回流**。这里不重新判定任何东西:能不能登记、落到哪个运行目录、有哪些问题,全部由 core +//! 说了算,本模块逐字段照搬([`scan_view`] / [`plan_view`] / [`done_view`] / [`failure_view`])。 +//! +//! ## 三个命令与写盘面 +//! +//! | 命令 | core 调用 | 写盘 | +//! |---|---|---| +//! | [`AdoptCommand::Scan`] | `adopt::scan` | 零 | +//! | [`AdoptCommand::Preview`] | `adopt::plan` | 零(预检就是 core 的 dry-run) | +//! | [`AdoptCommand::Confirm`] | `adopt::adopt` | 实例设置 + `profiles.json`(+ 缺省运行目录) | +//! +//! 预检与确认走**同一个** `AdoptRequestView`:页面在预检拿到落点与后果原文,用户点确认时把 +//! **同一份请求**发回来(不是「预检 A、写 B」)。这也是「运行目录只认回程」的实现方式—— +//! 页面从头到尾没有机会自己派生一个目录名。 +//! +//! ## 代次:为什么要它 +//! +//! 三条命令各起一条线程,用户可能连点(扫完再扫、预检后改目标再预检、失败后关面板)。 +//! 每条线程带一个代次回来,`adopt_epoch` 只认**最新**那一条:关面板([`AdoptCommand::Dismiss`]) +//! 也会把代次 +1,于是「点了关闭,两秒后扫描结果又把面板点亮」这种旧病不会发生 +//! (它与 M105 安装取消、M113 导入取消是同一套做法)。 +//! +//! ## 不做什么 +//! +//! - **不自己派生运行目录**:`run_dir` 只从 `plan`/`adopt` 的返回里取(缺省档可能带 `-2` 后缀); +//! - **不自己拼后果话术**:`notes` 原样来自 core 的 `target_notes()`,与 CLI 打的是同一句; +//! - **不折叠问题**:`problems[]` 逐条搬进 [`AdoptFailureView`],一条都不省。 + +use unlucky_core::game::adopt::{ + self, AdoptCandidate, AdoptError, AdoptOutcome, AdoptPlan, AdoptProblem, AdoptRequest, + AdoptTarget, +}; +use unlucky_core::game::install::instance::Isolation; +use unlucky_core::launch::time::{format_rfc3339_millis, now}; + +use crate::dto::{ + AdoptCandidateView, AdoptCommand, AdoptDoneView, AdoptEvent, AdoptFailureView, + AdoptIsolationView, AdoptPlanView, AdoptProblemView, AdoptRequestView, AdoptScanView, + AdoptTargetView, CoreEvent, +}; + +use super::env::HostEnv; +use super::job::{self, HostTask, JobSender}; +use super::CoreHost; + +/// 后台那一步是「只看不写」还是「真的写」。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Mode { + /// `adopt::plan`(预检,零写盘) + Preview, + /// `adopt::adopt`(真写) + Confirm, +} + +impl CoreHost { + /// 作废在飞的 adopt 结果,返回新代次(每条命令都调它,见模块文档「代次」一节)。 + fn next_adopt_epoch(&mut self) -> u64 { + self.adopt_epoch = self.adopt_epoch.wrapping_add(1); + self.adopt_epoch + } + + /// `AppCommand::Adopt` 的落点(M223)。 + /// + /// 每条命令都先把代次 +1:这一条之后回来的旧结果一律被 [`Self::handle_adopt`] 丢掉。 + pub(super) fn adopt(&mut self, command: AdoptCommand) { + match command { + AdoptCommand::Scan => { + let epoch = self.next_adopt_epoch(); + spawn_scan(self.env.clone(), self.tx.clone(), epoch); + } + AdoptCommand::Preview(request) => { + let epoch = self.next_adopt_epoch(); + spawn_adopt( + self.env.clone(), + self.tx.clone(), + *request, + Mode::Preview, + epoch, + ); + } + AdoptCommand::Confirm(request) => { + let epoch = self.next_adopt_epoch(); + spawn_adopt( + self.env.clone(), + self.tx.clone(), + *request, + Mode::Confirm, + epoch, + ); + } + AdoptCommand::Dismiss => { + // 关面板 = 作废在飞的一切(否则迟到的扫描结果会把面板重新点亮) + self.next_adopt_epoch(); + self.send(CoreEvent::Adopt(Box::new(AdoptEvent::Cleared))); + } + } + } + + /// 后台线程的回流落点:代次不符就丢(见模块文档「代次」一节)。 + /// + /// 登记成功之后补一次实例快照刷新:新实例必须**这一拍就出现在实例列表里**, + /// 而不是等用户手点刷新(否则「导入了但看不见」)。 + pub(super) fn handle_adopt(&mut self, event: AdoptEvent, epoch: u64) { + if epoch != self.adopt_epoch { + return; + } + let registered = matches!(event, AdoptEvent::Done(_)); + self.send(CoreEvent::Adopt(Box::new(event))); + if registered { + self.refresh_instances(); + } + } +} + +/// 扫描当前游戏根的 `versions/`(只读)。 +pub(super) fn spawn_scan(env: HostEnv, tx: JobSender, epoch: u64) { + job::spawn("adopt-scan", move || { + let event = match adopt::scan(&env.game, &env.layout) { + Ok(scan) => AdoptEvent::Scan(Box::new(AdoptScanView { + versions_dir: scan.versions_dir.clone(), + candidates: scan.candidates.iter().map(candidate_view).collect(), + })), + // 读目录失败也是**具名**失败(断链的 versions 联接 / 权限),不降级成空列表 + Err(error) => AdoptEvent::Failed(Box::new(failure_view(error))), + }; + let _ = tx.send(HostTask::Adopt { + event: Box::new(event), + epoch, + }); + }); +} + +/// 预检(零写盘)或确认登记。 +pub(super) fn spawn_adopt( + env: HostEnv, + tx: JobSender, + request: AdoptRequestView, + mode: Mode, + epoch: u64, +) { + job::spawn("adopt", move || { + let request = core_request(request); + let event = match mode { + Mode::Preview => match adopt::plan(&env.game, &env.layout, &request) { + Ok(plan) => AdoptEvent::Planned(Box::new(plan_view(plan))), + Err(error) => AdoptEvent::Failed(Box::new(failure_view(error))), + }, + Mode::Confirm => match adopt::adopt(&env.game, &env.layout, &request) { + Ok(outcome) => AdoptEvent::Done(Box::new(done_view(outcome))), + Err(error) => AdoptEvent::Failed(Box::new(failure_view(error))), + }, + }; + let _ = tx.send(HostTask::Adopt { + event: Box::new(event), + epoch, + }); + }); +} + +/// 视图请求 → core 请求(`created_at` 由宿主给:core 不碰时钟)。 +fn core_request(view: AdoptRequestView) -> AdoptRequest { + AdoptRequest { + version_id: view.version_id, + name: view.name.filter(|name| !name.trim().is_empty()), + target: target_of(view.target), + created_at: Some(format_rfc3339_millis(now())), + } +} + +/// 三档视图 → core 的运行目录选择。 +/// +/// `custom` **不映射**:GUI 不开「用户手输绝对路径」这一档(那要目录选择器), +/// 需要它的调用方走 CLI 的 `ull instances adopt --isolation custom --game-dir DIR`。 +fn target_of(view: AdoptTargetView) -> AdoptTarget { + match view { + AdoptTargetView::InstanceDir => AdoptTarget::InstanceDir, + AdoptTargetView::Shared => AdoptTarget::Shared, + AdoptTargetView::VersionDir => AdoptTarget::VersionDir, + } +} + +/// core 隔离档 → 视图。 +fn isolation_view(isolation: Isolation) -> AdoptIsolationView { + match isolation { + Isolation::Isolated => AdoptIsolationView::Isolated, + Isolation::Shared => AdoptIsolationView::Shared, + Isolation::Custom => AdoptIsolationView::Custom, + } +} + +/// core 候选 → 视图(`registered` 只带实例名:界面要的是「给谁登记过了」)。 +fn candidate_view(candidate: &AdoptCandidate) -> AdoptCandidateView { + AdoptCandidateView { + version_id: candidate.version_id.clone(), + jar_present: candidate.jar_present, + inherits: candidate.inherits.clone(), + registered: candidate + .registered + .iter() + .map(|instance| instance.name.clone()) + .collect(), + problems: candidate.problems.iter().map(problem_view).collect(), + } +} + +/// core 问题 → 视图(`code` 稳定 + `Display` 原文:路径、行列号、缺哪个父版本都在里面)。 +fn problem_view(problem: &AdoptProblem) -> AdoptProblemView { + AdoptProblemView { + code: problem.code().to_string(), + message: problem.to_string(), + } +} + +/// core 计划 → 视图。`run_dir` / `notes` **原样**搬运,界面不许再算一遍。 +fn plan_view(plan: AdoptPlan) -> AdoptPlanView { + AdoptPlanView { + version_id: plan.candidate.version_id.clone(), + name: plan.name, + target: target_view(&plan.target), + isolation: isolation_view(plan.isolation), + run_dir: plan.run_dir, + notes: plan.notes, + } +} + +/// core 结果 → 视图。 +fn done_view(outcome: AdoptOutcome) -> AdoptDoneView { + AdoptDoneView { + instance_id: outcome.instance_id, + name: outcome.name, + version_id: outcome.version_id, + isolation: isolation_view(outcome.isolation), + run_dir: outcome.run_dir, + dir_created: outcome.dir_created, + notes: outcome.notes, + } +} + +/// core 运行目录选择 → 视图(`Custom` 只在 core 侧出现,界面按「缺省档」显示)。 +fn target_view(target: &AdoptTarget) -> AdoptTargetView { + match target { + AdoptTarget::InstanceDir | AdoptTarget::Custom(_) => AdoptTargetView::InstanceDir, + AdoptTarget::Shared => AdoptTargetView::Shared, + AdoptTarget::VersionDir => AdoptTargetView::VersionDir, + } +} + +/// core 失败 → 视图:**`problems[]` 一条不少地带出来**(契约 §5.3)。 +fn failure_view(error: AdoptError) -> AdoptFailureView { + AdoptFailureView { + code: error.code().to_string(), + message: error.to_string(), + problems: error.problems().iter().map(problem_view).collect(), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/presenter/src/host/adopt/tests.rs b/crates/presenter/src/host/adopt/tests.rs new file mode 100644 index 0000000..ac1ad6a --- /dev/null +++ b/crates/presenter/src/host/adopt/tests.rs @@ -0,0 +1,217 @@ +//! `host::adopt` 的用例(M223):真盘夹具 + 三条命令的回程形状。 +//! +//! 抓得住:扫描只读(`.ULL/config` 不出现)、预检与确认的落点一致且都来自 core、 +//! 坏清单失败时 `problems[]` 逐条带出(带行列号)、三档映射。 +//! 抓不住:真启动游戏用不用这个运行目录(那是 launch 链路的活);D30 视觉(另走截图通道)。 +//! +//! 为什么单独一个文件:`host/adopt.rs` 带用例会到 440 行,越过 400 行硬线(AGENTS §3.1)。 + +use super::*; +use crate::dto::AdoptStage; + +/// 真盘夹具:`/.minecraft/versions//`(清单 + jar)+ `/.ULL`。 +struct Fixture { + root: std::path::PathBuf, +} + +impl Fixture { + fn new(tag: &str) -> Self { + let root = std::env::temp_dir().join(format!( + "ull-presenter-adopt-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) + )); + std::fs::create_dir_all(&root).expect("建临时根"); + Self { root } + } + + fn env(&self) -> HostEnv { + HostEnv::at_root(&self.root) + } + + /// 铺一个「别的启动器装好的」版本(`manifest` 原样写,便于造坏清单)。 + fn version(&self, id: &str, manifest: &str) { + let dir = self.root.join(".minecraft/versions").join(id); + std::fs::create_dir_all(&dir).expect("建版本目录"); + std::fs::write(dir.join(format!("{id}.json")), manifest).expect("写清单"); + std::fs::write(dir.join(format!("{id}.jar")), b"jar-bytes").expect("写 jar"); + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +const MANIFEST: &str = r#"{ + "id": "1.20.1", + "type": "release", + "mainClass": "net.minecraft.main.Main", + "libraries": [] +}"#; + +fn request(version_id: &str, target: AdoptTargetView) -> AdoptRequestView { + AdoptRequestView { + version_id: version_id.to_string(), + name: None, + target, + } +} + +/// 收一条 `HostTask::Adopt`(同步等后台线程交卷)。 +fn recv(rx: &super::super::job::JobReceiver) -> AdoptEvent { + match rx.recv().expect("后台线程应回一条") { + HostTask::Adopt { event, .. } => *event, + other => panic!("期望 HostTask::Adopt,收到 {other:?}"), + } +} + +#[test] +fn scan_lists_the_foreign_versions_without_writing() { + let fixture = Fixture::new("scan"); + fixture.version("1.20.1", MANIFEST); + let env = fixture.env(); + let (tx, rx) = job::channel(); + spawn_scan(env.clone(), tx, 1); + + let AdoptEvent::Scan(scan) = recv(&rx) else { + panic!("扫描应回 AdoptEvent::Scan"); + }; + assert_eq!(scan.versions_dir, fixture.root.join(".minecraft/versions")); + assert_eq!(scan.total(), 1); + assert_eq!(scan.adoptable(), 1); + assert!(scan.candidates[0].jar_present); + // 扫描零写盘:`.ULL/config` 下不该出现任何东西 + assert!(!fixture.root.join(".ULL/config/profiles.json").exists()); +} + +#[test] +fn confirm_registers_and_reports_the_core_run_dir() { + let fixture = Fixture::new("confirm"); + fixture.version("1.20.1", MANIFEST); + let env = fixture.env(); + + // 先预检:零写盘,落点与后果原文都来自 core + let (tx, rx) = job::channel(); + spawn_adopt( + env.clone(), + tx, + request("1.20.1", AdoptTargetView::InstanceDir), + Mode::Preview, + 1, + ); + let AdoptEvent::Planned(plan) = recv(&rx) else { + panic!("预检应回 AdoptEvent::Planned"); + }; + assert_eq!(plan.target, AdoptTargetView::InstanceDir); + assert_eq!(plan.isolation, AdoptIsolationView::Custom); + // 缺省档的运行目录落数据区(**不是** `versions/`) + assert!( + plan.run_dir + .starts_with(fixture.root.join(".ULL/data/instances")), + "落点应在 ULL 数据区:{}", + plan.run_dir.display() + ); + assert!(!plan.notes.is_empty(), "缺省档也要说清后果"); + assert!(!fixture.root.join(".ULL/config/profiles.json").exists()); + + // 确认:同一份请求发回去,真的登记 + let (tx, rx) = job::channel(); + spawn_adopt( + env, + tx, + request("1.20.1", AdoptTargetView::InstanceDir), + Mode::Confirm, + 2, + ); + let AdoptEvent::Done(done) = recv(&rx) else { + panic!("确认应回 AdoptEvent::Done"); + }; + assert_eq!(done.version_id, "1.20.1"); + assert_eq!(done.run_dir, plan.run_dir, "确认的落点必须与预检一致"); + assert!(done.dir_created); + let profiles = std::fs::read_to_string(fixture.root.join(".ULL/config/profiles.json")) + .expect("profiles.json 应被写出来"); + assert!(profiles.contains("1.20.1")); + // 游戏根的版本目录一个字节都没写(只有夹具自己那两个文件) + let dir = fixture.root.join(".minecraft/versions/1.20.1"); + let mut names: Vec = std::fs::read_dir(&dir) + .expect("读版本目录") + .map(|entry| { + entry + .expect("条目") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect(); + names.sort(); + assert_eq!(names, ["1.20.1.jar", "1.20.1.json"]); +} + +#[test] +fn a_bad_manifest_comes_back_as_every_problem_not_one_sentence() { + let fixture = Fixture::new("problems"); + fixture.version("1.20.1", "{ this is not json"); + let (tx, rx) = job::channel(); + spawn_adopt( + fixture.env(), + tx, + request("1.20.1", AdoptTargetView::InstanceDir), + Mode::Preview, + 1, + ); + let AdoptEvent::Failed(failure) = recv(&rx) else { + panic!("坏清单应回 AdoptEvent::Failed"); + }; + assert_eq!(failure.code, "adopt_not_adoptable"); + assert_eq!(failure.problems.len(), 1); + assert_eq!(failure.problems[0].code, "manifest_invalid_json"); + assert!( + failure.problems[0].message.contains("line"), + "清单问题要带行列号:{}", + failure.problems[0].message + ); +} + +#[test] +fn targets_map_to_core_and_custom_stays_out_of_the_gui() { + assert_eq!( + target_of(AdoptTargetView::InstanceDir), + AdoptTarget::InstanceDir + ); + assert_eq!(target_of(AdoptTargetView::Shared), AdoptTarget::Shared); + assert_eq!( + target_of(AdoptTargetView::VersionDir), + AdoptTarget::VersionDir + ); + // `Custom` 只在 core 侧出现(CLI 的 `--game-dir`):界面按缺省档显示 + assert_eq!( + target_view(&AdoptTarget::Custom(std::path::PathBuf::from("/x"))), + AdoptTargetView::InstanceDir + ); + assert_eq!( + isolation_view(Isolation::Custom), + AdoptIsolationView::Custom + ); + assert_eq!( + isolation_view(Isolation::Shared), + AdoptIsolationView::Shared + ); + assert_eq!( + isolation_view(Isolation::Isolated), + AdoptIsolationView::Isolated + ); + // 空名回落到版本 id 由 core 负责;这里只钉「空白名不进请求」 + let request = core_request(AdoptRequestView { + version_id: "1.20.1".to_string(), + name: Some(" ".to_string()), + target: AdoptTargetView::Shared, + }); + assert_eq!(request.name, None); + assert!(request.created_at.is_some()); + assert_eq!(AdoptStage::Pick.slug(), "pick"); +} diff --git a/crates/presenter/src/host/mod.rs b/crates/presenter/src/host/mod.rs index 94f4aef..6c15375 100644 --- a/crates/presenter/src/host/mod.rs +++ b/crates/presenter/src/host/mod.rs @@ -14,6 +14,8 @@ mod accounts; mod addon; +// M223:导入已有版本(扫描 / 预检 / 确认三条后台线程) +mod adopt; mod dispatch; mod engine; pub mod env; @@ -106,6 +108,10 @@ pub struct CoreHost { /// 一眼能看出「这是旧任务的」,于是既不会被取消掉的安装重新点亮面板,也不会串到下一轮安装 /// (两轮安装同时存在时,旧任务的事件曾经把新任务的进度条顶掉)。 install_epoch: u64, + /// 「导入已有版本」的代次(M223):每条 scan/preview/confirm 命令 +1,关面板也 +1。 + /// + /// 只认最新代次的回程事件——用户连点或点了关闭之后,旧线程带回来的结果不许再把面板点亮。 + adopt_epoch: u64, /// 自更新(M159):检查结论 / 应用状态 / 更新域偏好。 update: update_state::Runtime, } @@ -160,6 +166,7 @@ impl CoreHost { chat: MockBackend::default(), install_cancel: None, install_epoch: 0, + adopt_epoch: 0, update, }; if refresh { From 5a59cb33b2e9aa2706f74ce20799d6ea8d0b7e59 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 14:00:05 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat(presenter):=20adopt=20=E7=9A=84?= =?UTF-8?q?=E6=8C=87=E4=BB=A4/=E4=BA=8B=E4=BB=B6/=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF=20+=20install=20=E9=82=A3=E5=8F=A5=E5=81=87?= =?UTF-8?q?=E8=AF=9D=E5=8F=98=E7=9C=9F=E8=AF=9D=EF=BC=88M223=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppCommand::Adopt(Box)、CoreEvent::Adopt(Box)、 HostTask::Adopt { event, epoch }:三条穷尽枚举各加一条变体(代次独立于安装与 pack); - dispatch.rs 一条分支、handle.rs 一条分支、state.rs 的 adopt 字段与一条 apply 分支; - host/install.rs 的「还没有接入安装管线…请先在实例页导入已有版本」改成指向真入口: 那里以前是一句假话(实例页根本没有导入动作),现在向导落地了,它才成立。 --- crates/presenter/src/dto/events.rs | 9 +++++++++ crates/presenter/src/host/dispatch.rs | 2 ++ crates/presenter/src/host/handle.rs | 1 + crates/presenter/src/host/install.rs | 2 +- crates/presenter/src/host/job.rs | 11 +++++++++++ crates/presenter/src/state.rs | 6 ++++++ 6 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/presenter/src/dto/events.rs b/crates/presenter/src/dto/events.rs index 7fcd695..ed85fe8 100644 --- a/crates/presenter/src/dto/events.rs +++ b/crates/presenter/src/dto/events.rs @@ -12,6 +12,8 @@ use crate::dto::{ // 主线(M109 的 Content* / M113 的 Pack*)与 M114 的 Authlib*/AccountNotice 的并集。 AccountNotice, AccountView, + AdoptCommand, + AdoptEvent, AgentEvent, AuthlibLoginView, AuthlibRequest, @@ -149,6 +151,11 @@ pub enum CoreEvent { // M113: import/export /// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`]) Pack(Box), + /// 导入已有版本(M223):扫描 / 预检 / 完成 / 失败(子枚举见 [`AdoptEvent`]) + /// + /// 与 [`Self::Pack`] 分开是**语义**上必须的:整合包导入会把内容真搬进实例目录, + /// 而这里只登记盘上已有的版本、写两份配置(`versions/` 一个字节都不动)。 + Adopt(Box), /// 自更新(M159):整份状态快照(检查结论 + 就位状态 + 用户是否已拒绝)。 /// /// 与 `SettingsChanged` 同一条形态:快照整体替换,UI 不做增量合并。 @@ -263,6 +270,8 @@ pub enum AppCommand { // M113: import/export /// 整合包导入/导出(M113):导入 / 预检 / 确认写盘 / 关面板(子枚举见 [`PackCommand`]) Pack(Box), + /// 导入已有版本(M223):扫描 / 预检 / 确认登记 / 关面板(子枚举见 [`AdoptCommand`]) + Adopt(Box), // M159: 自更新 /// 查一次更新(设置页「立即检查」;会清掉「忽略这个版本」的标记并重新请求网络) CheckUpdate, diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index ccdf8e9..e747a62 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -253,6 +253,8 @@ impl CoreHost { // (`CancelInstall` 会把标记置位,下载在阶段边界收尾)。 // M113: import/export —— 导入 / 预检 / 确认写盘 / 关面板 AppCommand::Pack(command) => self.pack(*command), + // M223: 导入已有版本 —— 扫描 / 预检 / 确认登记 / 关面板 + AppCommand::Adopt(command) => self.adopt(*command), AppCommand::InstallContent(request) => { let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); self.install_cancel = Some(std::sync::Arc::clone(&cancel)); diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index 493dd92..b6522db 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -69,6 +69,7 @@ impl CoreHost { // M105 的形状(代次丢包)为准:旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉。 // 这是「点了取消没反应」的根因:取消时 `InstallProgress(None)` 刚把面板收回, // 旧任务的下一条「下载中」事件立刻又把它点亮。 + HostTask::Adopt { event, epoch } => self.handle_adopt(*event, epoch), HostTask::Install { progress, epoch } => { if epoch != self.install_epoch { return; diff --git a/crates/presenter/src/host/install.rs b/crates/presenter/src/host/install.rs index b5957c6..43310d4 100644 --- a/crates/presenter/src/host/install.rs +++ b/crates/presenter/src/host/install.rs @@ -106,7 +106,7 @@ pub fn supported(kind: LoaderKind) -> bool { /// 不支持的加载器 → 给用户的可执行建议。 pub fn unsupported_reason(kind: LoaderKind) -> String { format!( - "{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge/LiteLoader);请先在实例页导入已有版本,或换成受支持的加载器。", + "{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge/LiteLoader);如果别的启动器已经装好了这个版本,可以在实例页用「导入已有版本」把它只读接进来(不复制文件),否则请换成受支持的加载器。", kind.label() ) } diff --git a/crates/presenter/src/host/job.rs b/crates/presenter/src/host/job.rs index 7def756..fe02ba7 100644 --- a/crates/presenter/src/host/job.rs +++ b/crates/presenter/src/host/job.rs @@ -68,6 +68,17 @@ pub enum HostTask { /// 任务代次 epoch: u64, }, + // M223: 导入已有版本 + /// 扫描 / 预检 / 确认的结果(一条事件 + 任务代次) + /// + /// 代次是 adopt 自己的(`CoreHost::adopt_epoch`),**不**与安装或 pack 的代次共用: + /// 关一次导入面板不该把下载页在跑的安装静默丢掉。 + Adopt { + /// 事件载荷(扫描 / 预检 / 完成 / 失败) + event: Box, + /// 任务代次 + epoch: u64, + }, /// 安装进度(高频,host 只做聚合与转发) Install { /// 进度快照 diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index 94463e3..a59d9c0 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -8,6 +8,7 @@ use crate::dialogs::DialogQueue; use crate::dto::{ // 主线(M105 的 `LoaderKind` / M113 的 `PackState`)与 M114 的 `AuthlibLoginView` 并集。 AccountView, + AdoptState, AppEvent, AuthlibLoginView, CoreEvent, @@ -133,6 +134,8 @@ pub struct AppState { pub content: ContentState, /// 整合包导入/导出:进度 / 结果 / 探测诊断 / 导出预检(M113) pub pack: PackState, + /// 导入已有版本(M223):扫描 / 预检 / 失败 / 完成(**只装事件态**,表单在页面本地态) + pub adopt: AdoptState, } impl Default for AppState { @@ -178,6 +181,7 @@ impl AppState { live_session: None, content: ContentState::default(), pack: PackState::default(), + adopt: AdoptState::default(), } } @@ -296,6 +300,8 @@ impl AppState { CoreEvent::Update(view) => self.update = *view, // M113: import/export CoreEvent::Pack(event) => self.pack.apply(*event), + // M223: 导入已有版本(唯一写入点:`AdoptState::apply`) + CoreEvent::Adopt(event) => self.adopt.apply(*event), CoreEvent::ContentResults(results) => self.apply_content(*results), CoreEvent::ContentFailed(failure) => self.apply_content_failure(*failure), // M162:详情/版本列表(判断逻辑在 `state/content.rs`,这里只有一行投递) From 41caae35711bb1217d69ef25506d11861b68ef8b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 14:00:06 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat(app-iced):=20=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E9=A1=B5=E3=80=8C=E5=AF=BC=E5=85=A5=E5=B7=B2=E6=9C=89=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E3=80=8D=E5=90=91=E5=AF=BC=EF=BC=88M223=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 页头「导入」按钮(以前是占位动作,点了只打日志)现在打开四段向导: | 段 | 画什么 | |---|---| | Pick | 候选版本(可导入的才可点)+ 目标三档 chip + 实例名(缺省 InstanceDir) | | Preview | 生效运行目录(回程给的)+ 隔离档 + target_notes() 原文逐条 | | Failed | 稳定码 + problems 逐条(不折叠成一句「导入失败」) | | Done | 实例名 + 运行目录 + 提示原文 | - 向导是叠在页面之上的一层(遮罩 + 居中卡),不搅乱 list-detail 的版式判据; - 表单态在 App(app/adopt.rs 的 Form),事件真相在 presenter 的 AdoptState; - 在等回程时主按钮禁用(连点「确认」不会写两条实例); - i18n 中英目录 + 金表补 27 条向导文案(实例域末尾追加)。 --- crates/app-iced/src/app/adopt.rs | 349 ++++++++++++++++++ crates/app-iced/src/app/adopt/panes.rs | 131 +++++++ crates/app-iced/src/app/adopt/pick.rs | 223 +++++++++++ crates/app-iced/src/app/adopt/tests.rs | 201 ++++++++++ crates/app-iced/src/app/adopt/text.rs | 88 +++++ crates/app-iced/src/app/message.rs | 5 + crates/app-iced/src/app/mod.rs | 1 + crates/app-iced/src/app/state.rs | 5 + crates/app-iced/src/app/state/construct.rs | 1 + crates/app-iced/src/app/update.rs | 4 + crates/app-iced/src/app/update/adopt.rs | 176 +++++++++ crates/app-iced/src/app/update/clock.rs | 9 + .../app-iced/src/app/update/page_messages.rs | 5 + crates/app-iced/src/app/view.rs | 13 +- crates/i18n/src/catalog/golden.rs | 27 ++ crates/i18n/src/catalog/instances.rs | 29 ++ 16 files changed, 1265 insertions(+), 2 deletions(-) create mode 100644 crates/app-iced/src/app/adopt.rs create mode 100644 crates/app-iced/src/app/adopt/panes.rs create mode 100644 crates/app-iced/src/app/adopt/pick.rs create mode 100644 crates/app-iced/src/app/adopt/tests.rs create mode 100644 crates/app-iced/src/app/adopt/text.rs create mode 100644 crates/app-iced/src/app/update/adopt.rs diff --git a/crates/app-iced/src/app/adopt.rs b/crates/app-iced/src/app/adopt.rs new file mode 100644 index 0000000..94cba79 --- /dev/null +++ b/crates/app-iced/src/app/adopt.rs @@ -0,0 +1,349 @@ +//! 「导入已有版本」向导(M223 GUIADOPT):实例页页头的入口 + 一张盖在页面上的向导卡。 +//! +//! ## 四段(与 presenter 的 `AdoptState` 一一对应) +//! +//! | 段 | 画什么 | 从哪来 | 文件 | +//! |---|---|---|---| +//! | Pick | 候选版本 + 目标档 + 实例名 | `AdoptEvent::Scan` | [`pick`] | +//! | Preview | 落点 / 隔离档 / **`target_notes()` 原文** | `AdoptEvent::Planned` | [`panes`] | +//! | Failed | 稳定码 + 人读原因 + **`problems[]` 逐条** | `AdoptEvent::Failed` | [`panes`] | +//! | Done | 实例名 / 运行目录 / 提示原文 | `AdoptEvent::Done` | [`panes`] | +//! +//! 「现在画哪一段」只有一处判据(presenter 的 `AdoptState::stage`),本文件只做投影。 +//! +//! ## 三条纪律(契约 §5 的 1/2/3) +//! +//! 1. **运行目录不派生**:Preview / Done 上那个路径**原样**来自回程事件(缺省档可能带 `-2` 后缀, +//! 只有 core 知道最后用了哪个)。本模块没有任何「拼一个目录名」的代码。 +//! 2. **后果话术不重写**:共享档 / 版本目录档的后果句在 Preview 段**逐条**画 `plan.notes` +//! (core `target_notes()` 的原文)。Pick 段只放一句短提示(选之前就得知道会碰别人的目录), +//! 真正的后果原文在确认按钮之前一定会出现。 +//! 3. **不折叠问题**:Failed 段把 `problems` 一条一行画出来,标题只说「导入失败」,原因全在下面。 +//! +//! ## 为什么是「盖一层」而不是实例页里的一段 +//! +//! 向导有四段状态、每段内容都不一样,塞进 list-detail 的正文列会把版式判据(424 散文宽、 +//! 列表独立滚动)搅乱。盖一层与 M187 的模态同一手法(遮罩 + 居中卡),但**不是** +//! `DialogQueue` 那种「标题 + 正文 + 两个按钮」的窄形态:向导要画候选列表、单选档位与输入框。 +//! +//! ## 表单字段归 `App`,事件真相归 presenter +//! +//! [`Form`] 只装「用户正在填的东西」(选中哪个版本、展示名、目标档、有没有在等回程); +//! 候选、预检、失败、完成全部来自 `AppState.adopt`(presenter 的 `AdoptState`)。两边各管一半, +//! 于是「画出来的落点」与「真正写盘的落点」永远是同一份事实。 + +mod panes; +mod pick; +mod text; + +#[cfg(test)] +mod tests; + +use iced::widget::{column, container, mouse_area, opaque, row, Space}; +use iced::{Alignment, Background, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space as Sp; +use unlucky_design::typography::TypeRole; +use unlucky_i18n::{Catalog, Key, Lang}; +use unlucky_presenter::dto::{AdoptCandidateView, AdoptStage, AdoptState, AdoptTargetView}; + +use crate::app::components::buttons::{self, Button}; +use crate::app::components::feedback::dialog::scrim; +use crate::pages::frame; +use crate::theme::Ui; + +use self::text::{isolation_label, target_label, wrapped}; +use super::message::Message; + +/// 向导卡宽度:比模态卡(560)宽一档——候选行要放「版本 id + 状态」,目标档是三枚 chip。 +const CARD_WIDTH: f32 = 640.0; + +/// 候选列表 / 问题清单的滚动高(再多就滚,页脚按钮永远留在卡里)。 +const CARD_LIST_HEIGHT: f32 = 260.0; + +/// 卡片的最大高(窗口更矮时整卡按窗口走:内容在中间区滚)。 +const CARD_MAX_HEIGHT: f32 = 560.0; + +/// 正在等哪一条命令的回程。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Busy { + /// 空闲(可以点) + #[default] + Idle, + /// 正在扫描 `versions/` + Scanning, + /// 正在预检(零写盘) + Checking, + /// 正在真正登记 + Importing, +} + +impl Busy { + /// 有没有在等回程。 + #[must_use] + pub const fn waiting(self) -> bool { + !matches!(self, Self::Idle) + } +} + +/// 向导的表单态(**归 `App`**,页面借一帧)。 +#[derive(Debug, Default)] +pub struct Form { + /// 面板开着吗 + pub open: bool, + /// 选中的版本 id(`None` = 还没选) + pub selected: Option, + /// 展示名(空 = 用版本 id) + pub name: String, + /// 运行目录档(缺省 [`AdoptTargetView::InstanceDir`],**不是** isolated) + pub target: AdoptTargetView, + /// 在等在飞的那一条命令 + pub busy: Busy, +} + +impl Form { + /// 打开面板(回到 Pick 段、清掉上一轮的选中与名字)。 + pub fn open(&mut self) { + *self = Self { + open: true, + busy: Busy::Scanning, + ..Self::default() + }; + } + + /// 关掉面板(全清)。 + pub fn close(&mut self) { + *self = Self::default(); + } + + /// 回程到了:不在等任何东西了。 + pub fn settled(&mut self) { + self.busy = Busy::Idle; + } + + /// 当前选中的候选(选中项已不在列表里时返回 `None`,界面自动退回「请选择」)。 + #[must_use] + pub fn candidate<'a>(&self, adopt_state: &'a AdoptState) -> Option<&'a AdoptCandidateView> { + let id = self.selected.as_deref()?; + adopt_state + .scan + .as_ref()? + .candidates + .iter() + .find(|candidate| candidate.version_id == id) + } +} + +/// 向导消息(`crate::app::Message::Adopt` 的载荷)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AdoptMessage { + /// 打开向导(实例页页头「导入」) + Open, + /// 关掉(点遮罩 / 关闭按钮) + Close, + /// 选中一个候选版本 + Select(String), + /// 改展示名 + Name(String), + /// 改运行目录档 + Target(AdoptTargetView), + /// 预检(零写盘) + Check, + /// 确认登记(唯一写盘入口) + Confirm, + /// 从 Preview / Failed 退回候选列表(等价于重新扫一次) + Back, +} + +/// 向导消息 → app 消息。 +fn adopt(message: AdoptMessage) -> Message { + Message::Adopt(message) +} + +// --------------------------------------------------------------- 视图 + +/// 把向导叠在页面之上(没打开时原样返回页面树)。 +pub(super) fn layer<'a>( + ui: &'a Ui, + base: Element<'a, Message>, + form: &'a Form, + adopt_state: &'a AdoptState, + lang: Lang, +) -> Element<'a, Message> { + if !form.open { + return base; + } + let catalog = lang.catalog(); + let backdrop = mouse_area( + container(Space::new()) + .width(Length::Fill) + .height(Length::Fill) + .style(move |_theme| container::Style { + background: Some(Background::Color(scrim(ui))), + ..container::Style::default() + }), + ) + .on_press(adopt(AdoptMessage::Close)); + + // 透明的一层只负责居中;吃点击的 `opaque` 只包卡片(点卡内不关,点遮罩才关)。 + let centered = container(opaque(card(ui, form, adopt_state, catalog))) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .padding(ui.space(Sp::X6)); + + iced::widget::stack![base, backdrop, centered].into() +} + +/// 卡片本体:标题 + 一句说明 + 中间(按段) + 页脚按钮。 +fn card<'a>( + ui: &'a Ui, + form: &'a Form, + adopt_state: &'a AdoptState, + catalog: Catalog, +) -> Element<'a, Message> { + let body = column![ + frame::text_role( + ui, + TypeRole::HeadlineSmall, + true, + title_text(adopt_state, catalog), + ui.color(Role::OnSurface), + ) + .map(Message::Page), + wrapped( + ui, + subtitle(adopt_state, catalog), + ui.color(Role::OnSurfaceVariant) + ), + frame::divider(ui).map(Message::Page), + middle(ui, form, adopt_state, catalog), + frame::divider(ui).map(Message::Page), + footer(ui, form, adopt_state, catalog), + ] + .spacing(ui.space(Sp::X3)) + .width(Length::Fill); + + container(body) + .width(Length::Fixed(CARD_WIDTH)) + .max_height(CARD_MAX_HEIGHT) + .padding(ui.space(Sp::X6)) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))), + border: ui.outlined(ShapeScale::ExtraLarge), + ..container::Style::default() + }) + .into() +} + +/// 段标题(四段各一句)。 +fn title_text(adopt_state: &AdoptState, catalog: Catalog) -> &'static str { + match adopt_state.stage() { + AdoptStage::Pick => catalog.text(Key::AdoptTitle), + AdoptStage::Preview => catalog.text(Key::AdoptPlanTitle), + AdoptStage::Failed => catalog.text(Key::AdoptFailureTitle), + AdoptStage::Done => catalog.text(Key::AdoptDoneTitle), + } +} + +/// 标题下那句说明(Pick 段说清「什么都不复制」;Done 段说清「去实例列表启动」)。 +fn subtitle(adopt_state: &AdoptState, catalog: Catalog) -> String { + match adopt_state.stage() { + AdoptStage::Pick => catalog.text(Key::AdoptSubtitle).to_string(), + AdoptStage::Preview => adopt_state + .plan + .as_ref() + .map(|plan| { + format!( + "{} · {}", + isolation_label(plan.isolation, catalog), + target_label(plan.target, catalog) + ) + }) + .unwrap_or_default(), + AdoptStage::Failed => adopt_state + .failure + .as_ref() + .map(|failure| failure.message.clone()) + .unwrap_or_default(), + AdoptStage::Done => catalog.text(Key::AdoptDoneHint).to_string(), + } +} + +/// 中间区(按段交给 `pick` / `panes`)。 +fn middle<'a>( + ui: &'a Ui, + form: &'a Form, + adopt_state: &'a AdoptState, + catalog: Catalog, +) -> Element<'a, Message> { + match adopt_state.stage() { + AdoptStage::Pick => pick::pick(ui, form, adopt_state.scan.as_ref(), catalog), + AdoptStage::Preview => panes::preview(ui, adopt_state.plan.as_ref(), catalog), + AdoptStage::Failed => panes::failed(ui, adopt_state.failure.as_ref(), catalog), + AdoptStage::Done => panes::done(ui, adopt_state.done.as_ref(), catalog), + } +} + +/// 页脚按钮(按段给不同组合;在等回程时主按钮禁用)。 +fn footer<'a>( + ui: &'a Ui, + form: &'a Form, + adopt_state: &'a AdoptState, + catalog: Catalog, +) -> Element<'a, Message> { + let stage = adopt_state.stage(); + let busy = form.busy.waiting(); + + let primary: Element<'a, Message> = match stage { + AdoptStage::Pick => { + let ready = form.candidate(adopt_state).is_some_and(|c| c.adoptable()) && !busy; + if ready { + buttons::filled( + ui, + Button::new(catalog.text(Key::AdoptPreview), adopt(AdoptMessage::Check)), + ) + } else { + buttons::filled(ui, Button::disabled(catalog.text(Key::AdoptPreview))) + } + } + AdoptStage::Preview if !busy => buttons::filled( + ui, + Button::new( + catalog.text(Key::AdoptConfirm), + adopt(AdoptMessage::Confirm), + ), + ), + AdoptStage::Preview => { + buttons::filled(ui, Button::disabled(catalog.text(Key::AdoptConfirm))) + } + // 失败 / 完成:主按钮就是关掉(Done 不用「去列表看」——实例已经在列表里了) + AdoptStage::Failed | AdoptStage::Done => buttons::filled( + ui, + Button::new(catalog.text(Key::AdoptClose), adopt(AdoptMessage::Close)), + ), + }; + + let mut actions = row![].spacing(ui.space(Sp::X2)).width(Length::Fill); + // Preview / Failed 给一条「退回候选列表」(= 重新扫一次,代价只有一次读目录) + if matches!(stage, AdoptStage::Preview | AdoptStage::Failed) { + actions = actions.push(buttons::text( + ui, + Button::new(catalog.text(Key::AdoptBack), adopt(AdoptMessage::Back)), + )); + } + actions = actions.push(container(Space::new()).width(Length::Fill)); + if !matches!(stage, AdoptStage::Done) { + actions = actions.push(buttons::text( + ui, + Button::new(catalog.text(Key::AdoptClose), adopt(AdoptMessage::Close)), + )); + } + actions = actions.push(primary); + + column![actions].width(Length::Fill).into() +} diff --git a/crates/app-iced/src/app/adopt/panes.rs b/crates/app-iced/src/app/adopt/panes.rs new file mode 100644 index 0000000..2f31048 --- /dev/null +++ b/crates/app-iced/src/app/adopt/panes.rs @@ -0,0 +1,131 @@ +//! Preview / Failed / Done 三段。 +//! +//! | 段 | 画什么 | 判据 | +//! |---|---|---| +//! | Preview | 生效运行目录(**回程给的**)+ 隔离档 + `notes` 原文逐条 | `AdoptEvent::Planned` | +//! | Failed | 稳定码 + `problems[]` 逐条 | `AdoptEvent::Failed` | +//! | Done | 实例名 + 运行目录 + `notes` 原文逐条 | `AdoptEvent::Done` | +//! +//! 三段都不自己算路径、不自己写后果句:`run_dir` 与 `notes` 是 core 的返回值原样。 +//! 缺省档会带 `-2`/`-3` 后缀(两个不同展示名可能落到同一个物理目录),**只有 core 知道** +//! 最后用了哪个——页面对它再做一次派生就会与真正启动时用的目录分叉。 + +use iced::widget::{column, scrollable}; +use iced::{Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::spacing::Space as Sp; +use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::{AdoptDoneView, AdoptFailureView, AdoptPlanView}; + +use crate::pages::frame; +use crate::theme::Ui; + +use super::super::message::Message; +use super::text::{field_line, isolation_label, mono, problem_line, wrapped}; +use super::CARD_LIST_HEIGHT; + +/// 预检结果:落点 + 隔离档 + 后果原文。 +pub(super) fn preview<'a>( + ui: &'a Ui, + plan: Option<&'a AdoptPlanView>, + catalog: Catalog, +) -> Element<'a, Message> { + let Some(plan) = plan else { + return wrapped( + ui, + catalog.text(Key::AdoptScanning).to_string(), + ui.color(Role::OnSurfaceVariant), + ); + }; + let mut body = column![ + field_line( + ui, + catalog.text(Key::AdoptPlanRunDir), + &plan.run_dir.display().to_string() + ), + field_line( + ui, + catalog.text(Key::AdoptPlanIsolation), + &isolation_label(plan.isolation, catalog), + ), + ] + .spacing(ui.space(Sp::X2)) + .width(Length::Fill); + + // 后果话术:core `target_notes()` 的原文,一条一行、一个字不改 + for note in &plan.notes { + body = body.push(wrapped(ui, note.clone(), ui.color(Role::OnSurfaceVariant))); + } + body.into() +} + +/// 失败:稳定码 + **problems 逐条**。 +pub(super) fn failed<'a>( + ui: &'a Ui, + failure: Option<&'a AdoptFailureView>, + catalog: Catalog, +) -> Element<'a, Message> { + let Some(failure) = failure else { + return wrapped( + ui, + catalog.text(Key::AdoptScanning).to_string(), + ui.color(Role::OnSurfaceVariant), + ); + }; + let mut body = column![mono(ui, failure.code.clone())] + .spacing(ui.space(Sp::X2)) + .width(Length::Fill); + + if failure.problems.is_empty() { + body = body.push(wrapped( + ui, + failure.message.clone(), + ui.color(Role::OnSurfaceVariant), + )); + return body.into(); + } + body = body.push(wrapped( + ui, + catalog.text(Key::AdoptProblemCount).to_string(), + ui.color(Role::OnSurfaceVariant), + )); + for problem in &failure.problems { + body = body.push(problem_line(ui, problem)); + } + scrollable(body) + .height(Length::Fixed(CARD_LIST_HEIGHT)) + .style(frame::scroll_style(ui)) + .into() +} + +/// 完成:实例名 + 运行目录 + 提示原文。 +pub(super) fn done<'a>( + ui: &'a Ui, + done: Option<&'a AdoptDoneView>, + catalog: Catalog, +) -> Element<'a, Message> { + let Some(done) = done else { + return wrapped(ui, String::new(), ui.color(Role::OnSurfaceVariant)); + }; + let mut body = column![ + field_line(ui, catalog.text(Key::AdoptTitle), &done.name), + field_line( + ui, + catalog.text(Key::AdoptPlanRunDir), + &done.run_dir.display().to_string() + ), + field_line( + ui, + catalog.text(Key::AdoptPlanIsolation), + &isolation_label(done.isolation, catalog), + ), + ] + .spacing(ui.space(Sp::X2)) + .width(Length::Fill); + + for note in &done.notes { + body = body.push(wrapped(ui, note.clone(), ui.color(Role::OnSurfaceVariant))); + } + body.into() +} diff --git a/crates/app-iced/src/app/adopt/pick.rs b/crates/app-iced/src/app/adopt/pick.rs new file mode 100644 index 0000000..3a5cace --- /dev/null +++ b/crates/app-iced/src/app/adopt/pick.rs @@ -0,0 +1,223 @@ +//! Pick 段:候选列表 + 目标档 + 实例名。 +//! +//! **可导入的候选才可点**:不可导入的行把 `problems` 逐条列在下面但不接点击——点它只会换来 +//! 一条注定被 core 拒绝的命令(`adopt_not_adoptable`),而用户从行上已经看到了原因。 +//! +//! 目标档三枚 chip 的**短提示**在选中时立刻换(共享/版本目录档用 `Error` 色); +//! **后果原文**不在这里——它在 Preview 段逐条画 `plan.notes`(core `target_notes()` 的原文)。 + +use iced::widget::{button, column, container, row, scrollable}; +use iced::{Alignment, Background, Border, Color, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space as Sp; +use unlucky_design::typography::TypeRole; +use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::{AdoptCandidateView, AdoptScanView, AdoptTargetView}; + +use crate::app::components::inputs::{text_field, Field}; +use crate::app::components::selection::{chip, Chip}; +use crate::pages::frame; +use crate::theme::Ui; + +use super::super::message::Message; +use super::text::{label, problem_line, target_label, wrapped}; +use super::{adopt, AdoptMessage, Form, CARD_LIST_HEIGHT}; + +/// Pick 段整体。 +pub(super) fn pick<'a>( + ui: &'a Ui, + form: &'a Form, + scan: Option<&'a AdoptScanView>, + catalog: Catalog, +) -> Element<'a, Message> { + let Some(scan) = scan else { + // 扫描还没回来:一行进度文案(不做假进度条) + return wrapped( + ui, + catalog.text(Key::AdoptScanning).to_string(), + ui.color(Role::OnSurfaceVariant), + ); + }; + + let mut body = column![].spacing(ui.space(Sp::X3)).width(Length::Fill); + if scan.is_empty() { + let dir = scan.versions_dir.display().to_string(); + body = body.push(wrapped( + ui, + catalog.render(Key::AdoptEmpty, &[("dir", &dir)]), + ui.color(Role::OnSurfaceVariant), + )); + } else { + body = body.push(label(ui, catalog.text(Key::AdoptSelectLabel))); + body = body.push(candidate_list(ui, form, scan, catalog)); + } + + body = body.push(frame::divider(ui).map(Message::Page)); + body = body.push(label(ui, catalog.text(Key::AdoptTargetLabel))); + body = body.push(target_row(ui, form, catalog)); + body = body.push(text_field( + ui, + Field::new(form.name.as_str()).label(catalog.text(Key::AdoptNameLabel)), + |value| adopt(AdoptMessage::Name(value)), + )); + body.into() +} + +/// 候选列表(可滚,定高:列表再长页脚按钮也留在卡里)。 +fn candidate_list<'a>( + ui: &'a Ui, + form: &'a Form, + scan: &'a AdoptScanView, + catalog: Catalog, +) -> Element<'a, Message> { + let rows = column( + scan.candidates + .iter() + .map(|candidate| candidate_row(ui, form, candidate, catalog)) + .collect::>(), + ) + .spacing(ui.space(Sp::X1)) + .width(Length::Fill); + + scrollable(rows) + .height(Length::Fixed(CARD_LIST_HEIGHT)) + .style(frame::scroll_style(ui)) + .into() +} + +/// 一条候选。 +fn candidate_row<'a>( + ui: &'a Ui, + form: &'a Form, + candidate: &'a AdoptCandidateView, + catalog: Catalog, +) -> Element<'a, Message> { + let selected = form.selected.as_deref() == Some(candidate.version_id.as_str()); + + let mut items = column![row![ + frame::text_role( + ui, + TypeRole::BodyLarge, + selected, + candidate.version_id.as_str(), + ui.color(Role::OnSurface), + ) + .map(Message::Page), + container(status(ui, candidate, selected, catalog)) + .width(Length::Fill) + .align_x(Alignment::End), + ] + .width(Length::Fill) + .align_y(Alignment::Center)] + .spacing(ui.space(Sp::X1)) + .width(Length::Fill); + + // 问题逐条(不折叠):候选阶段就把「为什么不能导入」说清楚 + for problem in &candidate.problems { + items = items.push(problem_line(ui, problem)); + } + + let body = container(items) + .width(Length::Fill) + .padding(ui.space(Sp::X2)) + .style(move |_theme| container::Style { + background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))), + border: Border { + radius: ui.radius_dp(ShapeScale::Medium), + ..Border::default() + }, + ..container::Style::default() + }); + + if !candidate.adoptable() { + // 不可导入的行**不可点**(也就不存在「点了才被告知不行」这条路) + return body.into(); + } + button(body) + .width(Length::Fill) + .padding(0) + .style(|_theme, _status| button::Style { + background: None, + text_color: Color::TRANSPARENT, + border: Border::default(), + shadow: iced::Shadow::default(), + snap: true, + }) + .on_press(adopt(AdoptMessage::Select(candidate.version_id.clone()))) + .into() +} + +/// 候选右侧那枚状态(可导入 / N 个问题 / 已被某实例使用)。 +fn status<'a>( + ui: &'a Ui, + candidate: &AdoptCandidateView, + selected: bool, + catalog: Catalog, +) -> Element<'a, Message> { + if !candidate.adoptable() { + let count = candidate.problems.len().to_string(); + return wrapped( + ui, + catalog.render(Key::AdoptBlocked, &[("count", &count)]), + ui.color(Role::Error), + ); + } + if let Some(name) = candidate.registered.first() { + return wrapped( + ui, + catalog.render(Key::AdoptRegistered, &[("name", name)]), + ui.color(Role::OnSurfaceVariant), + ); + } + if selected { + return chip(ui, Chip::current(catalog.text(Key::AdoptReady))); + } + wrapped( + ui, + catalog.text(Key::AdoptReady).to_string(), + ui.color(Role::OnSurfaceVariant), + ) +} + +/// 三枚目标 chip(缺省档在最前;共享 / 版本目录档必须**明确点选**)。 +fn target_row<'a>(ui: &'a Ui, form: &'a Form, catalog: Catalog) -> Element<'a, Message> { + let chips = AdoptTargetView::ALL + .into_iter() + .map(|target| { + chip( + ui, + Chip::filter( + target_label(target, catalog), + form.target == target, + adopt(AdoptMessage::Target(target)), + ), + ) + }) + .collect::>(); + + column![ + row(chips).spacing(ui.space(Sp::X2)).width(Length::Fill), + target_hint(ui, form.target, catalog), + ] + .spacing(ui.space(Sp::X1)) + .width(Length::Fill) + .into() +} + +/// 目标档的一句话说明(点选后立刻换)。**不是后果原文**:原文在 Preview 段逐条画 +/// `plan.notes`(core `target_notes()`)。 +fn target_hint<'a>(ui: &'a Ui, target: AdoptTargetView, catalog: Catalog) -> Element<'a, Message> { + let key = match target { + AdoptTargetView::InstanceDir => Key::AdoptTargetInstanceDirHint, + AdoptTargetView::Shared => Key::AdoptTargetSharedHint, + AdoptTargetView::VersionDir => Key::AdoptTargetVersionDirHint, + }; + let color = if target.touches_foreign_dir() { + ui.color(Role::Error) + } else { + ui.color(Role::OnSurfaceVariant) + }; + wrapped(ui, catalog.text(key).to_string(), color) +} diff --git a/crates/app-iced/src/app/adopt/tests.rs b/crates/app-iced/src/app/adopt/tests.rs new file mode 100644 index 0000000..87d2a6b --- /dev/null +++ b/crates/app-iced/src/app/adopt/tests.rs @@ -0,0 +1,201 @@ +//! `app::adopt` 的用态用例(M223):表单态 + 四段投影。 +//! +//! 抓得住: +//! - `Form` 的生命周期(open 清上一轮 / close 全清 / settled 解除等待); +//! - `candidate()` 在选中项不在列表里时返回 `None`(界面自动退回「请选择」); +//! - 四段的文案真源(标题/后果原文/问题逐条)能从 `AdoptState` 投影出来。 +//! +//! 抓不住:**像素**(那是 D30 的截图通道);也不抓 core 的判据(那是 `host/adopt/tests.rs`)。 + +use unlucky_i18n::{Key, Lang}; +use unlucky_presenter::dto::{ + AdoptCandidateView, AdoptDoneView, AdoptEvent, AdoptFailureView, AdoptIsolationView, + AdoptPlanView, AdoptProblemView, AdoptScanView, AdoptStage, AdoptState, AdoptTargetView, +}; + +use super::*; + +fn candidate(version_id: &str, problems: Vec) -> AdoptCandidateView { + AdoptCandidateView { + version_id: version_id.to_string(), + jar_present: true, + inherits: Vec::new(), + registered: Vec::new(), + problems, + } +} + +fn state_with_scan() -> AdoptState { + let mut state = AdoptState::default(); + state.apply(AdoptEvent::Scan(Box::new(AdoptScanView { + versions_dir: std::path::PathBuf::from("/game/versions"), + candidates: vec![candidate("1.20.1", Vec::new())], + }))); + state +} + +/// 打开 ⇒ 关闭:上一轮的选中与名字都要清掉(否则下次打开画的是上一次的草稿)。 +#[test] +fn open_and_close_reset_the_form() { + let mut form = Form::default(); + form.open(); + assert!(form.open); + assert_eq!(form.busy, Busy::Scanning); + assert_eq!(form.target, AdoptTargetView::InstanceDir); + + form.selected = Some("1.20.1".to_string()); + form.name = "生存".to_string(); + form.close(); + assert!(!form.open); + assert!(form.selected.is_none()); + assert!(form.name.is_empty()); + + // 再开一次也不该带着上一轮的选中项 + form.open(); + assert!(form.selected.is_none()); +} + +/// `settled` 只解除等待,不动用户填的东西(回程不该清表单)。 +#[test] +fn settling_keeps_what_the_user_typed() { + let mut form = Form::default(); + form.open(); + form.selected = Some("1.20.1".to_string()); + form.name = "生存".to_string(); + form.settled(); + assert_eq!(form.busy, Busy::Idle); + assert_eq!(form.selected.as_deref(), Some("1.20.1")); + assert_eq!(form.name, "生存"); +} + +/// 选中项不在候选列表里 ⇒ `candidate()` 是 `None`(界面不能对着一个不存在的版本画「预检」)。 +#[test] +fn candidate_is_none_when_the_selection_vanished() { + let state = state_with_scan(); + let mut form = Form { + selected: Some("1.20.1".to_string()), + ..Form::default() + }; + assert!(form.candidate(&state).is_some()); + form.selected = Some("9.9.9".to_string()); + assert!(form.candidate(&state).is_none()); +} + +/// 四段的标题与后果原文都从目录来(不写死英文字面量、不重写 core 的后果句)。 +#[test] +fn the_four_stages_project_from_the_event_state() { + let catalog = Lang::ZhCn.catalog(); + let mut state = state_with_scan(); + + // Pick + assert_eq!(state.stage(), AdoptStage::Pick); + assert_eq!( + title_text(&state, catalog), + catalog.text(Key::AdoptTitle), + "标题走目录" + ); + assert!(subtitle(&state, catalog).contains("不复制")); + + // Preview:`notes` 是 core `target_notes()` 的原文,界面一个字都不许改 + let note = + "运行目录 = /game(共享档):mods/saves/config 会落在别人的 .minecraft 里".to_string(); + state.apply(AdoptEvent::Planned(Box::new(AdoptPlanView { + version_id: "1.20.1".to_string(), + name: "1.20.1".to_string(), + target: AdoptTargetView::Shared, + isolation: AdoptIsolationView::Shared, + run_dir: std::path::PathBuf::from("/game"), + notes: vec![note.clone()], + }))); + assert_eq!(state.stage(), AdoptStage::Preview); + assert_eq!( + state.plan.as_ref().map(|plan| plan.notes.first().cloned()), + Some(Some(note.clone())), + "后果原文必须原样留着" + ); + + // Failed:problems 逐条(不折叠) + state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView { + code: "adopt_not_adoptable".to_string(), + message: "版本 1.19.4 不能登记".to_string(), + problems: vec![ + AdoptProblemView { + code: "manifest_missing".to_string(), + message: "没有清单".to_string(), + }, + AdoptProblemView { + code: "missing_parent".to_string(), + message: "缺父版本 1.20.1".to_string(), + }, + ], + }))); + assert_eq!(state.stage(), AdoptStage::Failed); + assert_eq!( + state.failure.as_ref().map(|f| f.problems.len()), + Some(2), + "两条问题一条都不许吞" + ); + assert_eq!( + title_text(&state, catalog), + catalog.text(Key::AdoptFailureTitle) + ); + + // Done + state.apply(AdoptEvent::Done(Box::new(AdoptDoneView { + instance_id: "id-1".to_string(), + name: "1.20.1".to_string(), + version_id: "1.20.1".to_string(), + isolation: AdoptIsolationView::Shared, + run_dir: std::path::PathBuf::from("/game"), + dir_created: false, + notes: vec![note], + }))); + assert_eq!(state.stage(), AdoptStage::Done); + assert_eq!( + title_text(&state, catalog), + catalog.text(Key::AdoptDoneTitle) + ); +} + +/// 中英两套目录的向导文案都不是占位(`text()` 回落到 key 名就是这个用例要抓的假绿)。 +#[test] +fn both_catalogs_have_the_wizard_wording() { + for lang in [Lang::En, Lang::ZhCn] { + let catalog = lang.catalog(); + for key in [ + Key::AdoptTitle, + Key::AdoptSubtitle, + Key::AdoptScanning, + Key::AdoptEmpty, + Key::AdoptSelectLabel, + Key::AdoptReady, + Key::AdoptTargetLabel, + Key::AdoptTargetInstanceDir, + Key::AdoptTargetShared, + Key::AdoptTargetVersionDir, + Key::AdoptNameLabel, + Key::AdoptPreview, + Key::AdoptConfirm, + Key::AdoptBack, + Key::AdoptClose, + Key::AdoptPlanTitle, + Key::AdoptPlanRunDir, + Key::AdoptPlanIsolation, + Key::AdoptFailureTitle, + Key::AdoptProblemCount, + Key::AdoptDoneTitle, + Key::AdoptDoneHint, + ] { + let value = catalog.text(key); + assert!(!value.is_empty(), "{key:?} 在 {lang:?} 是空的"); + assert_ne!( + value, + format!("{key:?}"), + "{key:?} 在 {lang:?} 没进目录(回落成了 key 名)" + ); + } + } + // 共享档的短提示必须把「会写进别人的 .minecraft」说清楚(选之前就得知道) + let hint = Lang::En.catalog().text(Key::AdoptTargetSharedHint); + assert!(hint.contains(".minecraft"), "{hint}"); +} diff --git a/crates/app-iced/src/app/adopt/text.rs b/crates/app-iced/src/app/adopt/text.rs new file mode 100644 index 0000000..04e6e23 --- /dev/null +++ b/crates/app-iced/src/app/adopt/text.rs @@ -0,0 +1,88 @@ +//! 向导里的小工具:等宽行、可换行正文、标签、问题行、档位人话。 +//! +//! 单独一个文件只为守 400 行硬线(AGENTS §3.1):三段视图(`pick` / `panes`)与卡壳 +//! (`adopt.rs`)都要用它们,放哪儿都会让那一处越线。 + +use iced::widget::text::LineHeight; +use iced::widget::{column, text}; +use iced::{Color, Element, Font, Length, Pixels}; + +use unlucky_design::color::Role; +use unlucky_design::typography::TypeRole; +use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::{AdoptIsolationView, AdoptProblemView, AdoptTargetView}; + +use crate::pages::frame; +use crate::theme::Ui; + +use super::super::message::Message; + +/// 段内的次级标签(`LabelMedium` + `OnSurfaceVariant`)。 +pub(super) fn label<'a>(ui: &'a Ui, value: &'a str) -> Element<'a, Message> { + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + value, + ui.color(Role::OnSurfaceVariant), + ) + .map(Message::Page) +} + +/// 等宽一行(路径 / 机读码要能逐字读)。 +pub(super) fn mono<'a>(ui: &'a Ui, value: String) -> Element<'a, Message> { + let style = ui.style(TypeRole::BodySmall); + text(value) + .size(style.size_px) + .line_height(LineHeight::Absolute(Pixels(style.line_height_px))) + .font(Font { + family: iced::font::Family::Name(ui.families().mono), + ..Font::DEFAULT + }) + .color(ui.color(Role::OnSurface)) + .width(Length::Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph) + .into() +} + +/// 一段可换行的正文(长路径只按词折行会把卡顶破,所以按词或字形折)。 +pub(super) fn wrapped<'a>(ui: &'a Ui, value: String, color: Color) -> Element<'a, Message> { + frame::text_role_owned(ui, TypeRole::BodySmall, false, value, color).map(Message::Page) +} + +/// 「标签 + 值」一行(值用等宽)。 +pub(super) fn field_line<'a>(ui: &'a Ui, label_text: &'a str, value: &str) -> Element<'a, Message> { + column![label(ui, label_text), mono(ui, value.to_string())] + .spacing(0.0) + .width(Length::Fill) + .into() +} + +/// 一条问题:稳定码(等宽) + 人读原因(`Error` 色)。**一条都不许折叠**。 +pub(super) fn problem_line<'a>(ui: &'a Ui, problem: &'a AdoptProblemView) -> Element<'a, Message> { + column![ + mono(ui, problem.code.clone()), + wrapped(ui, problem.message.clone(), ui.color(Role::Error)), + ] + .spacing(0.0) + .width(Length::Fill) + .into() +} + +/// 隔离档的人话(沿用实例页已有的两条;`custom` 就是缺省档,直接说「在 ULL 数据区新建」)。 +pub(super) fn isolation_label(isolation: AdoptIsolationView, catalog: Catalog) -> String { + match isolation { + AdoptIsolationView::Isolated => catalog.text(Key::InstancesIsolationIsolated).to_string(), + AdoptIsolationView::Shared => catalog.text(Key::InstancesIsolationShared).to_string(), + AdoptIsolationView::Custom => catalog.text(Key::AdoptTargetInstanceDir).to_string(), + } +} + +/// 目标档的短标签。 +pub(super) fn target_label(target: AdoptTargetView, catalog: Catalog) -> &'static str { + match target { + AdoptTargetView::InstanceDir => catalog.text(Key::AdoptTargetInstanceDir), + AdoptTargetView::Shared => catalog.text(Key::AdoptTargetShared), + AdoptTargetView::VersionDir => catalog.text(Key::AdoptTargetVersionDir), + } +} diff --git a/crates/app-iced/src/app/message.rs b/crates/app-iced/src/app/message.rs index 2d9c4e6..f9ec644 100644 --- a/crates/app-iced/src/app/message.rs +++ b/crates/app-iced/src/app/message.rs @@ -69,6 +69,11 @@ pub enum Message { Demo(components::gallery::DemoMessage), /// 模态对话框(M187):主/次按钮、遮罩、展开、导出、打开文件夹、复制路径。 Dialog(super::dialog::Message), + /// 导入已有版本向导(M223):打开 / 选版本 / 选目标 / 预检 / 确认 / 关闭。 + /// + /// 落点是 `app/update/adopt.rs`。页面只发一条入口意图 + /// (`pages::Message::Action(pages::Action::Import)`),向导自己的四段状态都在这里收口。 + Adopt(super::adopt::AdoptMessage), } /// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。 diff --git a/crates/app-iced/src/app/mod.rs b/crates/app-iced/src/app/mod.rs index 4e2a686..4f428d9 100644 --- a/crates/app-iced/src/app/mod.rs +++ b/crates/app-iced/src/app/mod.rs @@ -27,6 +27,7 @@ //! `src/components/`(组件目录自己的归属规则不变),逻辑路径仍是 `crate::app::components` //! (组件源码里的文档与测试都按这个路径写)。 +mod adopt; mod appearance; mod backend; mod boot; diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index c575516..649aeb0 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -196,6 +196,11 @@ pub struct App { /// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在 /// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。 pub(super) launch_form: pages::LaunchForm, + /// 实例页「导入已有版本」向导的表单态(M223):选中版本 / 展示名 / 目标档 / 在等回程。 + /// + /// 住在 `App` 的理由与 `launch_form` 一致:向导跨帧(扫描 → 选 → 预检 → 确认), + /// 而候选/预检/失败/完成这些**事件真相**在 `AppState.adopt`(presenter),不在这里。 + pub(super) adopt_form: super::adopt::Form, /// 启动失败弹窗的**事实**(M187):与 `state.dialogs` 里的当前条按 id 绑定。 /// /// 为什么事实不塞进 `DialogRequest`:那是 presenter 的公共 DTO,弹窗与「这次失败的现场」 diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index 1b4ba1a..64a2148 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -136,6 +136,7 @@ impl App { // 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串 download_draft: pages::InstallDraft::default(), launch_form: pages::LaunchForm::default(), + adopt_form: super::super::adopt::Form::default(), install_cancelled_seen: 0, launch_ends_seen: 0, dialog_card: None, diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index 80cdd9f..ca9d02d 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -46,6 +46,8 @@ use super::state::{App, IDLE_TICK, TICK, WATCH_WINDOW}; use super::CAPTURE_AT_FRAME; mod account; +// M223:导入已有版本向导(扫描 / 预检 / 确认 / 关闭 / 退回) +mod adopt; mod agent; mod appearance; pub(super) mod boot_env; @@ -149,6 +151,8 @@ impl App { } Message::Page(message) => self.on_page(message), Message::Dialog(message) => dialog::on_dialog(self, message), + // M223:导入已有版本向导(打开 / 选版本 / 选目标 / 预检 / 确认 / 退回 / 关闭) + Message::Adopt(message) => self.on_adopt(message), Message::Agent(message) => { // `Esc` 先给弹窗:有弹窗时它属于「关掉这一层」,不属于 AI 栏的收起。 if let crate::shell::agent::Message::Key(event) = &message { diff --git a/crates/app-iced/src/app/update/adopt.rs b/crates/app-iced/src/app/update/adopt.rs new file mode 100644 index 0000000..c5aee94 --- /dev/null +++ b/crates/app-iced/src/app/update/adopt.rs @@ -0,0 +1,176 @@ +//! 「导入已有版本」向导的落点(M223 GUIADOPT)。 +//! +//! ## 四段各自发什么、等什么 +//! +//! | 用户动作 | 发什么 | 等什么回程 | +//! |---|---|---| +//! | 页头「导入」/ [`AdoptMessage::Open`] | `AdoptCommand::Scan` | `AdoptEvent::Scan` | +//! | 选目标 + 点「预检」/ [`AdoptMessage::Check`] | `AdoptCommand::Preview` | `Planned` / `Failed` | +//! | 点「确认导入」/ [`AdoptMessage::Confirm`] | `AdoptCommand::Confirm` | `Done` / `Failed` | +//! | 「返回」/ [`AdoptMessage::Back`] | `AdoptCommand::Scan`(重扫一次,顺手清掉预检/失败) | `Scan` | +//! | 「关闭」/ [`AdoptMessage::Close`] | `AdoptCommand::Dismiss` | `Cleared` | +//! +//! ## 三段纪律 +//! +//! 1. **不在本地判「能不能导入」**:候选可不可用、落点在哪、有哪些问题全部来自 presenter 的回程 +//! (`AdoptState`)。这里只把「用户点了什么」翻成命令; +//! 2. **确认只发用户填过的那份请求**(版本 + 名字 + 目标档),预检与确认**同一份**—— +//! 于是「预检 A、写 B」这种错位不可能发生; +//! 3. **在等回程时把主按钮并掉**(`Form::busy`):连点两次「确认」不会写两条实例记录。 + +use iced::Task; +use unlucky_presenter::dto::{AdoptCommand, AdoptRequestView, AppCommand}; + +use crate::app::adopt::{AdoptMessage, Busy}; + +use super::super::message::Message; +use super::super::state::App; + +impl App { + /// 向导的一条消息。 + pub(super) fn on_adopt(&mut self, message: AdoptMessage) -> Task { + match message { + AdoptMessage::Open => { + self.adopt_form.open(); + self.dispatch_adopt(AdoptCommand::Scan); + } + AdoptMessage::Close => { + self.adopt_form.close(); + self.dispatch_adopt(AdoptCommand::Dismiss); + } + AdoptMessage::Select(version_id) => { + self.adopt_form.selected = Some(version_id); + } + AdoptMessage::Name(name) => self.adopt_form.name = name, + AdoptMessage::Target(target) => self.adopt_form.target = target, + AdoptMessage::Check => { + if let Some(request) = self.adopt_request() { + self.adopt_form.busy = Busy::Checking; + self.dispatch_adopt(AdoptCommand::Preview(Box::new(request))); + } + } + AdoptMessage::Confirm => { + if let Some(request) = self.adopt_request() { + self.adopt_form.busy = Busy::Importing; + self.dispatch_adopt(AdoptCommand::Confirm(Box::new(request))); + } + } + AdoptMessage::Back => { + // 退回候选列表 = 重新扫一次:`scan` 会把上一轮的预检/失败一起作废, + // 于是界面回到 Pick 段,不需要再给 core 加一个「清失败」的命令。 + self.adopt_form.busy = Busy::Scanning; + self.dispatch_adopt(AdoptCommand::Scan); + } + } + Task::none() + } + + /// 回程到了:这一条命令已经交卷(`pump` 收到 `CoreEvent::Adopt` 时调,见 `update/clock.rs`)。 + pub(super) fn settle_adopt(&mut self) { + self.adopt_form.settled(); + } + + /// 当前表单能拼出的请求(没选版本就没有请求——按钮在那边也是禁用的)。 + fn adopt_request(&self) -> Option { + let version_id = self.adopt_form.selected.clone()?; + let name = self.adopt_form.name.trim(); + Some(AdoptRequestView { + version_id, + name: (!name.is_empty()).then(|| name.to_string()), + target: self.adopt_form.target, + }) + } + + fn dispatch_adopt(&mut self, command: AdoptCommand) { + self.dispatch(AppCommand::Adopt(Box::new(command))); + } +} + +#[cfg(test)] +mod tests { + use unlucky_design::color::SchemeFlavor; + use unlucky_presenter::dto::{AdoptEvent, AdoptTargetView}; + use unlucky_presenter::mock::MockBackend; + use unlucky_presenter::settings_bridge::SettingsBridge; + + use crate::app::adopt::{AdoptMessage, Busy}; + use crate::app::state::App; + use crate::cli::Options; + use crate::theme::Families; + + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + fn headless() -> App { + App::with_presenter( + Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(MockBackend::new()), + SettingsBridge::memory_only(), + ) + } + + /// 打开向导 = 表单进入「在等扫描」+ 派一条 `Scan`。 + /// + /// 抓得住:`Open` 没派命令(面板永远停在「正在扫描」)、或忘了置 `Busy::Scanning`(没有等待态)。 + /// 抓不住:扫描真的返回了什么(那在 presenter 的 `host/adopt` 用例里)。 + #[test] + fn opening_the_wizard_asks_for_a_scan() { + let mut app = headless(); + let _ = app.on_adopt(AdoptMessage::Open); + assert!(app.adopt_form.open); + assert_eq!(app.adopt_form.busy, Busy::Scanning); + assert_eq!( + app.adopt_form.target, + AdoptTargetView::InstanceDir, + "缺省不是 isolated" + ); + } + + /// 没选版本时「预检 / 确认」不派命令(否则会发出一条 `version_id` 为空的请求)。 + /// + /// 抓得住:`adopt_request()` 在没有选中项时仍拼出请求(空版本 id 打到 core 换一句 + /// `adopt_version_not_found`,用户看到莫名其妙的失败)。 + #[test] + fn no_request_without_a_selected_version() { + let mut app = headless(); + let _ = app.on_adopt(AdoptMessage::Open); + let _ = app.on_adopt(AdoptMessage::Check); + assert_eq!(app.adopt_form.busy, Busy::Scanning, "Check 不该改等待态"); + let _ = app.on_adopt(AdoptMessage::Confirm); + assert_eq!(app.adopt_form.busy, Busy::Scanning); + } + + /// 选中 + 预检 = 一条 Preview 命令,且请求里的名字是**去过空白**的用户输入。 + #[test] + fn check_sends_the_form_as_a_preview_request() { + let mut app = headless(); + let _ = app.on_adopt(AdoptMessage::Open); + let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string())); + let _ = app.on_adopt(AdoptMessage::Name(" 生存 ".to_string())); + let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::Shared)); + let _ = app.on_adopt(AdoptMessage::Check); + assert_eq!(app.adopt_form.busy, Busy::Checking); + // 请求本体不在 App 上留副本:能证明意图的只有等待态与目标档 + assert_eq!(app.adopt_form.target, AdoptTargetView::Shared); + } + + /// 回程到了就解除等待(`settle_adopt` 由 `pump` 调)。 + #[test] + fn an_incoming_event_settles_the_wait() { + let mut app = headless(); + let _ = app.on_adopt(AdoptMessage::Open); + assert!(app.adopt_form.busy.waiting()); + app.settle_adopt(); + assert_eq!(app.adopt_form.busy, Busy::Idle); + // 事件真相进了 presenter 的状态(界面从它取词) + app.state.apply(unlucky_presenter::dto::AppEvent::Core( + unlucky_presenter::dto::CoreEvent::Adopt(Box::new(AdoptEvent::Cleared)), + )); + assert!(app.state.adopt.scan.is_none()); + } +} diff --git a/crates/app-iced/src/app/update/clock.rs b/crates/app-iced/src/app/update/clock.rs index 184c82b..90165f1 100644 --- a/crates/app-iced/src/app/update/clock.rs +++ b/crates/app-iced/src/app/update/clock.rs @@ -140,7 +140,16 @@ impl App { ) { clean_scan = true; } + // M223:导入向导「在等回程」只认事件到达(派发时置位、这里解除), + // 于是连点「确认」不会写两条实例记录,界面也不会一直转圈。 + let settled_adopt = matches!( + &event, + unlucky_presenter::dto::AppEvent::Core(unlucky_presenter::dto::CoreEvent::Adopt(_)) + ); self.state.apply(event); + if settled_adopt { + self.settle_adopt(); + } } self.view_dirty = true; self.auto_select(); diff --git a/crates/app-iced/src/app/update/page_messages.rs b/crates/app-iced/src/app/update/page_messages.rs index 992de11..da352e9 100644 --- a/crates/app-iced/src/app/update/page_messages.rs +++ b/crates/app-iced/src/app/update/page_messages.rs @@ -109,6 +109,11 @@ impl App { } }; } + // M223:实例页页头「导入」——以前是占位动作(点了只打一行日志),现在是 + // 「导入已有版本」向导的入口(M221 的 core 能力 + M223 的 presenter/GUI 接线)。 + pages::Message::Action(pages::Action::Import) => { + return self.on_adopt(crate::app::adopt::AdoptMessage::Open); + } // 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记) pages::Message::Action(action) => { diag!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)"); diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index dfa0abe..44104e5 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -185,10 +185,19 @@ impl App { ), }; // 队列里有一条就叠一层模态(没有则原样返回)。模型由 `refresh_view` 维护。 - match self.dialog_model.as_ref() { + let tree = match self.dialog_model.as_ref() { Some(modal) => super::dialog::view::layer(&self.ui, tree, modal), None => tree, - } + }; + // M223:导入已有版本向导(实例页页头「导入」打开;没打开时原样返回页面树)。 + // 叠在模态**之上**:向导自己就是一层交互,模态只在崩溃/确认时出现,两者不会互相抢焦点。 + super::adopt::layer( + &self.ui, + tree, + &self.adopt_form, + &self.state.adopt, + self.lang(), + ) } } diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 3c439c1..7c8b3c8 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -59,6 +59,33 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("InstancesBackendVulkan", "Vulkan", "Vulkan 后端"), ("InstancesLaunchSave", "Save", "保存"), ("InstancesLaunchReset", "Reset to global", "恢复跟随全局"), + ("AdoptTitle", "Import an existing version", "导入已有版本"), + ("AdoptSubtitle", "Register versions another launcher already installed. Nothing is copied: versions, libraries and assets stay where they are.", "把别的启动器已经装好的版本登记成实例。不复制任何东西:versions / libraries / assets 原地不动。"), + ("AdoptScanning", "Scanning versions/…", "正在扫描 versions/…"), + ("AdoptEmpty", "No version found under {dir}. Install one here, or point the game folder at the .minecraft another launcher uses.", "在 {dir} 下一个版本都没找到。可以用本启动器装一个,或者把游戏目录指向别的启动器用的那个 .minecraft。"), + ("AdoptSelectLabel", "Pick a version", "选择版本"), + ("AdoptReady", "Ready", "可导入"), + ("AdoptBlocked", "{count} problem(s)", "{count} 个问题"), + ("AdoptRegistered", "Already used by {name}", "已被 {name} 使用"), + ("AdoptTargetLabel", "Where should the game run?", "游戏在哪个目录里跑"), + ("AdoptTargetInstanceDir", "New folder in ULL data (default)", "ULL 数据区新建目录(缺省)"), + ("AdoptTargetInstanceDirHint", "mods, saves and config stay out of the other launcher's .minecraft", "mods / saves / config 不会落进别人的 .minecraft"), + ("AdoptTargetShared", "Share the game folder", "共用游戏目录"), + ("AdoptTargetSharedHint", "mods, saves and config are written into the other launcher's .minecraft", "mods / saves / config 会写进别人的 .minecraft"), + ("AdoptTargetVersionDir", "Use the version folder", "用版本目录"), + ("AdoptTargetVersionDirHint", "player data grows inside versions/", "玩家数据会长在 versions/ 里"), + ("AdoptNameLabel", "Instance name (empty = version id)", "实例名(留空 = 版本 id)"), + ("AdoptPreview", "Check", "预检"), + ("AdoptConfirm", "Import", "导入"), + ("AdoptBack", "Back", "返回"), + ("AdoptClose", "Close", "关闭"), + ("AdoptPlanTitle", "Ready to import", "可以导入"), + ("AdoptPlanRunDir", "Game folder", "运行目录"), + ("AdoptPlanIsolation", "Isolation", "隔离方式"), + ("AdoptFailureTitle", "Import failed", "导入失败"), + ("AdoptProblemCount", "Every reason is listed below - nothing is collapsed into one sentence.", "下面逐条列出全部原因,不折叠成一句「导入失败」。"), + ("AdoptDoneTitle", "Imported", "已导入"), + ("AdoptDoneHint", "It is in the instance list now. Player data will live in the game folder below.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"), ]; /// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。 diff --git a/crates/i18n/src/catalog/instances.rs b/crates/i18n/src/catalog/instances.rs index 3fbd0f9..94ef6a5 100644 --- a/crates/i18n/src/catalog/instances.rs +++ b/crates/i18n/src/catalog/instances.rs @@ -102,6 +102,35 @@ macro_rules! instances_entries { InstancesBackendVulkan => ("Vulkan", "Vulkan 后端"), InstancesLaunchSave => ("Save", "保存"), InstancesLaunchReset => ("Reset to global", "恢复跟随全局"), + + // ── 导入已有版本(M223):向导的全部文案 + AdoptTitle => ("Import an existing version", "导入已有版本"), + AdoptSubtitle => ("Register versions another launcher already installed. Nothing is copied: versions, libraries and assets stay where they are.", "把别的启动器已经装好的版本登记成实例。不复制任何东西:versions / libraries / assets 原地不动。"), + AdoptScanning => ("Scanning versions/…", "正在扫描 versions/…"), + AdoptEmpty => ("No version found under {dir}. Install one here, or point the game folder at the .minecraft another launcher uses.", "在 {dir} 下一个版本都没找到。可以用本启动器装一个,或者把游戏目录指向别的启动器用的那个 .minecraft。"), + AdoptSelectLabel => ("Pick a version", "选择版本"), + AdoptReady => ("Ready", "可导入"), + AdoptBlocked => ("{count} problem(s)", "{count} 个问题"), + AdoptRegistered => ("Already used by {name}", "已被 {name} 使用"), + AdoptTargetLabel => ("Where should the game run?", "游戏在哪个目录里跑"), + AdoptTargetInstanceDir => ("New folder in ULL data (default)", "ULL 数据区新建目录(缺省)"), + AdoptTargetInstanceDirHint => ("mods, saves and config stay out of the other launcher's .minecraft", "mods / saves / config 不会落进别人的 .minecraft"), + AdoptTargetShared => ("Share the game folder", "共用游戏目录"), + AdoptTargetSharedHint => ("mods, saves and config are written into the other launcher's .minecraft", "mods / saves / config 会写进别人的 .minecraft"), + AdoptTargetVersionDir => ("Use the version folder", "用版本目录"), + AdoptTargetVersionDirHint => ("player data grows inside versions/", "玩家数据会长在 versions/ 里"), + AdoptNameLabel => ("Instance name (empty = version id)", "实例名(留空 = 版本 id)"), + AdoptPreview => ("Check", "预检"), + AdoptConfirm => ("Import", "导入"), + AdoptBack => ("Back", "返回"), + AdoptClose => ("Close", "关闭"), + AdoptPlanTitle => ("Ready to import", "可以导入"), + AdoptPlanRunDir => ("Game folder", "运行目录"), + AdoptPlanIsolation => ("Isolation", "隔离方式"), + AdoptFailureTitle => ("Import failed", "导入失败"), + AdoptProblemCount => ("Every reason is listed below - nothing is collapsed into one sentence.", "下面逐条列出全部原因,不折叠成一句「导入失败」。"), + AdoptDoneTitle => ("Imported", "已导入"), + AdoptDoneHint => ("It is in the instance list now. Player data will live in the game folder below.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"), } } }; } From 9ca5ee8ae70df29b9e158c3d288298062fe21773 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 14:00:16 +0800 Subject: [PATCH 5/9] =?UTF-8?q?docs(core):=20adopt=20=E7=9A=84=E4=B8=A4?= =?UTF-8?q?=E6=9D=A1=E6=96=87=E6=A1=A3=E5=B0=8F=E7=96=B5=EF=BC=88r4=20?= =?UTF-8?q?=E5=A4=8D=E5=AE=A1=E7=95=99=E7=9A=84=EF=BC=8CM223=20=E9=A1=BA?= =?UTF-8?q?=E6=89=8B=E4=BF=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plan.rs 第 11-12 行:一个悬空半句(「…必须落在」后面断掉)补完,并把 fspaths 的引用接上; - run_dir.rs 第 16 行:删掉「配置写面看 <.ULL>、并额外排除游戏根」这条**已移交** fspaths::ensure_surfaces_clear_of_game_tree 的职责描述,明说本模块只负责数据写面这一条。 纯文档,零行为变化。 --- crates/core/src/game/adopt/plan.rs | 4 ++-- crates/core/src/game/adopt/run_dir.rs | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/core/src/game/adopt/plan.rs b/crates/core/src/game/adopt/plan.rs index d0e85d2..cf3ffa2 100644 --- a/crates/core/src/game/adopt/plan.rs +++ b/crates/core/src/game/adopt/plan.rs @@ -8,8 +8,8 @@ //! 路径上的已有祖先若解析到**数据根之外**(符号链接/目录联接)⇒ 拒绝,不沿它写盘; //! 已被**别的实例**占着 ⇒ 顺延 `-2`/`-3` 后缀,绝不让两个实例共用同一个运行目录)。 //! -//! 写配置**之前**还要核一遍真实写面(`config/`、`config/instances/`、`profiles.json` 必须在 -//! 写盘面判据见 [`fspaths`](配置必须在 `<.ULL>` 内、默认写盘面不得与游戏树重叠)。 +//! 写配置**之前**还要核一遍真实写面(`config/`、`config/instances/`、`profiles.json` 必须落在 +//! `<.ULL>` 内、且默认写盘面不得与游戏树重叠),判据见 [`fspaths`]。 //! //! `versions/` `libraries/` `assets/` **一个字节都不写**:登记只填 `version_id`,内容靠 D20 的 //! 「恒共享层」被启动/下载链路复用([`crate::game::isolation::SHARED_LAYERS`])。 diff --git a/crates/core/src/game/adopt/run_dir.rs b/crates/core/src/game/adopt/run_dir.rs index 1e93fff..c12e559 100644 --- a/crates/core/src/game/adopt/run_dir.rs +++ b/crates/core/src/game/adopt/run_dir.rs @@ -13,8 +13,10 @@ //! # 判据的基准(为什么不拿 `/instances` 或 `<.ULL>/config` 当基准) //! //! 被保护的**边界自己是链接**时,拿它当基准等于把越界当正常。所以基准一律是**解析后的父级**: -//! 数据写面看 ``,配置写面看 `<.ULL>`、并额外排除游戏根。用户把整棵树迁到别的盘 -//! (`<.ULL>` 或 `` 本身是联接/符号链接)仍然允许——判据是落点,不是路径长得像不像。 +//! 数据写面看 ``(本模块只负责这一条);配置写面(`<.ULL>` 内、并额外排除游戏根)已移交 +//! [`super::fspaths`] 的 `ensure_config_write_surface` / `ensure_surfaces_clear_of_game_tree`, +//! 不在本模块的判据里。用户把整棵树迁到别的盘(`<.ULL>` 或 `` 本身是联接/符号链接) +//! 仍然允许——判据是落点,不是路径长得像不像。 //! //! # 已知边界 //! From a36e17a5d38de57550c6a269fc0a8fd138a24c96 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 14:07:28 +0800 Subject: [PATCH 6/9] =?UTF-8?q?fix(app-iced):=20=E7=82=B9=E9=80=89?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E6=A1=A3=E7=AB=8B=E5=88=BB=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=90=8E=E6=9E=9C=E5=8E=9F=E6=96=87=EF=BC=8C=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=E5=9C=A8=E9=A3=9E=E6=97=B6=E4=B8=8D=E5=90=83=E5=85=B3=E9=97=AD?= =?UTF-8?q?=EF=BC=88M223=20=E8=87=AA=E6=A3=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处自己复审抓到的问题: 1. 契约 §5.2 要求 Shared/VersionDir 被**点选**就展示 target_notes() 原文,而上一版要等 用户再点一次「预检」才出现。现在点选这两档会顺手发一次**零写盘**的预检,结果落在 Pick 段当提示读(不把用户甩到确认页:确认仍由显式的「预检」按钮触发,Form::checked)。 改档之后旧 plan 因目标档不一致而不再显示(Form::fresh_plan),避免画上一档的后果原文。 2. 「关闭」在登记在飞时被忽略:core 那条线程已经开跑,关面板会让 Done 事件被代次丢掉, 用户看到的就是「实例写进去了,界面什么都没说」。 用例:a_foreign_target_shows_its_notes_without_jumping_to_confirm / choosing_a_foreign_target_previews_but_waits_for_the_user / closing_is_ignored_while_the_registration_is_in_flight。 --- crates/app-iced/src/app/adopt.rs | 67 ++++++++++++++++++++----- crates/app-iced/src/app/adopt/pick.rs | 10 +++- crates/app-iced/src/app/adopt/tests.rs | 52 +++++++++++++++++-- crates/app-iced/src/app/update/adopt.rs | 53 ++++++++++++++++++- 4 files changed, 163 insertions(+), 19 deletions(-) diff --git a/crates/app-iced/src/app/adopt.rs b/crates/app-iced/src/app/adopt.rs index 94cba79..d64ced2 100644 --- a/crates/app-iced/src/app/adopt.rs +++ b/crates/app-iced/src/app/adopt.rs @@ -48,7 +48,9 @@ use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::Space as Sp; use unlucky_design::typography::TypeRole; use unlucky_i18n::{Catalog, Key, Lang}; -use unlucky_presenter::dto::{AdoptCandidateView, AdoptStage, AdoptState, AdoptTargetView}; +use unlucky_presenter::dto::{ + AdoptCandidateView, AdoptPlanView, AdoptStage, AdoptState, AdoptTargetView, +}; use crate::app::components::buttons::{self, Button}; use crate::app::components::feedback::dialog::scrim; @@ -102,6 +104,13 @@ pub struct Form { pub target: AdoptTargetView, /// 在等在飞的那一条命令 pub busy: Busy, + /// 用户已经点过「预检」(没点过就留在 Pick 段,哪怕后台已经顺手算过一次落点)。 + /// + /// 为什么要有这一位:共享档 / 版本目录档一旦被点选,界面就要立刻显示 `target_notes()` 的 + /// **后果原文**(契约 §5.2)——那是 core 唯一会产出这段文字的地方,所以点选时顺手发一次 + /// **零写盘**的预检。但那一次结果不该把用户甩到「可以导入」的确认页:确认必须由显式的 + /// `Check` 触发。于是 `checked` 记住「这次预检是不是用户要的」。 + pub checked: bool, } impl Form { @@ -124,6 +133,30 @@ impl Form { self.busy = Busy::Idle; } + /// 当前该画哪一段:`Preview` 只在用户**点过预检**之后才算数,其余按 `AdoptState::stage()`。 + /// + /// 这条收窄是为了「点选共享档 ⇒ 立刻看到后果原文」而不把用户直接甩到确认页: + /// 自动预检的结果落在 Pick 段里当**提示**读,用户点「预检」之后才进确认段。 + #[must_use] + pub fn stage(&self, adopt_state: &AdoptState) -> AdoptStage { + match adopt_state.stage() { + AdoptStage::Preview if !self.checked => AdoptStage::Pick, + other => other, + } + } + + /// 后台算出来、且**对得上当前选择**的预检(对不上 = 上一档的过期结果,一律不画)。 + /// + /// 判据是「目标档一致」:用户改了目标档之后,旧 plan 的 `notes` 说的是别的目录, + /// 拿它当后果原文就是撒谎。版本一致性由 [`Self::candidate`] 那一路保证(选中即重扫)。 + #[must_use] + pub fn fresh_plan<'a>(&self, adopt_state: &'a AdoptState) -> Option<&'a AdoptPlanView> { + adopt_state + .plan + .as_ref() + .filter(|plan| plan.target == self.target) + } + /// 当前选中的候选(选中项已不在列表里时返回 `None`,界面自动退回「请选择」)。 #[must_use] pub fn candidate<'a>(&self, adopt_state: &'a AdoptState) -> Option<&'a AdoptCandidateView> { @@ -206,24 +239,25 @@ fn card<'a>( adopt_state: &'a AdoptState, catalog: Catalog, ) -> Element<'a, Message> { + let stage = form.stage(adopt_state); let body = column![ frame::text_role( ui, TypeRole::HeadlineSmall, true, - title_text(adopt_state, catalog), + title_text(stage, catalog), ui.color(Role::OnSurface), ) .map(Message::Page), wrapped( ui, - subtitle(adopt_state, catalog), + subtitle(stage, adopt_state, catalog), ui.color(Role::OnSurfaceVariant) ), frame::divider(ui).map(Message::Page), - middle(ui, form, adopt_state, catalog), + middle(ui, form, adopt_state, stage, catalog), frame::divider(ui).map(Message::Page), - footer(ui, form, adopt_state, catalog), + footer(ui, form, adopt_state, stage, catalog), ] .spacing(ui.space(Sp::X3)) .width(Length::Fill); @@ -241,8 +275,8 @@ fn card<'a>( } /// 段标题(四段各一句)。 -fn title_text(adopt_state: &AdoptState, catalog: Catalog) -> &'static str { - match adopt_state.stage() { +fn title_text(stage: AdoptStage, catalog: Catalog) -> &'static str { + match stage { AdoptStage::Pick => catalog.text(Key::AdoptTitle), AdoptStage::Preview => catalog.text(Key::AdoptPlanTitle), AdoptStage::Failed => catalog.text(Key::AdoptFailureTitle), @@ -251,8 +285,8 @@ fn title_text(adopt_state: &AdoptState, catalog: Catalog) -> &'static str { } /// 标题下那句说明(Pick 段说清「什么都不复制」;Done 段说清「去实例列表启动」)。 -fn subtitle(adopt_state: &AdoptState, catalog: Catalog) -> String { - match adopt_state.stage() { +fn subtitle(stage: AdoptStage, adopt_state: &AdoptState, catalog: Catalog) -> String { + match stage { AdoptStage::Pick => catalog.text(Key::AdoptSubtitle).to_string(), AdoptStage::Preview => adopt_state .plan @@ -279,11 +313,18 @@ fn middle<'a>( ui: &'a Ui, form: &'a Form, adopt_state: &'a AdoptState, + stage: AdoptStage, catalog: Catalog, ) -> Element<'a, Message> { - match adopt_state.stage() { - AdoptStage::Pick => pick::pick(ui, form, adopt_state.scan.as_ref(), catalog), - AdoptStage::Preview => panes::preview(ui, adopt_state.plan.as_ref(), catalog), + match stage { + AdoptStage::Pick => pick::pick( + ui, + form, + adopt_state.scan.as_ref(), + form.fresh_plan(adopt_state), + catalog, + ), + AdoptStage::Preview => panes::preview(ui, form.fresh_plan(adopt_state), catalog), AdoptStage::Failed => panes::failed(ui, adopt_state.failure.as_ref(), catalog), AdoptStage::Done => panes::done(ui, adopt_state.done.as_ref(), catalog), } @@ -294,9 +335,9 @@ fn footer<'a>( ui: &'a Ui, form: &'a Form, adopt_state: &'a AdoptState, + stage: AdoptStage, catalog: Catalog, ) -> Element<'a, Message> { - let stage = adopt_state.stage(); let busy = form.busy.waiting(); let primary: Element<'a, Message> = match stage { diff --git a/crates/app-iced/src/app/adopt/pick.rs b/crates/app-iced/src/app/adopt/pick.rs index 3a5cace..2ac06d1 100644 --- a/crates/app-iced/src/app/adopt/pick.rs +++ b/crates/app-iced/src/app/adopt/pick.rs @@ -14,7 +14,7 @@ use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::Space as Sp; use unlucky_design::typography::TypeRole; use unlucky_i18n::{Catalog, Key}; -use unlucky_presenter::dto::{AdoptCandidateView, AdoptScanView, AdoptTargetView}; +use unlucky_presenter::dto::{AdoptCandidateView, AdoptPlanView, AdoptScanView, AdoptTargetView}; use crate::app::components::inputs::{text_field, Field}; use crate::app::components::selection::{chip, Chip}; @@ -30,6 +30,7 @@ pub(super) fn pick<'a>( ui: &'a Ui, form: &'a Form, scan: Option<&'a AdoptScanView>, + plan: Option<&'a AdoptPlanView>, catalog: Catalog, ) -> Element<'a, Message> { let Some(scan) = scan else { @@ -57,6 +58,13 @@ pub(super) fn pick<'a>( body = body.push(frame::divider(ui).map(Message::Page)); body = body.push(label(ui, catalog.text(Key::AdoptTargetLabel))); body = body.push(target_row(ui, form, catalog)); + // 后果原文(契约 §5.2):共享档 / 版本目录档一被点选就显示 core `target_notes()` 的原文—— + // 这一条来自**零写盘**的自动预检(见 `AdoptMessage::Target` 的落点),不是界面另写的一句。 + if let Some(plan) = plan { + for note in &plan.notes { + body = body.push(wrapped(ui, note.clone(), ui.color(Role::OnSurfaceVariant))); + } + } body = body.push(text_field( ui, Field::new(form.name.as_str()).label(catalog.text(Key::AdoptNameLabel)), diff --git a/crates/app-iced/src/app/adopt/tests.rs b/crates/app-iced/src/app/adopt/tests.rs index 87d2a6b..082ff7f 100644 --- a/crates/app-iced/src/app/adopt/tests.rs +++ b/crates/app-iced/src/app/adopt/tests.rs @@ -90,11 +90,11 @@ fn the_four_stages_project_from_the_event_state() { // Pick assert_eq!(state.stage(), AdoptStage::Pick); assert_eq!( - title_text(&state, catalog), + title_text(state.stage(), catalog), catalog.text(Key::AdoptTitle), "标题走目录" ); - assert!(subtitle(&state, catalog).contains("不复制")); + assert!(subtitle(state.stage(), &state, catalog).contains("不复制")); // Preview:`notes` 是 core `target_notes()` 的原文,界面一个字都不许改 let note = @@ -136,7 +136,7 @@ fn the_four_stages_project_from_the_event_state() { "两条问题一条都不许吞" ); assert_eq!( - title_text(&state, catalog), + title_text(state.stage(), catalog), catalog.text(Key::AdoptFailureTitle) ); @@ -152,7 +152,7 @@ fn the_four_stages_project_from_the_event_state() { }))); assert_eq!(state.stage(), AdoptStage::Done); assert_eq!( - title_text(&state, catalog), + title_text(state.stage(), catalog), catalog.text(Key::AdoptDoneTitle) ); } @@ -199,3 +199,47 @@ fn both_catalogs_have_the_wizard_wording() { let hint = Lang::En.catalog().text(Key::AdoptTargetSharedHint); assert!(hint.contains(".minecraft"), "{hint}"); } + +/// 「点选共享档 ⇒ 立刻看到后果原文」但**不**把用户甩到确认页: +/// 预检结果到了也只是 Pick 段里的提示,直到用户点过「预检」(`checked`) 才进 Preview。 +/// +/// 抓得住:把 `Form::stage` 写回直接取 `AdoptState::stage()`(点一次共享档就跳到确认页, +/// 实例名还没来得及改);或忘了 `fresh_plan` 的目标一致性判据(改档之后画上一档的后果原文)。 +#[test] +fn a_foreign_target_shows_its_notes_without_jumping_to_confirm() { + let mut state = state_with_scan(); + let note = + "运行目录 = /game(共享档):mods/saves/config 会落在别人的 .minecraft 里".to_string(); + state.apply(AdoptEvent::Planned(Box::new(AdoptPlanView { + version_id: "1.20.1".to_string(), + name: "1.20.1".to_string(), + target: AdoptTargetView::Shared, + isolation: AdoptIsolationView::Shared, + run_dir: std::path::PathBuf::from("/game"), + notes: vec![note.clone()], + }))); + + let mut form = Form { + open: true, + selected: Some("1.20.1".to_string()), + target: AdoptTargetView::Shared, + ..Form::default() + }; + + // 自动预检回来但用户没点「预检」:仍是 Pick 段,notes 当作提示可读 + assert_eq!(form.stage(&state), AdoptStage::Pick); + assert_eq!( + form.fresh_plan(&state) + .map(|plan| plan.notes.first().cloned()), + Some(Some(note.clone())), + "后果原文必须能取到" + ); + + // 点过「预检」之后才进确认段 + form.checked = true; + assert_eq!(form.stage(&state), AdoptStage::Preview); + + // 换了目标档:上一档的预检作废(不再当后果原文画) + form.target = AdoptTargetView::VersionDir; + assert!(form.fresh_plan(&state).is_none(), "过期的预检不许画"); +} diff --git a/crates/app-iced/src/app/update/adopt.rs b/crates/app-iced/src/app/update/adopt.rs index c5aee94..597d1d0 100644 --- a/crates/app-iced/src/app/update/adopt.rs +++ b/crates/app-iced/src/app/update/adopt.rs @@ -35,6 +35,11 @@ impl App { self.dispatch_adopt(AdoptCommand::Scan); } AdoptMessage::Close => { + // 登记在飞时不吃「关闭」:core 那条线程已经开跑了,写了盘却不回报 Done + // 就会出现「实例已存在但界面什么都没说」——等它交卷(几十毫秒级)。 + if self.adopt_form.busy == Busy::Importing { + return Task::none(); + } self.adopt_form.close(); self.dispatch_adopt(AdoptCommand::Dismiss); } @@ -42,9 +47,22 @@ impl App { self.adopt_form.selected = Some(version_id); } AdoptMessage::Name(name) => self.adopt_form.name = name, - AdoptMessage::Target(target) => self.adopt_form.target = target, + AdoptMessage::Target(target) => { + self.adopt_form.target = target; + // 换了目标档 ⇒ 上一档的预检作废(确认按钮不该继续亮着) + self.adopt_form.checked = false; + // 共享档 / 版本目录档会写进**别人的目录**:一被点选就把后果原文拿出来 + // (零写盘的预检,见 `Form::stage` 的收窄:这一次结果落在 Pick 段当提示读)。 + if target.touches_foreign_dir() { + if let Some(request) = self.adopt_request() { + self.adopt_form.busy = Busy::Checking; + self.dispatch_adopt(AdoptCommand::Preview(Box::new(request))); + } + } + } AdoptMessage::Check => { if let Some(request) = self.adopt_request() { + self.adopt_form.checked = true; self.adopt_form.busy = Busy::Checking; self.dispatch_adopt(AdoptCommand::Preview(Box::new(request))); } @@ -58,6 +76,7 @@ impl App { AdoptMessage::Back => { // 退回候选列表 = 重新扫一次:`scan` 会把上一轮的预检/失败一起作废, // 于是界面回到 Pick 段,不需要再给 core 加一个「清失败」的命令。 + self.adopt_form.checked = false; self.adopt_form.busy = Busy::Scanning; self.dispatch_adopt(AdoptCommand::Scan); } @@ -173,4 +192,36 @@ mod tests { )); assert!(app.state.adopt.scan.is_none()); } + + /// 共享档一被点选就发一条**预检**(零写盘),但**不**置 `checked`——确认页要用户点「预检」。 + /// + /// 抓得住:`Target` 分支忘了发预检(后果原文永远不出现,契约 §5.2 失守)、 + /// 或者顺手把 `checked` 也置上(点一下档位就跳到确认页)。 + #[test] + fn choosing_a_foreign_target_previews_but_waits_for_the_user() { + let mut app = headless(); + let _ = app.on_adopt(AdoptMessage::Open); + let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string())); + let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::Shared)); + assert_eq!(app.adopt_form.busy, Busy::Checking); + assert!(!app.adopt_form.checked, "确认页要等用户点「预检」"); + + // 缺省档不触发自动预检(它不会写进别人的目录) + let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::InstanceDir)); + assert!(!app.adopt_form.checked); + } + + /// 登记在飞时「关闭」被忽略:core 已经在写了,关掉面板只会让 Done 无处可去。 + /// + /// 抓得住:Close 无条件清表单并 `Dismiss`(实例写成功了,界面却什么都没说)。 + #[test] + fn closing_is_ignored_while_the_registration_is_in_flight() { + let mut app = headless(); + let _ = app.on_adopt(AdoptMessage::Open); + let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string())); + let _ = app.on_adopt(AdoptMessage::Check); + app.adopt_form.busy = Busy::Importing; + let _ = app.on_adopt(AdoptMessage::Close); + assert!(app.adopt_form.open, "登记在飞时不许把面板关掉"); + } } From 6ffbd51f60f2837e8699a6649906f8a32aa0cdd6 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 14:25:10 +0800 Subject: [PATCH 7/9] =?UTF-8?q?feat(app-iced):=20=E5=90=91=E5=AF=BC?= =?UTF-8?q?=E7=9A=84=E5=87=BA=E5=9B=BE=E7=A7=8D=E5=AD=90=20+=20=E5=8D=A1?= =?UTF-8?q?=E5=A3=B3=E5=87=A0=E4=BD=95=E4=BF=AE=E6=AD=A3=EF=BC=88M223=20?= =?UTF-8?q?=E8=A7=86=E8=A7=89=E8=87=AA=E6=A3=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D30 的截图通道在本机可用(`--software-renderer` 走 tiny-skia,1180×760 落盘),所以不再把视觉 证据留空:新增 `ULL_ADOPT_STATE`(与 `ULL_JAVA_STATE` / `ULL_AI_STATE` 同一族,**只在 `--screenshot` 下生效**),可把向导摆到七种状态之一(pick / empty / preview / shared / failed / done + 深色),于是出图不靠「赌异步时序」。 顺带修掉三处只有出图才看得见的问题: 1. **卡壳把页脚裁掉了**:内容高于 `max_height` 时 iced 直接裁,第一版 PICK 段的「实例名」与 按钮整排不可见。现在列表段(Pick/Failed)定高 + 中间区可滚、页脚固定;短内容段 (Preview/Done)按内容收高,不留一大片空白; 2. **两枚一模一样的「关闭」**:Failed/Done 的主按钮就是关闭,文字关闭不该再出现; 3. Preview 段少了实例名一行,且标题下的摘要重复了「共用目录 · 共用游戏目录」。 夹具只造候选/问题/运行目录这些**版面数据**,后果原文仍由 core 的 `target_notes()` 现算, 图证明版面与文案、不证明 core 判据(见 `DevDocs/artifacts/m223/README.md` 的真/假表)。 为守 400 行(`components::gate` 实测 412 行红),卡壳/标题/页脚纯搬移到 `app/adopt/shell.rs`。 --- crates/app-iced/src/app/adopt.rs | 166 ++--------------- crates/app-iced/src/app/adopt/panes.rs | 12 +- crates/app-iced/src/app/adopt/pick.rs | 9 +- crates/app-iced/src/app/adopt/shell.rs | 187 +++++++++++++++++++ crates/app-iced/src/app/adopt/shot.rs | 199 +++++++++++++++++++++ crates/app-iced/src/app/adopt/tests.rs | 8 +- crates/app-iced/src/app/state/construct.rs | 2 + crates/app-iced/src/app/update/adopt.rs | 17 ++ crates/i18n/src/catalog/golden.rs | 3 +- crates/i18n/src/catalog/instances.rs | 3 +- 10 files changed, 435 insertions(+), 171 deletions(-) create mode 100644 crates/app-iced/src/app/adopt/shell.rs create mode 100644 crates/app-iced/src/app/adopt/shot.rs diff --git a/crates/app-iced/src/app/adopt.rs b/crates/app-iced/src/app/adopt.rs index d64ced2..bb73656 100644 --- a/crates/app-iced/src/app/adopt.rs +++ b/crates/app-iced/src/app/adopt.rs @@ -9,7 +9,13 @@ //! | Failed | 稳定码 + 人读原因 + **`problems[]` 逐条** | `AdoptEvent::Failed` | [`panes`] | //! | Done | 实例名 / 运行目录 / 提示原文 | `AdoptEvent::Done` | [`panes`] | //! -//! 「现在画哪一段」只有一处判据(presenter 的 `AdoptState::stage`),本文件只做投影。 +//! 「现在画哪一段」的判据只有一处:presenter 的 `AdoptState::stage`,外面套一条收窄 +//! [`Form::stage`]——`Preview` 只在用户**点过预检**(`Form::checked`)之后才算数。收窄的理由: +//! 共享档 / 版本目录档一被点选就要显示后果原文(§5.2),那要用一次**零写盘**的预检换; +//! 但那次结果不该把用户直接甩到确认页(实例名还没填完)。 +//! +//! 卡壳与页脚在 [`shell`](定高可滚 / 收高两种几何),三段视图在 [`pick`] 与 [`panes`], +//! 等宽与正文小工具在 `text`,D30 出图种子在 `shot`(`ULL_ADOPT_STATE`,只在 `--screenshot` 下生效)。 //! //! ## 三条纪律(契约 §5 的 1/2/3) //! @@ -34,40 +40,33 @@ mod panes; mod pick; +mod shell; +/// D30 出图用的状态夹具(只在 `--screenshot` 下按环境变量生效)。 +pub(crate) mod shot; mod text; #[cfg(test)] mod tests; -use iced::widget::{column, container, mouse_area, opaque, row, Space}; +use iced::widget::{container, mouse_area, opaque, Space}; use iced::{Alignment, Background, Element, Length}; -use unlucky_design::color::Role; -use unlucky_design::elevation::MaterialLayer; -use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::Space as Sp; -use unlucky_design::typography::TypeRole; -use unlucky_i18n::{Catalog, Key, Lang}; +use unlucky_i18n::{Catalog, Lang}; use unlucky_presenter::dto::{ AdoptCandidateView, AdoptPlanView, AdoptStage, AdoptState, AdoptTargetView, }; -use crate::app::components::buttons::{self, Button}; use crate::app::components::feedback::dialog::scrim; -use crate::pages::frame; use crate::theme::Ui; -use self::text::{isolation_label, target_label, wrapped}; use super::message::Message; /// 向导卡宽度:比模态卡(560)宽一档——候选行要放「版本 id + 状态」,目标档是三枚 chip。 const CARD_WIDTH: f32 = 640.0; -/// 候选列表 / 问题清单的滚动高(再多就滚,页脚按钮永远留在卡里)。 -const CARD_LIST_HEIGHT: f32 = 260.0; - /// 卡片的最大高(窗口更矮时整卡按窗口走:内容在中间区滚)。 -const CARD_MAX_HEIGHT: f32 = 560.0; +const CARD_MAX_HEIGHT: f32 = 620.0; /// 正在等哪一条命令的回程。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -222,7 +221,7 @@ pub(super) fn layer<'a>( .on_press(adopt(AdoptMessage::Close)); // 透明的一层只负责居中;吃点击的 `opaque` 只包卡片(点卡内不关,点遮罩才关)。 - let centered = container(opaque(card(ui, form, adopt_state, catalog))) + let centered = container(opaque(shell::card(ui, form, adopt_state, catalog))) .width(Length::Fill) .height(Length::Fill) .align_x(Alignment::Center) @@ -232,84 +231,8 @@ pub(super) fn layer<'a>( iced::widget::stack![base, backdrop, centered].into() } -/// 卡片本体:标题 + 一句说明 + 中间(按段) + 页脚按钮。 -fn card<'a>( - ui: &'a Ui, - form: &'a Form, - adopt_state: &'a AdoptState, - catalog: Catalog, -) -> Element<'a, Message> { - let stage = form.stage(adopt_state); - let body = column![ - frame::text_role( - ui, - TypeRole::HeadlineSmall, - true, - title_text(stage, catalog), - ui.color(Role::OnSurface), - ) - .map(Message::Page), - wrapped( - ui, - subtitle(stage, adopt_state, catalog), - ui.color(Role::OnSurfaceVariant) - ), - frame::divider(ui).map(Message::Page), - middle(ui, form, adopt_state, stage, catalog), - frame::divider(ui).map(Message::Page), - footer(ui, form, adopt_state, stage, catalog), - ] - .spacing(ui.space(Sp::X3)) - .width(Length::Fill); - - container(body) - .width(Length::Fixed(CARD_WIDTH)) - .max_height(CARD_MAX_HEIGHT) - .padding(ui.space(Sp::X6)) - .style(move |_theme| container::Style { - background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))), - border: ui.outlined(ShapeScale::ExtraLarge), - ..container::Style::default() - }) - .into() -} - -/// 段标题(四段各一句)。 -fn title_text(stage: AdoptStage, catalog: Catalog) -> &'static str { - match stage { - AdoptStage::Pick => catalog.text(Key::AdoptTitle), - AdoptStage::Preview => catalog.text(Key::AdoptPlanTitle), - AdoptStage::Failed => catalog.text(Key::AdoptFailureTitle), - AdoptStage::Done => catalog.text(Key::AdoptDoneTitle), - } -} - -/// 标题下那句说明(Pick 段说清「什么都不复制」;Done 段说清「去实例列表启动」)。 -fn subtitle(stage: AdoptStage, adopt_state: &AdoptState, catalog: Catalog) -> String { - match stage { - AdoptStage::Pick => catalog.text(Key::AdoptSubtitle).to_string(), - AdoptStage::Preview => adopt_state - .plan - .as_ref() - .map(|plan| { - format!( - "{} · {}", - isolation_label(plan.isolation, catalog), - target_label(plan.target, catalog) - ) - }) - .unwrap_or_default(), - AdoptStage::Failed => adopt_state - .failure - .as_ref() - .map(|failure| failure.message.clone()) - .unwrap_or_default(), - AdoptStage::Done => catalog.text(Key::AdoptDoneHint).to_string(), - } -} - /// 中间区(按段交给 `pick` / `panes`)。 -fn middle<'a>( +pub(super) fn middle<'a>( ui: &'a Ui, form: &'a Form, adopt_state: &'a AdoptState, @@ -329,62 +252,3 @@ fn middle<'a>( AdoptStage::Done => panes::done(ui, adopt_state.done.as_ref(), catalog), } } - -/// 页脚按钮(按段给不同组合;在等回程时主按钮禁用)。 -fn footer<'a>( - ui: &'a Ui, - form: &'a Form, - adopt_state: &'a AdoptState, - stage: AdoptStage, - catalog: Catalog, -) -> Element<'a, Message> { - let busy = form.busy.waiting(); - - let primary: Element<'a, Message> = match stage { - AdoptStage::Pick => { - let ready = form.candidate(adopt_state).is_some_and(|c| c.adoptable()) && !busy; - if ready { - buttons::filled( - ui, - Button::new(catalog.text(Key::AdoptPreview), adopt(AdoptMessage::Check)), - ) - } else { - buttons::filled(ui, Button::disabled(catalog.text(Key::AdoptPreview))) - } - } - AdoptStage::Preview if !busy => buttons::filled( - ui, - Button::new( - catalog.text(Key::AdoptConfirm), - adopt(AdoptMessage::Confirm), - ), - ), - AdoptStage::Preview => { - buttons::filled(ui, Button::disabled(catalog.text(Key::AdoptConfirm))) - } - // 失败 / 完成:主按钮就是关掉(Done 不用「去列表看」——实例已经在列表里了) - AdoptStage::Failed | AdoptStage::Done => buttons::filled( - ui, - Button::new(catalog.text(Key::AdoptClose), adopt(AdoptMessage::Close)), - ), - }; - - let mut actions = row![].spacing(ui.space(Sp::X2)).width(Length::Fill); - // Preview / Failed 给一条「退回候选列表」(= 重新扫一次,代价只有一次读目录) - if matches!(stage, AdoptStage::Preview | AdoptStage::Failed) { - actions = actions.push(buttons::text( - ui, - Button::new(catalog.text(Key::AdoptBack), adopt(AdoptMessage::Back)), - )); - } - actions = actions.push(container(Space::new()).width(Length::Fill)); - if !matches!(stage, AdoptStage::Done) { - actions = actions.push(buttons::text( - ui, - Button::new(catalog.text(Key::AdoptClose), adopt(AdoptMessage::Close)), - )); - } - actions = actions.push(primary); - - column![actions].width(Length::Fill).into() -} diff --git a/crates/app-iced/src/app/adopt/panes.rs b/crates/app-iced/src/app/adopt/panes.rs index 2f31048..e3675f4 100644 --- a/crates/app-iced/src/app/adopt/panes.rs +++ b/crates/app-iced/src/app/adopt/panes.rs @@ -10,7 +10,7 @@ //! 缺省档会带 `-2`/`-3` 后缀(两个不同展示名可能落到同一个物理目录),**只有 core 知道** //! 最后用了哪个——页面对它再做一次派生就会与真正启动时用的目录分叉。 -use iced::widget::{column, scrollable}; +use iced::widget::column; use iced::{Element, Length}; use unlucky_design::color::Role; @@ -18,12 +18,10 @@ use unlucky_design::spacing::Space as Sp; use unlucky_i18n::{Catalog, Key}; use unlucky_presenter::dto::{AdoptDoneView, AdoptFailureView, AdoptPlanView}; -use crate::pages::frame; use crate::theme::Ui; use super::super::message::Message; use super::text::{field_line, isolation_label, mono, problem_line, wrapped}; -use super::CARD_LIST_HEIGHT; /// 预检结果:落点 + 隔离档 + 后果原文。 pub(super) fn preview<'a>( @@ -39,6 +37,7 @@ pub(super) fn preview<'a>( ); }; let mut body = column![ + field_line(ui, catalog.text(Key::AdoptInstanceName), &plan.name), field_line( ui, catalog.text(Key::AdoptPlanRunDir), @@ -93,10 +92,7 @@ pub(super) fn failed<'a>( for problem in &failure.problems { body = body.push(problem_line(ui, problem)); } - scrollable(body) - .height(Length::Fixed(CARD_LIST_HEIGHT)) - .style(frame::scroll_style(ui)) - .into() + body.into() } /// 完成:实例名 + 运行目录 + 提示原文。 @@ -109,7 +105,7 @@ pub(super) fn done<'a>( return wrapped(ui, String::new(), ui.color(Role::OnSurfaceVariant)); }; let mut body = column![ - field_line(ui, catalog.text(Key::AdoptTitle), &done.name), + field_line(ui, catalog.text(Key::AdoptInstanceName), &done.name), field_line( ui, catalog.text(Key::AdoptPlanRunDir), diff --git a/crates/app-iced/src/app/adopt/pick.rs b/crates/app-iced/src/app/adopt/pick.rs index 2ac06d1..a3467fa 100644 --- a/crates/app-iced/src/app/adopt/pick.rs +++ b/crates/app-iced/src/app/adopt/pick.rs @@ -6,7 +6,7 @@ //! 目标档三枚 chip 的**短提示**在选中时立刻换(共享/版本目录档用 `Error` 色); //! **后果原文**不在这里——它在 Preview 段逐条画 `plan.notes`(core `target_notes()` 的原文)。 -use iced::widget::{button, column, container, row, scrollable}; +use iced::widget::{button, column, container, row}; use iced::{Alignment, Background, Border, Color, Element, Length}; use unlucky_design::color::Role; @@ -23,7 +23,7 @@ use crate::theme::Ui; use super::super::message::Message; use super::text::{label, problem_line, target_label, wrapped}; -use super::{adopt, AdoptMessage, Form, CARD_LIST_HEIGHT}; +use super::{adopt, AdoptMessage, Form}; /// Pick 段整体。 pub(super) fn pick<'a>( @@ -89,10 +89,7 @@ fn candidate_list<'a>( .spacing(ui.space(Sp::X1)) .width(Length::Fill); - scrollable(rows) - .height(Length::Fixed(CARD_LIST_HEIGHT)) - .style(frame::scroll_style(ui)) - .into() + rows.into() } /// 一条候选。 diff --git a/crates/app-iced/src/app/adopt/shell.rs b/crates/app-iced/src/app/adopt/shell.rs new file mode 100644 index 0000000..1f504a5 --- /dev/null +++ b/crates/app-iced/src/app/adopt/shell.rs @@ -0,0 +1,187 @@ +//! 向导卡的外壳与页脚(M223 从 `app/adopt.rs` 搬出,守 400 行硬线)。 +//! +//! | 函数 | 职责 | +//! |---|---| +//! | [`card`] | 卡壳(标题 + 说明 + 中间区 + 页脚)、定高/收高两种几何、遮罩上的那一张卡 | +//! | [`title_text`] / [`subtitle`] | 四段的标题与一句说明(文案全部来自 i18n 目录) | +//! | [`footer`] | 按段给按钮组合;在等回程时主按钮禁用 | +//! +//! 纯搬移:函数体一行未改,只是换了文件(`app/adopt.rs` 落笔 412 行越过 `components::gate` +//! 的 400 行硬线)。调用方仍是 `app/adopt.rs` 的 `layer` 与 `middle`。 + +use iced::widget::{column, container, row, scrollable, Space}; +use iced::{Background, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space as Sp; +use unlucky_design::typography::TypeRole; +use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::{AdoptStage, AdoptState}; + +use crate::app::components::buttons::{self, Button}; +use crate::pages::frame; +use crate::theme::Ui; + +use super::super::message::Message; +use super::text::{target_label, wrapped}; +use super::{adopt, middle, AdoptMessage, Form, CARD_MAX_HEIGHT, CARD_WIDTH}; + +/// 卡片本体:标题 + 一句说明 + 中间(按段) + 页脚按钮。 +pub(super) fn card<'a>( + ui: &'a Ui, + form: &'a Form, + adopt_state: &'a AdoptState, + catalog: Catalog, +) -> Element<'a, Message> { + let stage = form.stage(adopt_state); + let middle = middle(ui, form, adopt_state, stage, catalog); + let head = column![ + frame::text_role( + ui, + TypeRole::HeadlineSmall, + true, + title_text(stage, catalog), + ui.color(Role::OnSurface), + ) + .map(Message::Page), + wrapped( + ui, + subtitle(stage, adopt_state, catalog), + ui.color(Role::OnSurfaceVariant) + ), + frame::divider(ui).map(Message::Page), + ] + .spacing(ui.space(Sp::X3)) + .width(Length::Fill); + + let tail = column![ + frame::divider(ui).map(Message::Page), + footer(ui, form, adopt_state, stage, catalog), + ] + .spacing(ui.space(Sp::X3)) + .width(Length::Fill); + + // 列表段(Pick / Failed)**定高 + 中间区可滚**:内容再长,页脚按钮也不会被裁掉 + // (第一版就是被裁掉的)。短内容段(Preview / Done)按内容高收,不留一大片空白。 + let list_stage = matches!(stage, AdoptStage::Pick | AdoptStage::Failed); + let card = if list_stage { + let body = column![ + head, + scrollable(middle) + .height(Length::Fill) + .style(frame::scroll_style(ui)), + tail, + ] + .spacing(ui.space(Sp::X3)) + .width(Length::Fill); + container(body) + .width(Length::Fixed(CARD_WIDTH)) + .height(Length::Fixed(CARD_MAX_HEIGHT)) + } else { + let body = column![head, middle, tail] + .spacing(ui.space(Sp::X3)) + .width(Length::Fill); + container(body) + .width(Length::Fixed(CARD_WIDTH)) + .max_height(CARD_MAX_HEIGHT) + }; + + card.padding(ui.space(Sp::X6)) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))), + border: ui.outlined(ShapeScale::ExtraLarge), + ..container::Style::default() + }) + .into() +} + +/// 段标题(四段各一句)。 +pub(super) fn title_text(stage: AdoptStage, catalog: Catalog) -> &'static str { + match stage { + AdoptStage::Pick => catalog.text(Key::AdoptTitle), + AdoptStage::Preview => catalog.text(Key::AdoptPlanTitle), + AdoptStage::Failed => catalog.text(Key::AdoptFailureTitle), + AdoptStage::Done => catalog.text(Key::AdoptDoneTitle), + } +} + +/// 标题下那句说明(Pick 段说清「什么都不复制」;Done 段说清「去实例列表启动」)。 +pub(super) fn subtitle(stage: AdoptStage, adopt_state: &AdoptState, catalog: Catalog) -> String { + match stage { + AdoptStage::Pick => catalog.text(Key::AdoptSubtitle).to_string(), + // 预检:版本名 · 目标档(隔离档在正文里单独一行,重复一遍只会更吵) + AdoptStage::Preview => adopt_state + .plan + .as_ref() + .map(|plan| format!("{} · {}", plan.name, target_label(plan.target, catalog))) + .unwrap_or_default(), + AdoptStage::Failed => adopt_state + .failure + .as_ref() + .map(|failure| failure.message.clone()) + .unwrap_or_default(), + AdoptStage::Done => catalog.text(Key::AdoptDoneHint).to_string(), + } +} + +/// 页脚按钮(按段给不同组合;在等回程时主按钮禁用)。 +pub(super) fn footer<'a>( + ui: &'a Ui, + form: &'a Form, + adopt_state: &'a AdoptState, + stage: AdoptStage, + catalog: Catalog, +) -> Element<'a, Message> { + let busy = form.busy.waiting(); + + let primary: Element<'a, Message> = match stage { + AdoptStage::Pick => { + let ready = form.candidate(adopt_state).is_some_and(|c| c.adoptable()) && !busy; + if ready { + buttons::filled( + ui, + Button::new(catalog.text(Key::AdoptPreview), adopt(AdoptMessage::Check)), + ) + } else { + buttons::filled(ui, Button::disabled(catalog.text(Key::AdoptPreview))) + } + } + AdoptStage::Preview if !busy => buttons::filled( + ui, + Button::new( + catalog.text(Key::AdoptConfirm), + adopt(AdoptMessage::Confirm), + ), + ), + AdoptStage::Preview => { + buttons::filled(ui, Button::disabled(catalog.text(Key::AdoptConfirm))) + } + // 失败 / 完成:主按钮就是关掉(Done 不用「去列表看」——实例已经在列表里了) + AdoptStage::Failed | AdoptStage::Done => buttons::filled( + ui, + Button::new(catalog.text(Key::AdoptClose), adopt(AdoptMessage::Close)), + ), + }; + + let mut actions = row![].spacing(ui.space(Sp::X2)).width(Length::Fill); + // Preview / Failed 给一条「退回候选列表」(= 重新扫一次,代价只有一次读目录) + if matches!(stage, AdoptStage::Preview | AdoptStage::Failed) { + actions = actions.push(buttons::text( + ui, + Button::new(catalog.text(Key::AdoptBack), adopt(AdoptMessage::Back)), + )); + } + actions = actions.push(container(Space::new()).width(Length::Fill)); + // 失败/完成段的主按钮就是「关闭」,再给一枚文字「关闭」就是两枚一模一样的按钮 + if matches!(stage, AdoptStage::Pick | AdoptStage::Preview) { + actions = actions.push(buttons::text( + ui, + Button::new(catalog.text(Key::AdoptClose), adopt(AdoptMessage::Close)), + )); + } + actions = actions.push(primary); + + column![actions].width(Length::Fill).into() +} diff --git a/crates/app-iced/src/app/adopt/shot.rs b/crates/app-iced/src/app/adopt/shot.rs new file mode 100644 index 0000000..99df6bd --- /dev/null +++ b/crates/app-iced/src/app/adopt/shot.rs @@ -0,0 +1,199 @@ +//! 出图用的向导状态夹具(D30 视觉验收的输入)。 +//! +//! ## 为什么要有它 +//! +//! 向导是**四段有状态**的界面,而截图通道只跑一次 `view()`(第 3 帧落盘),没有真人点按钮。 +//! 组件库的做法是 `--screen=gallery` + `ForcedState`,页面层的做法是 `pages/instances/demo.rs` +//! 的 `DEMO`——同一类要求,同一类做法:**给渲染器一份确定的状态**,而不是让截图去赌异步时序。 +//! +//! ## 哪些是真、哪些是夹具(复审请照这条核对) +//! +//! | 内容 | 来源 | +//! |---|---| +//! | 候选 / 问题 / 失败原因 | **夹具**(合成的 id 与问题文本,只为把版面撑到真实形态) | +//! | 运行目录 / 隔离档 | 夹具(固定路径,便于两张图逐像素对照) | +//! | **后果原文** | **真**:`unlucky_core::game::adopt::target_notes()` 现算,一个字不抄 | +//! | 文案(标题/标签/按钮) | **真**:`unlucky_i18n` 目录 | +//! | 渲染 | **真**:`--software-renderer`(tiny-skia)1180×760,与产品路径同一棵控件树 | +//! +//! 也就是说:这些图证明**版面与文案**,不证明 core 的判据(判据的证据在 `host/adopt/tests.rs` +//! 与 `core/tests/adopt/`)。 +//! +//! ## 怎么用 +//! +//! ```text +//! ULL_ADOPT_STATE=pick|empty|preview|shared|failed|done \ +//! unlucky-launcher-iced.exe --software-renderer --screenshot [--screenshot-theme dark] +//! ``` +//! +//! 只在 `--screenshot` 已经给了的情况下生效(交互模式读这个环境变量没有意义,直接忽略)。 + +use std::path::PathBuf; + +use unlucky_core::game::adopt::{target_notes, AdoptTarget}; +use unlucky_presenter::dto::{ + AdoptCandidateView, AdoptDoneView, AdoptEvent, AdoptFailureView, AdoptIsolationView, + AdoptPlanView, AdoptProblemView, AdoptScanView, AdoptState, AdoptTargetView, +}; + +use super::{Busy, Form}; + +/// 夹具里的版本目录(只是**画**出来的路径,不碰盘)。 +const VERSIONS_DIR: &str = "D:/minecraft/.minecraft/versions"; + +/// 夹具里的数据区运行目录。 +const INSTANCE_RUN_DIR: &str = "D:/ULL/.ULL/data/instances/Better-MC"; + +/// 夹具里的游戏根。 +const GAME_ROOT: &str = "D:/minecraft/.minecraft"; + +/// 环境变量名(见模块文档)。 +pub(crate) const ENV: &str = "ULL_ADOPT_STATE"; + +/// 按名字造一份「打开着的向导」;不认识的名字返回 `None`(不影响正常启动)。 +pub(crate) fn seed(name: &str) -> Option<(Form, AdoptState)> { + let name = name.trim().to_ascii_lowercase(); + let mut form = Form { + open: true, + ..Form::default() + }; + let mut state = AdoptState::default(); + + match name.as_str() { + // 扫描完成、还没选版本(列表里三态齐全:可导入 / 已登记 / 有问题) + "pick" => { + state.apply(AdoptEvent::Scan(Box::new(scan_view()))); + form.busy = Busy::Idle; + } + // 游戏根下真没有版本(空态) + "empty" => { + state.apply(AdoptEvent::Scan(Box::new(AdoptScanView { + versions_dir: PathBuf::from(VERSIONS_DIR), + candidates: Vec::new(), + }))); + form.busy = Busy::Idle; + } + // 选中缺省档(InstanceDir)并点过预检 + "preview" => { + state.apply(AdoptEvent::Scan(Box::new(scan_view()))); + state.apply(AdoptEvent::Planned(Box::new(plan_view( + AdoptTargetView::InstanceDir, + AdoptIsolationView::Custom, + INSTANCE_RUN_DIR, + )))); + form.selected = Some("1.20.1".to_string()); + form.checked = true; + form.busy = Busy::Idle; + } + // 选中共享档(Shared):后果原文由 core `target_notes()` 现算 + "shared" => { + state.apply(AdoptEvent::Scan(Box::new(scan_view()))); + state.apply(AdoptEvent::Planned(Box::new(plan_view( + AdoptTargetView::Shared, + AdoptIsolationView::Shared, + GAME_ROOT, + )))); + form.selected = Some("1.20.1".to_string()); + form.target = AdoptTargetView::Shared; + form.checked = true; + form.busy = Busy::Idle; + } + // 预检失败:两条问题逐条列出(不许折叠成一句) + "failed" => { + state.apply(AdoptEvent::Scan(Box::new(scan_view()))); + state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView { + code: "adopt_not_adoptable".to_string(), + message: "版本 1.19.4 不能登记:清单不是合法 JSON".to_string(), + problems: vec![ + AdoptProblemView { + code: "manifest_invalid_json".to_string(), + message: format!( + "清单不是合法 JSON {VERSIONS_DIR}/1.19.4/1.19.4.json:expected `,` or `}}` at line 12 column 3" + ), + }, + AdoptProblemView { + code: "missing_parent".to_string(), + message: "版本 1.19.4 继承的父版本 1.19.3 不在盘上".to_string(), + }, + ], + }))); + form.selected = Some("1.19.4".to_string()); + form.checked = true; + form.busy = Busy::Idle; + } + // 登记完成 + "done" => { + state.apply(AdoptEvent::Scan(Box::new(scan_view()))); + state.apply(AdoptEvent::Done(Box::new(AdoptDoneView { + instance_id: "2f1c0a5e-7f4b-4c1d-9a2e-6b8d5c3a1f70".to_string(), + name: "Better MC".to_string(), + version_id: "1.20.1-fabric-0.15.0".to_string(), + isolation: AdoptIsolationView::Custom, + run_dir: PathBuf::from(INSTANCE_RUN_DIR), + dir_created: true, + notes: target_notes(&AdoptTarget::InstanceDir, &PathBuf::from(INSTANCE_RUN_DIR)), + }))); + form.selected = Some("1.20.1-fabric-0.15.0".to_string()); + form.checked = true; + form.busy = Busy::Idle; + } + _ => return None, + } + Some((form, state)) +} + +/// 一份扫描结果(三态齐全)。 +fn scan_view() -> AdoptScanView { + AdoptScanView { + versions_dir: PathBuf::from(VERSIONS_DIR), + candidates: vec![ + AdoptCandidateView { + version_id: "1.20.1".to_string(), + jar_present: true, + inherits: Vec::new(), + registered: Vec::new(), + problems: Vec::new(), + }, + AdoptCandidateView { + version_id: "1.20.1-fabric-0.15.0".to_string(), + jar_present: false, + inherits: vec!["1.20.1-fabric-0.15.0".to_string(), "1.20.1".to_string()], + registered: vec!["Better MC".to_string()], + problems: Vec::new(), + }, + AdoptCandidateView { + version_id: "1.19.4".to_string(), + jar_present: true, + inherits: Vec::new(), + registered: Vec::new(), + problems: vec![AdoptProblemView { + code: "manifest_invalid_json".to_string(), + message: format!( + "清单不是合法 JSON {VERSIONS_DIR}/1.19.4/1.19.4.json:expected `,` or `}}` at line 12 column 3" + ), + }], + }, + ], + } +} + +/// 一份预检结果。**`notes` 由 core 现算**(共享档/版本目录档的后果原文只有一处)。 +fn plan_view( + target: AdoptTargetView, + isolation: AdoptIsolationView, + run_dir: &str, +) -> AdoptPlanView { + let core_target = match target { + AdoptTargetView::InstanceDir => AdoptTarget::InstanceDir, + AdoptTargetView::Shared => AdoptTarget::Shared, + AdoptTargetView::VersionDir => AdoptTarget::VersionDir, + }; + AdoptPlanView { + version_id: "1.20.1".to_string(), + name: "1.20.1".to_string(), + target, + isolation, + run_dir: PathBuf::from(run_dir), + notes: target_notes(&core_target, &PathBuf::from(run_dir)), + } +} diff --git a/crates/app-iced/src/app/adopt/tests.rs b/crates/app-iced/src/app/adopt/tests.rs index 082ff7f..607c047 100644 --- a/crates/app-iced/src/app/adopt/tests.rs +++ b/crates/app-iced/src/app/adopt/tests.rs @@ -90,11 +90,11 @@ fn the_four_stages_project_from_the_event_state() { // Pick assert_eq!(state.stage(), AdoptStage::Pick); assert_eq!( - title_text(state.stage(), catalog), + shell::title_text(state.stage(), catalog), catalog.text(Key::AdoptTitle), "标题走目录" ); - assert!(subtitle(state.stage(), &state, catalog).contains("不复制")); + assert!(shell::subtitle(state.stage(), &state, catalog).contains("不复制")); // Preview:`notes` 是 core `target_notes()` 的原文,界面一个字都不许改 let note = @@ -136,7 +136,7 @@ fn the_four_stages_project_from_the_event_state() { "两条问题一条都不许吞" ); assert_eq!( - title_text(state.stage(), catalog), + shell::title_text(state.stage(), catalog), catalog.text(Key::AdoptFailureTitle) ); @@ -152,7 +152,7 @@ fn the_four_stages_project_from_the_event_state() { }))); assert_eq!(state.stage(), AdoptStage::Done); assert_eq!( - title_text(state.stage(), catalog), + shell::title_text(state.stage(), catalog), catalog.text(Key::AdoptDoneTitle) ); } diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index 64a2148..1dc5802 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -158,6 +158,8 @@ impl App { // M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。 app.hydrate_ai_settings(); // M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。 + // M223: adopt wizard screenshot seed (evidence channel only; `ULL_ADOPT_STATE`). + app.hydrate_adopt_state(); app.hydrate_java_settings(); // 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。 // 下一个人据此分得清「探测错了」还是「探测对了但没用上」。 diff --git a/crates/app-iced/src/app/update/adopt.rs b/crates/app-iced/src/app/update/adopt.rs index 597d1d0..984d9ba 100644 --- a/crates/app-iced/src/app/update/adopt.rs +++ b/crates/app-iced/src/app/update/adopt.rs @@ -85,6 +85,23 @@ impl App { } /// 回程到了:这一条命令已经交卷(`pump` 收到 `CoreEvent::Adopt` 时调,见 `update/clock.rs`)。 + /// 出图通道的种子(`ULL_ADOPT_STATE`,与 `ULL_JAVA_STATE` / `ULL_AI_STATE` 同一族)。 + /// + /// **只在 `--screenshot` 下生效**:交互模式读这个环境变量没有意义(也没有人会设它)。 + /// 不认识的名字一律忽略,退回正常启动路径(向导保持关闭)。 + pub(in crate::app) fn hydrate_adopt_state(&mut self) { + if self.options.screenshot.is_none() { + return; + } + let Ok(raw) = std::env::var(crate::app::adopt::shot::ENV) else { + return; + }; + if let Some((form, state)) = crate::app::adopt::shot::seed(&raw) { + self.adopt_form = form; + self.state.adopt = state; + } + } + pub(super) fn settle_adopt(&mut self) { self.adopt_form.settled(); } diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 7c8b3c8..3da5ed8 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -83,7 +83,8 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("AdoptPlanRunDir", "Game folder", "运行目录"), ("AdoptPlanIsolation", "Isolation", "隔离方式"), ("AdoptFailureTitle", "Import failed", "导入失败"), - ("AdoptProblemCount", "Every reason is listed below - nothing is collapsed into one sentence.", "下面逐条列出全部原因,不折叠成一句「导入失败」。"), + ("AdoptProblemCount", "Every reason is listed below:", "全部原因如下:"), + ("AdoptInstanceName", "Instance name", "实例名"), ("AdoptDoneTitle", "Imported", "已导入"), ("AdoptDoneHint", "It is in the instance list now. Player data will live in the game folder below.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"), ]; diff --git a/crates/i18n/src/catalog/instances.rs b/crates/i18n/src/catalog/instances.rs index 94ef6a5..2bd1aab 100644 --- a/crates/i18n/src/catalog/instances.rs +++ b/crates/i18n/src/catalog/instances.rs @@ -128,7 +128,8 @@ macro_rules! instances_entries { AdoptPlanRunDir => ("Game folder", "运行目录"), AdoptPlanIsolation => ("Isolation", "隔离方式"), AdoptFailureTitle => ("Import failed", "导入失败"), - AdoptProblemCount => ("Every reason is listed below - nothing is collapsed into one sentence.", "下面逐条列出全部原因,不折叠成一句「导入失败」。"), + AdoptProblemCount => ("Every reason is listed below:", "全部原因如下:"), + AdoptInstanceName => ("Instance name", "实例名"), AdoptDoneTitle => ("Imported", "已导入"), AdoptDoneHint => ("It is in the instance list now. Player data will live in the game folder below.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"), } } From fa3fab9086e1685a4930e0b12188089c38536a62 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 15:00:57 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix(app-iced):=20=E5=A4=8D=E5=AE=A1=20r1=20?= =?UTF-8?q?=E4=B8=89=E6=9D=A1=EF=BC=88=E5=9C=A8=E9=A3=9E=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E8=A1=A5=20Back/Target=E3=80=81=E9=A2=84=E6=A3=80=E6=8C=89?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E8=BF=87=E6=9C=9F=E3=80=81shell=20=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=A6=82=E5=AE=9E=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1(行为):`Busy::Importing` 的早退只盖了 Close。「返回」会派 Scan、「换目标档」会派 零写盘 Preview,两条都把 `adopt_epoch` +1,于是在飞的 Confirm 交卷时 Done 被代次丢掉—— 实例写进去了,界面什么都没说(正是 Close 守卫要防的形状)。两处补同款早退,且守卫写在 改本地状态**之前**:挡下来的换档不该把界面改成 B 档而盘上写的是 A 档。 P2-2(显示陈旧):`Form::fresh_plan` 原来只按目标档过期。先选 1.20.1 + 版本目录档,再改选 1.19.4,目标档没变,旧预检的 notes(说的是 `versions/1.20.1`)会继续当新版本的后果原文画。 现在连 `plan.version_id == selected` 一起判;没选中版本时也返回 None。同处那句 「版本一致性由 Self::candidate 那一路保证(选中即重扫)」与代码不符(Select 只改本地字段、 不发命令),改成如实说明过期判定只能在这里做。 P2-3(文档):`app/adopt/shell.rs` 的「纯搬移:函数体一行未改」不成立——同一提交里卡壳几何、 页脚去重、预览摘要都改过。改成逐条列出改了什么与为什么。 用例(两条都是先写红再修,红时 panic 信息就是错在哪): - back_and_target_are_ignored_while_the_registration_is_in_flight(记账后端断言「不许派 Scan / 不许派 Preview」);红:`登记在飞时 Back 不许派 Scan(会把 Done 的代次顶掉)`; - fresh_plan_expires_when_the_selected_version_changes;红:`换了版本之后旧预检必须过期 (否则画的是 1.20.1 的后果原文)`。 顺带补图:`ULL_ADOPT_STATE` 加 `pick-selected` 一档(选中行底色 + 预检启用态), 出图 `DevDocs/artifacts/m223/adopt-pick-selected-light.png`(r1 复审说这格只有代码没截图)。 --- crates/app-iced/src/app/adopt.rs | 15 +++- crates/app-iced/src/app/adopt/shell.rs | 15 +++- crates/app-iced/src/app/adopt/shot.rs | 8 +- crates/app-iced/src/app/adopt/tests.rs | 42 +++++++++ crates/app-iced/src/app/update/adopt.rs | 111 +++++++++++++++++++++++- 5 files changed, 183 insertions(+), 8 deletions(-) diff --git a/crates/app-iced/src/app/adopt.rs b/crates/app-iced/src/app/adopt.rs index bb73656..b6db869 100644 --- a/crates/app-iced/src/app/adopt.rs +++ b/crates/app-iced/src/app/adopt.rs @@ -144,16 +144,23 @@ impl Form { } } - /// 后台算出来、且**对得上当前选择**的预检(对不上 = 上一档的过期结果,一律不画)。 + /// 后台算出来、且**对得上当前选择**的预检(对不上 = 过期的结果,一律不画)。 /// - /// 判据是「目标档一致」:用户改了目标档之后,旧 plan 的 `notes` 说的是别的目录, - /// 拿它当后果原文就是撒谎。版本一致性由 [`Self::candidate`] 那一路保证(选中即重扫)。 + /// 两个判据缺一不可: + /// - **目标档一致**:改了档之后旧 plan 的 `notes` 说的是别的目录; + /// - **版本 id 一致**:换了版本之后旧 plan 的 `notes` 说的是别的版本目录 + /// (版本目录档的后果原文里带 `versions/`,拿它当新版本的后果就是撒谎)。 + /// + /// 没有选中版本时也返回 `None`:那一刻没有任何版本可言,画谁的预检都不成立。 + /// 这两条都不是「选中即重扫」带来的——`Select` 只改本地字段、不发命令(落点见 + /// `app/update/adopt.rs`),所以过期判定只能在这里做。 #[must_use] pub fn fresh_plan<'a>(&self, adopt_state: &'a AdoptState) -> Option<&'a AdoptPlanView> { + let selected = self.selected.as_deref()?; adopt_state .plan .as_ref() - .filter(|plan| plan.target == self.target) + .filter(|plan| plan.target == self.target && plan.version_id == selected) } /// 当前选中的候选(选中项已不在列表里时返回 `None`,界面自动退回「请选择」)。 diff --git a/crates/app-iced/src/app/adopt/shell.rs b/crates/app-iced/src/app/adopt/shell.rs index 1f504a5..35a6051 100644 --- a/crates/app-iced/src/app/adopt/shell.rs +++ b/crates/app-iced/src/app/adopt/shell.rs @@ -6,8 +6,19 @@ //! | [`title_text`] / [`subtitle`] | 四段的标题与一句说明(文案全部来自 i18n 目录) | //! | [`footer`] | 按段给按钮组合;在等回程时主按钮禁用 | //! -//! 纯搬移:函数体一行未改,只是换了文件(`app/adopt.rs` 落笔 412 行越过 `components::gate` -//! 的 400 行硬线)。调用方仍是 `app/adopt.rs` 的 `layer` 与 `middle`。 +//! ## 这个文件不是「纯搬移」 +//! +//! 它**起于**一次搬移:`app/adopt.rs` 落笔 412 行越过 `components::gate` 的 400 行硬线, +//! 于是把卡壳 / 标题 / 页脚搬到这里。但**同一个提交里**这三块还改过行为与几何,逐条如下 +//! (复审对 diff 时按这几条看,别按「一行未改」看): +//! +//! | 改了什么 | 为什么 | +//! |---|---| +//! | `card` 拆成「定高 + 中间区可滚」(Pick / Failed)与「按内容收高」(Preview / Done)两种几何 | 原来只给 `max_height`,内容一高就被裁:页脚按钮与实例名整排不可见(出图时抓到) | +//! | `footer` 的「关闭」只在 Pick / Preview 出现 | Failed / Done 的主按钮就是关闭,再给一枚文字关闭就是两枚一模一样的按钮 | +//! | `subtitle` 的 Preview 文案改成「版本名 · 目标档」 | 原来打「隔离档 · 目标档」,同屏两个「共用目录」 | +//! +//! 调用方仍是 `app/adopt.rs` 的 `layer` 与 `middle`。 use iced::widget::{column, container, row, scrollable, Space}; use iced::{Background, Element, Length}; diff --git a/crates/app-iced/src/app/adopt/shot.rs b/crates/app-iced/src/app/adopt/shot.rs index 99df6bd..cd48a14 100644 --- a/crates/app-iced/src/app/adopt/shot.rs +++ b/crates/app-iced/src/app/adopt/shot.rs @@ -22,7 +22,7 @@ //! ## 怎么用 //! //! ```text -//! ULL_ADOPT_STATE=pick|empty|preview|shared|failed|done \ +//! ULL_ADOPT_STATE=pick|pick-selected|empty|preview|shared|failed|done \ //! unlucky-launcher-iced.exe --software-renderer --screenshot [--screenshot-theme dark] //! ``` //! @@ -65,6 +65,12 @@ pub(crate) fn seed(name: &str) -> Option<(Form, AdoptState)> { state.apply(AdoptEvent::Scan(Box::new(scan_view()))); form.busy = Busy::Idle; } + // 扫描完成、**已选中一个可导入的版本**(选中行底色 + 预检按钮启用态) + "pick-selected" => { + state.apply(AdoptEvent::Scan(Box::new(scan_view()))); + form.selected = Some("1.20.1".to_string()); + form.busy = Busy::Idle; + } // 游戏根下真没有版本(空态) "empty" => { state.apply(AdoptEvent::Scan(Box::new(AdoptScanView { diff --git a/crates/app-iced/src/app/adopt/tests.rs b/crates/app-iced/src/app/adopt/tests.rs index 607c047..2697f6f 100644 --- a/crates/app-iced/src/app/adopt/tests.rs +++ b/crates/app-iced/src/app/adopt/tests.rs @@ -243,3 +243,45 @@ fn a_foreign_target_shows_its_notes_without_jumping_to_confirm() { form.target = AdoptTargetView::VersionDir; assert!(form.fresh_plan(&state).is_none(), "过期的预检不许画"); } + +/// **P2-2**:`fresh_plan` 必须按**版本 id** 过期,不只是目标档。 +/// +/// 场景:先选 1.20.1 + 版本目录档(notes 说 `versions/1.20.1`),再改选 1.19.4——目标档没变, +/// 旧的预检结果若不按版本过滤就会继续当「这个版本的后果原文」画出来,那是一句假话。 +/// +/// 抓得住:`fresh_plan` 只看 `plan.target == self.target`(第一次修法就是这样漏的)。 +/// 抓不住:core 真的会不会为 1.19.4 算出同一段 notes(那是 `target_notes()` 的活)。 +#[test] +fn fresh_plan_expires_when_the_selected_version_changes() { + let mut state = state_with_scan(); + state.apply(AdoptEvent::Planned(Box::new(AdoptPlanView { + version_id: "1.20.1".to_string(), + name: "1.20.1".to_string(), + target: AdoptTargetView::VersionDir, + isolation: AdoptIsolationView::Isolated, + run_dir: std::path::PathBuf::from("/game/versions/1.20.1"), + notes: vec!["运行目录 = 版本目录 /game/versions/1.20.1".to_string()], + }))); + + let mut form = Form { + open: true, + selected: Some("1.20.1".to_string()), + target: AdoptTargetView::VersionDir, + checked: true, + ..Form::default() + }; + assert!( + form.fresh_plan(&state).is_some(), + "对得上版本与目标档时应当可读" + ); + + // 改选另一个版本:目标档没变,但旧预检说的是别的版本的目录 + form.selected = Some("1.19.4".to_string()); + assert!( + form.fresh_plan(&state).is_none(), + "换了版本之后旧预检必须过期(否则画的是 1.20.1 的后果原文)" + ); + // 没选版本时同样不该画(`None` = 没有任何版本的预检可言) + form.selected = None; + assert!(form.fresh_plan(&state).is_none()); +} diff --git a/crates/app-iced/src/app/update/adopt.rs b/crates/app-iced/src/app/update/adopt.rs index 984d9ba..d63c3c9 100644 --- a/crates/app-iced/src/app/update/adopt.rs +++ b/crates/app-iced/src/app/update/adopt.rs @@ -48,6 +48,12 @@ impl App { } AdoptMessage::Name(name) => self.adopt_form.name = name, AdoptMessage::Target(target) => { + // 登记在飞时不吃「换档」:它会派一条零写盘的预览把 `adopt_epoch` +1, + // 于是在飞的 Confirm 交卷时 Done 被代次丢掉——实例写进去了,界面什么都没说。 + // 连本地档位也不改:界面上显示 A 档、盘上写的是 B 档,是最糟的一种不一致。 + if self.adopt_form.busy == Busy::Importing { + return Task::none(); + } self.adopt_form.target = target; // 换了目标档 ⇒ 上一档的预检作废(确认按钮不该继续亮着) self.adopt_form.checked = false; @@ -74,6 +80,11 @@ impl App { } } AdoptMessage::Back => { + // 同 Close / Target:登记在飞时「返回」会把 `adopt_epoch` +1(它派 Scan), + // 在飞 Confirm 的 Done 就再也到不了界面。 + if self.adopt_form.busy == Busy::Importing { + return Task::none(); + } // 退回候选列表 = 重新扫一次:`scan` 会把上一轮的预检/失败一起作废, // 于是界面回到 Pick 段,不需要再给 core 加一个「清失败」的命令。 self.adopt_form.checked = false; @@ -124,8 +135,13 @@ impl App { #[cfg(test)] mod tests { + use std::cell::RefCell; + use std::rc::Rc; + use unlucky_design::color::SchemeFlavor; - use unlucky_presenter::dto::{AdoptEvent, AdoptTargetView}; + use unlucky_presenter::dto::{ + AdoptCommand, AdoptEvent, AdoptTargetView, AppCommand, AppEvent, Backend, + }; use unlucky_presenter::mock::MockBackend; use unlucky_presenter::settings_bridge::SettingsBridge; @@ -150,6 +166,52 @@ mod tests { ) } + /// 记账后端:把 `dispatch` 收到的命令留在台账里(`MockBackend` 只认自己那几条, + /// 「有没有派出去」根本看不见——而 P2-1 的判据正是「不许派」)。 + #[derive(Debug, Default)] + struct RecordingBackend { + dispatched: Rc>>, + } + + impl Backend for RecordingBackend { + fn drain_events(&mut self) -> Vec { + Vec::new() + } + + fn dispatch(&mut self, command: AppCommand) { + self.dispatched.borrow_mut().push(command); + } + } + + /// 带台账的 App(返回台账句柄,测试直接查「派了什么」)。 + fn recording() -> (App, Rc>>) { + let dispatched: Rc>> = Rc::new(RefCell::new(Vec::new())); + let app = App::with_presenter( + Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(RecordingBackend { + dispatched: Rc::clone(&dispatched), + }), + SettingsBridge::memory_only(), + ); + (app, dispatched) + } + + /// 台账里有没有派过某条 adopt 命令(只认命令种类,不看载荷内容)。 + fn dispatched_adopt(ledger: &Rc>>, want: &str) -> bool { + ledger.borrow().iter().any(|command| match command { + AppCommand::Adopt(command) => match (&**command, want) { + (AdoptCommand::Scan, "scan") => true, + (AdoptCommand::Preview(_), "preview") => true, + (AdoptCommand::Confirm(_), "confirm") => true, + (AdoptCommand::Dismiss, "dismiss") => true, + _ => false, + }, + _ => false, + }) + } + /// 打开向导 = 表单进入「在等扫描」+ 派一条 `Scan`。 /// /// 抓得住:`Open` 没派命令(面板永远停在「正在扫描」)、或忘了置 `Busy::Scanning`(没有等待态)。 @@ -241,4 +303,51 @@ mod tests { let _ = app.on_adopt(AdoptMessage::Close); assert!(app.adopt_form.open, "登记在飞时不许把面板关掉"); } + + /// **P2-1**:登记在飞时,除了 Close,「返回」与「换目标档」也必须早退。 + /// + /// 三条都是同一条病:它们都会派一条新命令把 `adopt_epoch` +1,于是在飞 Confirm 的 + /// `Done` 回来时被代次丢掉 —— 实例**写进去了**,界面却什么都没说(Close 那条早就守住了, + /// 唯独这两支漏了)。 + /// + /// 抓得住:`Back` 派了 `Scan`(旧 `Done` 被丢)、`Target` 派了 `Preview`(同上), + /// 以及「守卫写了但写在改本地状态之后」(表单被改成新档位,而盘上写的是旧档位)。 + /// 抓不住:host 侧代次比较的真实时序(那在 presenter 的 `host/adopt` 那层)。 + #[test] + fn back_and_target_are_ignored_while_the_registration_is_in_flight() { + let (mut app, ledger) = recording(); + let _ = app.on_adopt(AdoptMessage::Open); + let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string())); + let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::InstanceDir)); + // 走到「确认」那一步:真的派一条 Confirm 出去 + let _ = app.on_adopt(AdoptMessage::Check); + let _ = app.on_adopt(AdoptMessage::Confirm); + assert!( + dispatched_adopt(&ledger, "confirm"), + "前提:确认要真的派出去" + ); + assert_eq!(app.adopt_form.busy, Busy::Importing); + ledger.borrow_mut().clear(); + + // 在飞期间点「返回」:不许派 Scan + let _ = app.on_adopt(AdoptMessage::Back); + assert!( + !dispatched_adopt(&ledger, "scan"), + "登记在飞时 Back 不许派 Scan(会把 Done 的代次顶掉)" + ); + assert_eq!(app.adopt_form.busy, Busy::Importing, "等待态不许被改掉"); + + // 在飞期间换目标档:不许派 Preview,也不许改本地档位 + let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::Shared)); + assert!( + !dispatched_adopt(&ledger, "preview"), + "登记在飞时换档不许派 Preview(会把 Done 的代次顶掉)" + ); + assert_eq!( + app.adopt_form.target, + AdoptTargetView::InstanceDir, + "挡下来的点击不该改出界面与盘上不一致的档位" + ); + assert_eq!(app.adopt_form.busy, Busy::Importing); + } } From 44c00b48739508f0b897a8588aa378a5094c5400 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 15:29:46 +0800 Subject: [PATCH 9/9] =?UTF-8?q?fix(app-iced):=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=8A=A9=E6=89=8B=E6=94=B9=E7=94=A8=20matches!=EF=BC=8C?= =?UTF-8?q?=E6=B6=88=E6=8E=89=20deny=20=E7=BA=A7=20clippy=EF=BC=88?= =?UTF-8?q?=E5=A4=8D=E5=AE=A1=20r2=20P1-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dispatched_adopt` 内层 `match (&**command, want) { … _ => false }` 被判 `match expression looks like matches! macro`(deny 级 ⟹ CI 的 `--workspace --all-targets -D warnings` 必红;558fbf2 原树 `cargo clippy -p unlucky-app-iced --all-targets` 实测 exit 101)。 改成 `let AppCommand::Adopt(..) = command else { return false }` + 一条 `matches!`, 判据与原来逐字等价(四种命令种类 × 对应 slug)。同一处的注释补一句「为什么不能再写 match」, 免得下一个人改回去。 教训记账:r2 自验只跑了 fmt/check/test,漏了 clippy(T23「五步少一步等于那步不存在」); 本轮五步齐跑,退出码见完成报告。 --- crates/app-iced/src/app/update/adopt.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/crates/app-iced/src/app/update/adopt.rs b/crates/app-iced/src/app/update/adopt.rs index d63c3c9..965ade4 100644 --- a/crates/app-iced/src/app/update/adopt.rs +++ b/crates/app-iced/src/app/update/adopt.rs @@ -199,16 +199,21 @@ mod tests { } /// 台账里有没有派过某条 adopt 命令(只认命令种类,不看载荷内容)。 + /// + /// 写法是 `let ... else` + `matches!`:内层用 `match` 会被 clippy 判成 + /// `match expression looks like matches! macro`(deny 级,CI 的 `-D warnings` 直接红)。 fn dispatched_adopt(ledger: &Rc>>, want: &str) -> bool { - ledger.borrow().iter().any(|command| match command { - AppCommand::Adopt(command) => match (&**command, want) { - (AdoptCommand::Scan, "scan") => true, - (AdoptCommand::Preview(_), "preview") => true, - (AdoptCommand::Confirm(_), "confirm") => true, - (AdoptCommand::Dismiss, "dismiss") => true, - _ => false, - }, - _ => false, + ledger.borrow().iter().any(|command| { + let AppCommand::Adopt(command) = command else { + return false; + }; + matches!( + (&**command, want), + (AdoptCommand::Scan, "scan") + | (AdoptCommand::Preview(_), "preview") + | (AdoptCommand::Confirm(_), "confirm") + | (AdoptCommand::Dismiss, "dismiss") + ) }) }