Merge branch 'feat/m223-gui-adopt-import-wiring-guiadopt'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-10 20:18:14 +08:00
commit 71144dfca8
34 files changed
+3030 -112

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
//! 「导入已有版本」向导(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`,外面套一条收窄
//! [`Form::stage`]——`Preview` 只在用户**点过预检**(`Form::checked`)之后才算数。收窄的理由:
//! 共享档 / 版本目录档一被点选就要显示后果原文(§5.2),那要用一次**零写盘**的预检换;
//! 但那次结果不该把用户直接甩到确认页(实例名还没填完)。
//!
//! 卡壳与页脚在 [`shell`](定高可滚 / 收高两种几何),三段视图在 [`pick`] 与 [`panes`],
//! 等宽与正文小工具在 `text`,D30 出图种子在 `shot`(`ULL_ADOPT_STATE`,只在 `--screenshot` 下生效)。
//!
//! ## 三条纪律(契约 §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 shell;
/// D30 出图用的状态夹具(只在 `--screenshot` 下按环境变量生效)。
pub(crate) mod shot;
mod text;
#[cfg(test)]
mod tests;
use iced::widget::{container, mouse_area, opaque, Space};
use iced::{Alignment, Background, Element, Length};
use unlucky_design::spacing::Space as Sp;
use unlucky_i18n::{Catalog, Lang};
use unlucky_presenter::dto::{
AdoptCandidateView, AdoptPlanView, AdoptStage, AdoptState, AdoptTargetView,
};
use crate::app::components::feedback::dialog::scrim;
use crate::theme::Ui;
use super::message::Message;
/// 向导卡宽度:比模态卡(560)宽一档——候选行要放「版本 id + 状态」,目标档是三枚 chip。
const CARD_WIDTH: f32 = 640.0;
/// 卡片的最大高(窗口更矮时整卡按窗口走:内容在中间区滚)。
const CARD_MAX_HEIGHT: f32 = 620.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<String>,
/// 展示名(空 = 用版本 id)
pub name: String,
/// 运行目录档(缺省 [`AdoptTargetView::InstanceDir`],**不是** isolated)
pub target: AdoptTargetView,
/// 在等在飞的那一条命令
pub busy: Busy,
/// 用户已经点过「预检」(没点过就留在 Pick 段,哪怕后台已经顺手算过一次落点)。
///
/// 为什么要有这一位:共享档 / 版本目录档一旦被点选,界面就要立刻显示 `target_notes()` 的
/// **后果原文**(契约 §5.2)——那是 core 唯一会产出这段文字的地方,所以点选时顺手发一次
/// **零写盘**的预检。但那一次结果不该把用户甩到「可以导入」的确认页:确认必须由显式的
/// `Check` 触发。于是 `checked` 记住「这次预检是不是用户要的」。
pub checked: bool,
}
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;
}
/// 当前该画哪一段:`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` 说的是别的目录;
/// - **版本 id 一致**:换了版本之后旧 plan 的 `notes` 说的是别的版本目录
/// (版本目录档的后果原文里带 `versions/<id>`,拿它当新版本的后果就是撒谎)。
///
/// 没有选中版本时也返回 `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 && plan.version_id == selected)
}
/// 当前选中的候选(选中项已不在列表里时返回 `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(shell::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()
}
/// 中间区(按段交给 `pick` / `panes`)。
pub(super) fn middle<'a>(
ui: &'a Ui,
form: &'a Form,
adopt_state: &'a AdoptState,
stage: AdoptStage,
catalog: Catalog,
) -> Element<'a, Message> {
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),
}
}
+127
View File
@@ -0,0 +1,127 @@
//! 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;
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::theme::Ui;
use super::super::message::Message;
use super::text::{field_line, isolation_label, mono, problem_line, wrapped};
/// 预检结果:落点 + 隔离档 + 后果原文。
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::AdoptInstanceName), &plan.name),
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));
}
body.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::AdoptInstanceName), &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()
}
+228
View File
@@ -0,0 +1,228 @@
//! Pick 段:候选列表 + 目标档 + 实例名。
//!
//! **可导入的候选才可点**:不可导入的行把 `problems` 逐条列在下面但不接点击——点它只会换来
//! 一条注定被 core 拒绝的命令(`adopt_not_adoptable`),而用户从行上已经看到了原因。
//!
//! 目标档三枚 chip 的**短提示**在选中时立刻换(共享/版本目录档用 `Error` 色);
//! **后果原文**不在这里——它在 Preview 段逐条画 `plan.notes`(core `target_notes()` 的原文)。
use iced::widget::{button, column, container, row};
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, AdoptPlanView, 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};
/// Pick 段整体。
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 {
// 扫描还没回来:一行进度文案(不做假进度条)
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));
// 后果原文(契约 §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)),
|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::<Vec<_>>(),
)
.spacing(ui.space(Sp::X1))
.width(Length::Fill);
rows.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::<Vec<_>>();
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)
}
+198
View File
@@ -0,0 +1,198 @@
//! 向导卡的外壳与页脚(M223 从 `app/adopt.rs` 搬出,守 400 行硬线)。
//!
//! | 函数 | 职责 |
//! |---|---|
//! | [`card`] | 卡壳(标题 + 说明 + 中间区 + 页脚)、定高/收高两种几何、遮罩上的那一张卡 |
//! | [`title_text`] / [`subtitle`] | 四段的标题与一句说明(文案全部来自 i18n 目录) |
//! | [`footer`] | 按段给按钮组合;在等回程时主按钮禁用 |
//!
//! ## 这个文件不是「纯搬移」
//!
//! 它**起于**一次搬移:`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};
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()
}
+205
View File
@@ -0,0 +1,205 @@
//! 出图用的向导状态夹具(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|pick-selected|empty|preview|shared|failed|done \
//! unlucky-launcher-iced.exe --software-renderer --screenshot <path> [--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;
}
// 扫描完成、**已选中一个可导入的版本**(选中行底色 + 预检按钮启用态)
"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 {
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)),
}
}
+287
View File
@@ -0,0 +1,287 @@
//! `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<AdoptProblemView>) -> 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!(
shell::title_text(state.stage(), catalog),
catalog.text(Key::AdoptTitle),
"标题走目录"
);
assert!(shell::subtitle(state.stage(), &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!(
shell::title_text(state.stage(), 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!(
shell::title_text(state.stage(), 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}");
}
/// 「点选共享档 ⇒ 立刻看到后果原文」但**不**把用户甩到确认页:
/// 预检结果到了也只是 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(), "过期的预检不许画");
}
/// **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());
}
+88
View File
@@ -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),
}
}
+5
View File
@@ -69,6 +69,11 @@ pub enum Message {
Demo(components::gallery::DemoMessage), Demo(components::gallery::DemoMessage),
/// 模态对话框(M187):主/次按钮、遮罩、展开、导出、打开文件夹、复制路径。 /// 模态对话框(M187):主/次按钮、遮罩、展开、导出、打开文件夹、复制路径。
Dialog(super::dialog::Message), Dialog(super::dialog::Message),
/// 导入已有版本向导(M223):打开 / 选版本 / 选目标 / 预检 / 确认 / 关闭。
///
/// 落点是 `app/update/adopt.rs`。页面只发一条入口意图
/// (`pages::Message::Action(pages::Action::Import)`),向导自己的四段状态都在这里收口。
Adopt(super::adopt::AdoptMessage),
} }
/// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。 /// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。
+1
View File
@@ -27,6 +27,7 @@
//! `src/components/`(组件目录自己的归属规则不变),逻辑路径仍是 `crate::app::components` //! `src/components/`(组件目录自己的归属规则不变),逻辑路径仍是 `crate::app::components`
//! (组件源码里的文档与测试都按这个路径写)。 //! (组件源码里的文档与测试都按这个路径写)。
mod adopt;
mod appearance; mod appearance;
mod backend; mod backend;
mod boot; mod boot;
+5
View File
@@ -196,6 +196,11 @@ pub struct App {
/// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在 /// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在
/// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。 /// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。
pub(super) launch_form: pages::LaunchForm, pub(super) launch_form: pages::LaunchForm,
/// 实例页「导入已有版本」向导的表单态(M223):选中版本 / 展示名 / 目标档 / 在等回程。
///
/// 住在 `App` 的理由与 `launch_form` 一致:向导跨帧(扫描 → 选 → 预检 → 确认),
/// 而候选/预检/失败/完成这些**事件真相**在 `AppState.adopt`(presenter),不在这里。
pub(super) adopt_form: super::adopt::Form,
/// 启动失败弹窗的**事实**(M187):与 `state.dialogs` 里的当前条按 id 绑定。 /// 启动失败弹窗的**事实**(M187):与 `state.dialogs` 里的当前条按 id 绑定。
/// ///
/// 为什么事实不塞进 `DialogRequest`:那是 presenter 的公共 DTO,弹窗与「这次失败的现场」 /// 为什么事实不塞进 `DialogRequest`:那是 presenter 的公共 DTO,弹窗与「这次失败的现场」
@@ -136,6 +136,7 @@ impl App {
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串 // 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
download_draft: pages::InstallDraft::default(), download_draft: pages::InstallDraft::default(),
launch_form: pages::LaunchForm::default(), launch_form: pages::LaunchForm::default(),
adopt_form: super::super::adopt::Form::default(),
install_cancelled_seen: 0, install_cancelled_seen: 0,
launch_ends_seen: 0, launch_ends_seen: 0,
dialog_card: None, dialog_card: None,
@@ -157,6 +158,8 @@ impl App {
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。 // M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
app.hydrate_ai_settings(); app.hydrate_ai_settings();
// M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。 // M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。
// M223: adopt wizard screenshot seed (evidence channel only; `ULL_ADOPT_STATE`).
app.hydrate_adopt_state();
app.hydrate_java_settings(); app.hydrate_java_settings();
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。 // 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。 // 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
+4
View File
@@ -46,6 +46,8 @@ use super::state::{App, IDLE_TICK, TICK, WATCH_WINDOW};
use super::CAPTURE_AT_FRAME; use super::CAPTURE_AT_FRAME;
mod account; mod account;
// M223:导入已有版本向导(扫描 / 预检 / 确认 / 关闭 / 退回)
mod adopt;
mod agent; mod agent;
mod appearance; mod appearance;
pub(super) mod boot_env; pub(super) mod boot_env;
@@ -149,6 +151,8 @@ impl App {
} }
Message::Page(message) => self.on_page(message), Message::Page(message) => self.on_page(message),
Message::Dialog(message) => dialog::on_dialog(self, message), Message::Dialog(message) => dialog::on_dialog(self, message),
// M223:导入已有版本向导(打开 / 选版本 / 选目标 / 预检 / 确认 / 退回 / 关闭)
Message::Adopt(message) => self.on_adopt(message),
Message::Agent(message) => { Message::Agent(message) => {
// `Esc` 先给弹窗:有弹窗时它属于「关掉这一层」,不属于 AI 栏的收起。 // `Esc` 先给弹窗:有弹窗时它属于「关掉这一层」,不属于 AI 栏的收起。
if let crate::shell::agent::Message::Key(event) = &message { if let crate::shell::agent::Message::Key(event) = &message {
+358
View File
@@ -0,0 +1,358 @@
//! 「导入已有版本」向导的落点(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<Message> {
match message {
AdoptMessage::Open => {
self.adopt_form.open();
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);
}
AdoptMessage::Select(version_id) => {
self.adopt_form.selected = Some(version_id);
}
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;
// 共享档 / 版本目录档会写进**别人的目录**:一被点选就把后果原文拿出来
// (零写盘的预检,见 `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)));
}
}
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 => {
// 同 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;
self.adopt_form.busy = Busy::Scanning;
self.dispatch_adopt(AdoptCommand::Scan);
}
}
Task::none()
}
/// 回程到了:这一条命令已经交卷(`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();
}
/// 当前表单能拼出的请求(没选版本就没有请求——按钮在那边也是禁用的)。
fn adopt_request(&self) -> Option<AdoptRequestView> {
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 std::cell::RefCell;
use std::rc::Rc;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{
AdoptCommand, AdoptEvent, AdoptTargetView, AppCommand, AppEvent, Backend,
};
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(),
)
}
/// 记账后端:把 `dispatch` 收到的命令留在台账里(`MockBackend` 只认自己那几条,
/// 「有没有派出去」根本看不见——而 P2-1 的判据正是「不许派」)。
#[derive(Debug, Default)]
struct RecordingBackend {
dispatched: Rc<RefCell<Vec<AppCommand>>>,
}
impl Backend for RecordingBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
Vec::new()
}
fn dispatch(&mut self, command: AppCommand) {
self.dispatched.borrow_mut().push(command);
}
}
/// 带台账的 App(返回台账句柄,测试直接查「派了什么」)。
fn recording() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
let dispatched: Rc<RefCell<Vec<AppCommand>>> = 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 命令(只认命令种类,不看载荷内容)。
///
/// 写法是 `let ... else` + `matches!`:内层用 `match` 会被 clippy 判成
/// `match expression looks like matches! macro`(deny 级,CI 的 `-D warnings` 直接红)。
fn dispatched_adopt(ledger: &Rc<RefCell<Vec<AppCommand>>>, want: &str) -> bool {
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")
)
})
}
/// 打开向导 = 表单进入「在等扫描」+ 派一条 `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());
}
/// 共享档一被点选就发一条**预检**(零写盘),但**不**置 `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, "登记在飞时不许把面板关掉");
}
/// **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);
}
}
+9
View File
@@ -140,7 +140,16 @@ impl App {
) { ) {
clean_scan = true; clean_scan = true;
} }
// M223:导入向导「在等回程」只认事件到达(派发时置位、这里解除),
// 于是连点「确认」不会写两条实例记录,界面也不会一直转圈。
let settled_adopt = matches!(
&event,
unlucky_presenter::dto::AppEvent::Core(unlucky_presenter::dto::CoreEvent::Adopt(_))
);
self.state.apply(event); self.state.apply(event);
if settled_adopt {
self.settle_adopt();
}
} }
self.view_dirty = true; self.view_dirty = true;
self.auto_select(); self.auto_select();
@@ -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 模块文档的偏离登记)
pages::Message::Action(action) => { pages::Message::Action(action) => {
diag!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)"); diag!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
+11 -2
View File
@@ -185,10 +185,19 @@ impl App {
), ),
}; };
// 队列里有一条就叠一层模态(没有则原样返回)。模型由 `refresh_view` 维护。 // 队列里有一条就叠一层模态(没有则原样返回)。模型由 `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), Some(modal) => super::dialog::view::layer(&self.ui, tree, modal),
None => tree, None => tree,
} };
// M223:导入已有版本向导(实例页页头「导入」打开;没打开时原样返回页面树)。
// 叠在模态**之上**:向导自己就是一层交互,模态只在崩溃/确认时出现,两者不会互相抢焦点。
super::adopt::layer(
&self.ui,
tree,
&self.adopt_form,
&self.state.adopt,
self.lang(),
)
} }
} }
+2 -2
View File
@@ -8,8 +8,8 @@
//! 路径上的已有祖先若解析到**数据根之外**(符号链接/目录联接)⇒ 拒绝,不沿它写盘; //! 路径上的已有祖先若解析到**数据根之外**(符号链接/目录联接)⇒ 拒绝,不沿它写盘;
//! 已被**别的实例**占着 ⇒ 顺延 `-2`/`-3` 后缀,绝不让两个实例共用同一个运行目录)。 //! 已被**别的实例**占着 ⇒ 顺延 `-2`/`-3` 后缀,绝不让两个实例共用同一个运行目录)。
//! //!
//! 写配置**之前**还要核一遍真实写面(`config/`、`config/instances/`、`profiles.json` 必须在 //! 写配置**之前**还要核一遍真实写面(`config/`、`config/instances/`、`profiles.json` 必须落在
//! 写盘面判据见 [`fspaths`](配置必须在 `<.ULL>` 内、默认写盘面不得与游戏树重叠)。 //! `<.ULL>` 内、且默认写盘面不得与游戏树重叠),判据见 [`fspaths`]。
//! //!
//! `versions/` `libraries/` `assets/` **一个字节都不写**:登记只填 `version_id`,内容靠 D20 的 //! `versions/` `libraries/` `assets/` **一个字节都不写**:登记只填 `version_id`,内容靠 D20 的
//! 「恒共享层」被启动/下载链路复用([`crate::game::isolation::SHARED_LAYERS`])。 //! 「恒共享层」被启动/下载链路复用([`crate::game::isolation::SHARED_LAYERS`])。
+4 -2
View File
@@ -13,8 +13,10 @@
//! # 判据的基准(为什么不拿 `<data>/instances` 或 `<.ULL>/config` 当基准) //! # 判据的基准(为什么不拿 `<data>/instances` 或 `<.ULL>/config` 当基准)
//! //!
//! 被保护的**边界自己是链接**时,拿它当基准等于把越界当正常。所以基准一律是**解析后的父级**: //! 被保护的**边界自己是链接**时,拿它当基准等于把越界当正常。所以基准一律是**解析后的父级**:
//! 数据写面看 `<data>`,配置写面看 `<.ULL>`、并额外排除游戏根。用户把整棵树迁到别的盘 //! 数据写面看 `<data>`(本模块只负责这一条);配置写面(`<.ULL>` 内、并额外排除游戏根)已移交
//! (`<.ULL>` 或 `<data>` 本身是联接/符号链接)仍然允许——判据是落点,不是路径长得像不像。 //! [`super::fspaths`] 的 `ensure_config_write_surface` / `ensure_surfaces_clear_of_game_tree`,
//! 不在本模块的判据里。用户把整棵树迁到别的盘(`<.ULL>` 或 `<data>` 本身是联接/符号链接)
//! 仍然允许——判据是落点,不是路径长得像不像。
//! //!
//! # 已知边界 //! # 已知边界
//! //!
+28
View File
@@ -59,6 +59,34 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("InstancesBackendVulkan", "Vulkan", "Vulkan 后端"), ("InstancesBackendVulkan", "Vulkan", "Vulkan 后端"),
("InstancesLaunchSave", "Save", "保存"), ("InstancesLaunchSave", "Save", "保存"),
("InstancesLaunchReset", "Reset to global", "恢复跟随全局"), ("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/<id>", "玩家数据会长在 versions/<id> 里"),
("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:", "全部原因如下:"),
("AdoptInstanceName", "Instance name", "实例名"),
("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`)。 /// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
+30
View File
@@ -102,6 +102,36 @@ macro_rules! instances_entries {
InstancesBackendVulkan => ("Vulkan", "Vulkan 后端"), InstancesBackendVulkan => ("Vulkan", "Vulkan 后端"),
InstancesLaunchSave => ("Save", "保存"), InstancesLaunchSave => ("Save", "保存"),
InstancesLaunchReset => ("Reset to global", "恢复跟随全局"), 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/<id>", "玩家数据会长在 versions/<id> 里"),
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:", "全部原因如下:"),
AdoptInstanceName => ("Instance name", "实例名"),
AdoptDoneTitle => ("Imported", "已导入"),
AdoptDoneHint => ("It is in the instance list now. Player data will live in the game folder below.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"),
} } } }
}; };
} }
+384
View File
@@ -0,0 +1,384 @@
//! 「导入已有版本」的 IPC 面(M223 GUIADOPT):扫描 → 预检 → 确认三段。
//!
//! 能力本身在 core(`unlucky_core::game::adopt`,M221 的契约见
//! `DevDocs/SubAgentsTalk/adoptcore-契约.md`);本文件只有**可序列化的视图**,
//! 一个 `unlucky_core::*` 类型都不出现(AGENTS §6 的跨边界纪律)。
//!
//! ## 三段:前两段零写盘,第三段只写 ULL 自己的配置
//!
//! | 段 | 指令 | 回程 | 写盘 |
//! |---|---|---|---|
//! | 扫描 | [`AdoptCommand::Scan`] | [`AdoptEvent::Scan`] | 零(不建目录、不改 mtime) |
//! | 预检 | [`AdoptCommand::Preview`] | [`AdoptEvent::Planned`] / [`AdoptEvent::Failed`] | 零 |
//! | 确认 | [`AdoptCommand::Confirm`] | [`AdoptEvent::Done`] / [`AdoptEvent::Failed`] | 实例设置 + `profiles.json`(+ 缺省运行目录) |
//!
//! ## 三条硬纪律(契约 §5 的 1/2/3)
//!
//! 1. **运行目录只认回程**:[`AdoptPlanView::run_dir`] / [`AdoptDoneView::run_dir`] 是唯一来源。
//! 缺省档会带 `-2`/`-3` 后缀消歧(两个不同展示名可能落到同一个物理目录),**只有 core 知道
//! 最后用了哪个**;页面拿展示名自己派生一次就是错的(会与实例真正启动时用的目录分叉)。
//! 2. **后果话术只有一份**:`Shared` / `VersionDir` 的后果**不在本文件里另写**一句,
//! 由预检把 core `target_notes()` 的原文放进 [`AdoptPlanView::notes`]——于是界面上的
//! 「mods/saves/config 会落进别人的 `.minecraft`」与 CLI 打的是同一句。
//! 想显示后果就必须先预检(预检零写盘,所以这不是成本)。
//! 3. **问题逐条**:core 的结构化问题([`AdoptProblemView`],带稳定 `code`)与整次失败
//! ([`AdoptFailureView`])都原样搬过来,**不折叠成一句「导入失败」**。
//!
//! ## 缺省档为什么是 `InstanceDir` 而不是 `isolated`
//!
//! 全局缺省 `isolated` 的运行目录就是 `versions/<id>/`——那是**别人的版本目录**。沿用它会让第一次
//! 启动就在别人的 `.minecraft` 里长出 `mods/` `saves/` `logs/`。所以 [`AdoptTargetView`] 的
//! 缺省是 `InstanceDir`(ULL 数据区新建独立目录),另两档必须**明确点选**。
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// app → host 的导入意图。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AdoptCommand {
/// 扫当前游戏根的 `versions/`(只读;顺带得到每个版本能不能登记、已登记给谁)
Scan,
/// **预检**:按请求算落点与后果(零写盘)。页面拿回 [`AdoptEvent::Planned`] 才画确认按钮。
Preview(Box<AdoptRequestView>),
/// 确认登记(唯一写盘入口)。
Confirm(Box<AdoptRequestView>),
/// 关掉面板(扫描结果 / 预检 / 结果卡都收起来)
Dismiss,
}
/// host → app 的回程事件。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AdoptEvent {
/// 扫描结果(`versions/` 里的全部条目,逐项带问题)
Scan(Box<AdoptScanView>),
/// 预检结果(落点 + 后果原文 + 将写的展示名)
Planned(Box<AdoptPlanView>),
/// 失败:`code` 稳定、`message` 人读、`problems` 逐条(可能为空)
Failed(Box<AdoptFailureView>),
/// 登记完成
Done(Box<AdoptDoneView>),
/// 面板已关(状态复位)
Cleared,
}
/// 登记后的**运行目录从哪来**(隔离档由它决定)。
///
/// GUI 只开三档:[`Self::InstanceDir`](缺省)/[`Self::Shared`] / [`Self::VersionDir`]。
/// `custom`(用户手输绝对路径)留在 CLI:GUI 要它就得配目录选择器,不在本 mission 的交付面里。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum AdoptTargetView {
/// 缺省:ULL 数据区新建独立运行目录(`custom` 档,与别人的 `.minecraft` 隔离)
#[default]
InstanceDir,
/// 共用游戏根(别人的 `.minecraft`)——必须明确点选
Shared,
/// 版本目录 `versions/<id>/`(`isolated` 档)——会在别人的版本目录里长玩家数据
VersionDir,
}
impl AdoptTargetView {
/// 三档的声明顺序(= 面板上的先后,缺省在最前)。
pub const ALL: [AdoptTargetView; 3] = [
AdoptTargetView::InstanceDir,
AdoptTargetView::Shared,
AdoptTargetView::VersionDir,
];
/// 稳定 slug(与 core 的 `AdoptTarget::as_str` 同词表;`instance_dir` 不进 CLI 词表)。
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::InstanceDir => "instance_dir",
Self::Shared => "shared",
Self::VersionDir => "version_dir",
}
}
/// 从 slug 反解。
#[must_use]
pub fn from_slug(slug: &str) -> Option<Self> {
Self::ALL.into_iter().find(|target| target.slug() == slug)
}
/// 这一档会不会把玩家数据写进**别人的**目录(界面据此加警示标记)。
#[must_use]
pub const fn touches_foreign_dir(self) -> bool {
matches!(self, Self::Shared | Self::VersionDir)
}
}
/// 生效的隔离档(`core::game::install::instance::Isolation` 的视图形态)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AdoptIsolationView {
/// `versions/<id>/` 当运行目录
Isolated,
/// 游戏根当运行目录
Shared,
/// 指定/新建目录当运行目录(缺省档就是这一档)
Custom,
}
impl AdoptIsolationView {
/// 稳定 slug(与 core 的 `Isolation::as_str` 同一套字面量)。
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::Isolated => "isolated",
Self::Shared => "shared",
Self::Custom => "custom",
}
}
/// 从 slug 反解(未知值返回 `None`,界面照原样上屏而不是猜)。
#[must_use]
pub fn from_slug(slug: &str) -> Option<Self> {
match slug {
"isolated" => Some(Self::Isolated),
"shared" => Some(Self::Shared),
"custom" => Some(Self::Custom),
_ => None,
}
}
}
/// 一次预检/登记的请求(页面表单 → host)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptRequestView {
/// 要接进来的版本 id(= `versions/<id>/` 的目录名)
pub version_id: String,
/// 展示名(`None` / 空串 = 用版本 id)
pub name: Option<String>,
/// 运行目录选择(缺省 [`AdoptTargetView::InstanceDir`])
pub target: AdoptTargetView,
}
impl AdoptRequestView {
/// 最小请求(缺省名 + 缺省运行目录)。
#[must_use]
pub fn new(version_id: impl Into<String>) -> Self {
Self {
version_id: version_id.into(),
name: None,
target: AdoptTargetView::default(),
}
}
}
/// 一条**结构化**的不合格原因(`code` 稳定,`message` 人读)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptProblemView {
/// 稳定机读码(`manifest_missing` / `missing_parent` / `outside_game_root` …)
pub code: String,
/// 人读原因(core 的 `Display` 原文:带路径、行列号、缺哪个父版本)
pub message: String,
}
/// 盘上的一个候选版本。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptCandidateView {
/// 版本 id(= 目录名,登记后写进 `profiles.json`)
pub version_id: String,
/// 客户端 jar 在不在(只报事实,不校验内容)
pub jar_present: bool,
/// `inheritsFrom` 链(自身在前、最祖先在后)
pub inherits: Vec<String>,
/// 已经绑定这个版本的实例名(空 = 还没登记过)
pub registered: Vec<String>,
/// 问题清单(空 = 可登记)
pub problems: Vec<AdoptProblemView>,
}
impl AdoptCandidateView {
/// 能不能登记(没有任何问题)。
#[must_use]
pub fn adoptable(&self) -> bool {
self.problems.is_empty()
}
/// 已登记过(再登记会得到第二个实例,共用同一份版本文件)。
#[must_use]
pub fn is_registered(&self) -> bool {
!self.registered.is_empty()
}
}
/// 一次扫描的结果。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptScanView {
/// 被扫的目录(`<游戏根>/versions`)
pub versions_dir: PathBuf,
/// 候选(按版本 id 字典序)
pub candidates: Vec<AdoptCandidateView>,
}
impl AdoptScanView {
/// 候选条数。
#[must_use]
pub fn total(&self) -> usize {
self.candidates.len()
}
/// 可登记的条数。
#[must_use]
pub fn adoptable(&self) -> usize {
self.candidates
.iter()
.filter(|candidate| candidate.adoptable())
.count()
}
/// `versions/` 存在但一个候选都没有(新数据根,或只剩别的启动器的空目录)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.candidates.is_empty()
}
}
/// 预检结果:落点 + 后果原文 + 将写的展示名。**零写盘**。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptPlanView {
/// 版本 id
pub version_id: String,
/// 将写进档案的展示名(空名已回落到版本 id)
pub name: String,
/// 运行目录选择
pub target: AdoptTargetView,
/// 生效隔离档
pub isolation: AdoptIsolationView,
/// **生效运行目录**(可能是顺延后的 `-2`/`-3`;页面不许自己派生)
pub run_dir: PathBuf,
/// 人读提示(含 `target_notes()` 的原文:共享档/版本目录档会把玩家数据写进别人的目录)
pub notes: Vec<String>,
}
/// 登记完成。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptDoneView {
/// 实例 id(稳定 UUID)
pub instance_id: String,
/// 展示名
pub name: String,
/// 绑定的版本 id
pub version_id: String,
/// 生效隔离档
pub isolation: AdoptIsolationView,
/// 生效运行目录(**唯一来源**:启动时用的就是它)
pub run_dir: PathBuf,
/// 是否新建了缺省运行目录(`false` = 目录已存在但为空、直接复用)
pub dir_created: bool,
/// 人读提示(与预检同一份)
pub notes: Vec<String>,
}
/// 一次失败(整次调用,不是单条候选问题)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptFailureView {
/// 稳定机读码(`adopt_not_adoptable` / `adopt_duplicate_name` / `adopt_target_not_empty` …)
pub code: String,
/// 人读原因(core 的 `Display` 原文)
pub message: String,
/// 候选问题清单(`adopt_not_adoptable` 时逐条给出;其它失败为空)
pub problems: Vec<AdoptProblemView>,
}
/// 向导的**事件态**(`AppState::adopt` 的全部内容)。
///
/// 只有从 [`AdoptEvent`] 来的事实:扫描结果、预检结果、失败、完成。页面的表单字段
/// (选中的版本、展示名输入、目标选择、面板开没开)属于 UI 本地态,**不在这里**——
/// 状态只有一个写入点,才不会有「页面显示的落点和真正写盘的落点不是一回事」。
#[derive(Debug, Clone, PartialEq, Default)]
pub struct AdoptState {
/// 最近一次扫描(`None` = 还没扫过 / 已被新一轮顶掉)
pub scan: Option<AdoptScanView>,
/// 最近一次预检(`None` = 还没预检 / 已过期)
pub plan: Option<AdoptPlanView>,
/// 最近一次失败
pub failure: Option<AdoptFailureView>,
/// 最近一次完成的登记
pub done: Option<AdoptDoneView>,
}
impl AdoptState {
/// 事件 → 状态(**唯一**写入点,与 `PackState::apply` 同一条纪律)。
pub fn apply(&mut self, event: AdoptEvent) {
match event {
AdoptEvent::Scan(scan) => {
// 新一轮扫描:上一轮的预检/失败/完成都作废(候选集合可能已经变了)
self.scan = Some(*scan);
self.plan = None;
self.failure = None;
self.done = None;
}
AdoptEvent::Planned(plan) => {
self.plan = Some(*plan);
self.failure = None;
self.done = None;
}
AdoptEvent::Failed(failure) => {
// 预检失败时上一份预检必须消失:留着会让确认按钮继续亮着
self.plan = None;
self.failure = Some(*failure);
}
AdoptEvent::Done(done) => {
// 登记成功:预检与失败都过期(这一轮已经有结论)
self.plan = None;
self.failure = None;
self.done = Some(*done);
}
AdoptEvent::Cleared => {
self.scan = None;
self.plan = None;
self.failure = None;
self.done = None;
}
}
}
/// 面板此刻该画的那一段(扫描 / 选版本 / 预检 / 结果)。
///
/// 优先级:完成 > 失败 > 预检 > 扫描列表。写成一个方法而不是让页面各判一遍,
/// 是为了「同一时刻只画一段」在单测里可断言。
#[must_use]
pub fn stage(&self) -> AdoptStage {
if self.done.is_some() {
return AdoptStage::Done;
}
if self.failure.is_some() {
return AdoptStage::Failed;
}
if self.plan.is_some() {
return AdoptStage::Preview;
}
AdoptStage::Pick
}
}
/// 面板当前画哪一段([`AdoptState::stage`] 的产物)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AdoptStage {
/// 扫出来的候选列表(选版本)
Pick,
/// 预检结果(落点 + 后果原文 + 确认按钮)
Preview,
/// 失败(原因逐条)
Failed,
/// 完成
Done,
}
impl AdoptStage {
/// 稳定 slug(进日志/测试断言用)。
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::Pick => "pick",
Self::Preview => "preview",
Self::Failed => "failed",
Self::Done => "done",
}
}
}
#[cfg(test)]
mod tests;
+136
View File
@@ -0,0 +1,136 @@
//! `dto::adopt` 的 IPC 契约用例(M223 从 `dto/adopt.rs` **纯搬移**,断言一条未改)。
//!
//! 为什么搬:`dto/adopt.rs` 落笔 512 行,越过 400 行硬线(AGENTS §3.1);口径与
//! [`crate::dto::events::tests`] 完全一致——**纯搬移、一行不改**。`adopt.rs` 是非 `mod.rs`
//! 文件,`mod tests;` 的落点就是本文件。
use super::*;
fn candidate(version_id: &str, problems: Vec<AdoptProblemView>) -> AdoptCandidateView {
AdoptCandidateView {
version_id: version_id.to_string(),
jar_present: true,
inherits: Vec::new(),
registered: Vec::new(),
problems,
}
}
fn scan_view() -> AdoptScanView {
AdoptScanView {
versions_dir: PathBuf::from("/game/versions"),
candidates: vec![
candidate("1.20.1", Vec::new()),
candidate(
"1.19.4",
vec![AdoptProblemView {
code: "manifest_missing".to_string(),
message: "版本 1.19.4 没有清单文件".to_string(),
}],
),
],
}
}
#[test]
fn events_round_trip_through_json() {
let event = AdoptEvent::Failed(Box::new(AdoptFailureView {
code: "adopt_not_adoptable".to_string(),
message: "版本 1.19.4 不能登记".to_string(),
problems: vec![AdoptProblemView {
code: "manifest_missing".to_string(),
message: "清单缺了".to_string(),
}],
}));
let text = serde_json::to_string(&event).expect("序列化");
assert_eq!(
serde_json::from_str::<AdoptEvent>(&text).expect("反序列化"),
event
);
let command = AdoptCommand::Preview(Box::new(AdoptRequestView {
version_id: "1.20.1".to_string(),
name: Some("生存".to_string()),
target: AdoptTargetView::Shared,
}));
let text = serde_json::to_string(&command).expect("序列化");
assert_eq!(
serde_json::from_str::<AdoptCommand>(&text).expect("反序列化"),
command
);
}
#[test]
fn scan_summarises_adoptable_and_empty() {
let view = scan_view();
assert_eq!(view.total(), 2);
assert_eq!(view.adoptable(), 1);
assert!(!view.is_empty());
assert!(AdoptScanView {
versions_dir: PathBuf::from("/game/versions"),
candidates: Vec::new(),
}
.is_empty());
}
#[test]
fn target_slugs_round_trip_and_mark_foreign_dirs() {
for target in AdoptTargetView::ALL {
assert_eq!(AdoptTargetView::from_slug(target.slug()), Some(target));
}
assert_eq!(AdoptTargetView::default(), AdoptTargetView::InstanceDir);
assert!(!AdoptTargetView::InstanceDir.touches_foreign_dir());
assert!(AdoptTargetView::Shared.touches_foreign_dir());
assert!(AdoptTargetView::VersionDir.touches_foreign_dir());
}
#[test]
fn state_follows_the_four_stages() {
let mut state = AdoptState::default();
assert_eq!(state.stage(), AdoptStage::Pick);
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
assert_eq!(state.stage(), AdoptStage::Pick);
assert_eq!(state.scan.as_ref().map(AdoptScanView::adoptable), Some(1));
let plan = AdoptPlanView {
version_id: "1.20.1".to_string(),
name: "1.20.1".to_string(),
target: AdoptTargetView::InstanceDir,
isolation: AdoptIsolationView::Custom,
run_dir: PathBuf::from("/ull/data/instances/1.20.1"),
notes: vec!["运行目录 = /ull/data/instances/1.20.1".to_string()],
};
state.apply(AdoptEvent::Planned(Box::new(plan.clone())));
assert_eq!(state.stage(), AdoptStage::Preview);
assert_eq!(
state.plan.as_ref().map(|plan| &plan.run_dir),
Some(&plan.run_dir)
);
// 失败会把预检顶掉:留着预检,确认按钮就会在「不能登记」的结论上继续亮着
state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView {
code: "adopt_duplicate_name".to_string(),
message: "已有同名实例".to_string(),
problems: Vec::new(),
})));
assert_eq!(state.stage(), AdoptStage::Failed);
assert!(state.plan.is_none());
state.apply(AdoptEvent::Done(Box::new(AdoptDoneView {
instance_id: "id-1".to_string(),
name: "1.20.1".to_string(),
version_id: "1.20.1".to_string(),
isolation: AdoptIsolationView::Custom,
run_dir: PathBuf::from("/ull/data/instances/1.20.1"),
dir_created: true,
notes: Vec::new(),
})));
assert_eq!(state.stage(), AdoptStage::Done);
assert!(state.failure.is_none());
// 关面板 = 全清(再开要重新扫,否则画的是上一轮的候选)
state.apply(AdoptEvent::Cleared);
assert_eq!(state.stage(), AdoptStage::Pick);
assert!(state.scan.is_none() && state.done.is_none());
}
+10 -79
View File
@@ -12,6 +12,8 @@ use crate::dto::{
// 主线(M109 的 Content* / M113 的 Pack*)与 M114 的 Authlib*/AccountNotice 的并集。 // 主线(M109 的 Content* / M113 的 Pack*)与 M114 的 Authlib*/AccountNotice 的并集。
AccountNotice, AccountNotice,
AccountView, AccountView,
AdoptCommand,
AdoptEvent,
AgentEvent, AgentEvent,
AuthlibLoginView, AuthlibLoginView,
AuthlibRequest, AuthlibRequest,
@@ -149,6 +151,11 @@ pub enum CoreEvent {
// M113: import/export // M113: import/export
/// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`]) /// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`])
Pack(Box<PackEvent>), Pack(Box<PackEvent>),
/// 导入已有版本(M223):扫描 / 预检 / 完成 / 失败(子枚举见 [`AdoptEvent`])
///
/// 与 [`Self::Pack`] 分开是**语义**上必须的:整合包导入会把内容真搬进实例目录,
/// 而这里只登记盘上已有的版本、写两份配置(`versions/` 一个字节都不动)。
Adopt(Box<AdoptEvent>),
/// 自更新(M159):整份状态快照(检查结论 + 就位状态 + 用户是否已拒绝)。 /// 自更新(M159):整份状态快照(检查结论 + 就位状态 + 用户是否已拒绝)。
/// ///
/// 与 `SettingsChanged` 同一条形态:快照整体替换,UI 不做增量合并。 /// 与 `SettingsChanged` 同一条形态:快照整体替换,UI 不做增量合并。
@@ -263,6 +270,8 @@ pub enum AppCommand {
// M113: import/export // M113: import/export
/// 整合包导入/导出(M113):导入 / 预检 / 确认写盘 / 关面板(子枚举见 [`PackCommand`]) /// 整合包导入/导出(M113):导入 / 预检 / 确认写盘 / 关面板(子枚举见 [`PackCommand`])
Pack(Box<PackCommand>), Pack(Box<PackCommand>),
/// 导入已有版本(M223):扫描 / 预检 / 确认登记 / 关面板(子枚举见 [`AdoptCommand`])
Adopt(Box<AdoptCommand>),
// M159: 自更新 // M159: 自更新
/// 查一次更新(设置页「立即检查」;会清掉「忽略这个版本」的标记并重新请求网络) /// 查一次更新(设置页「立即检查」;会清掉「忽略这个版本」的标记并重新请求网络)
CheckUpdate, CheckUpdate,
@@ -318,82 +327,4 @@ pub trait Backend {
} }
#[cfg(test)] #[cfg(test)]
mod tests { 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::<AppCommand>(&serde_json::to_string(&login).expect("序列化"))
.expect("反序列化"),
login
);
assert!(DeviceLoginPhase::Waiting.busy());
// 第三方轨(M114):请求进、状态出,两边都不带 token 字段
let authlib = AppCommand::AddAuthlibAccount(AuthlibRequest::new(
"https://littleskin.cn/api/yggdrasil/",
"me@example.com",
"hunter2",
));
let text = serde_json::to_string(&authlib).expect("序列化");
assert_eq!(
serde_json::from_str::<AppCommand>(&text).expect("反序列化"),
authlib
);
let view = AppEvent::Core(CoreEvent::AuthlibLoginChanged(AuthlibLoginView::reachable(
"https://littleskin.cn/api/yggdrasil/",
"LittleSkin",
)));
let text = serde_json::to_string(&view).expect("序列化");
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, view);
assert!(
!text.to_lowercase().contains("token"),
"回程视图里不该有 token"
);
}
}
+87
View File
@@ -0,0 +1,87 @@
//! `dto::events` 的 IPC 契约用例(M223 从 `events.rs` 尾部**纯搬移**,一行未改)。
//!
//! 为什么搬:M223 要给 `AppCommand` / `CoreEvent` 各加一条变体(「导入已有版本」的指令与事件),
//! 而 `events.rs` 当时正好 399 行——加两行变体 + 两行 import 就顶破 400 行硬线(AGENTS §3.1)。
//! 拆分口径与仓库其它拆分一致:**纯搬移、一行不改**,`mod tests;` 只是把位置挪到
//! `dto/events/tests.rs`(`events.rs` 是非 `mod.rs` 文件,子模块目录就是 `events/`)。
//!
//! 搬移后 `events.rs` 只剩契约本体(枚举 + 构造器 + `Backend` trait),本文件的断言一条没动,
//! 所以「搬移零行为变化」可以用逐行 diff 对。
use super::*;
use crate::dto::{AccountKind, DeviceLoginPhase, InstallStage};
#[test]
fn events_are_serializable_ipc_contracts() {
let event = AppEvent::Core(CoreEvent::InstallProgress(Some(InstallProgressView {
stage: InstallStage::Downloading,
files_done: 3,
files_total: 9,
..InstallProgressView::new()
})));
let text = serde_json::to_string(&event).expect("序列化");
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, event);
let account = AppEvent::Core(CoreEvent::AccountsChanged(vec![AccountView {
id: "account:1".to_string(),
name: "Steve".to_string(),
kind: AccountKind::Offline,
profile_id: "0f8f".to_string(),
login_name: String::new(),
created: "2026-10-31 20:12".to_string(),
active: true,
server_url: String::new(),
skin: None,
}]));
let text = serde_json::to_string(&account).expect("序列化");
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, account);
// 凭据永不跨界:序列化文本里没有 token 类字段
assert!(!text.to_lowercase().contains("token"));
}
#[test]
fn commands_round_trip() {
let command = AppCommand::CreateInstance(CreateRequest {
version: "1.20.4".to_string(),
loader: crate::dto::LoaderKind::Fabric,
loader_version: "0.16.5".to_string(),
name: "生存".to_string(),
});
let text = serde_json::to_string(&command).expect("序列化");
let back: AppCommand = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, command);
let login = AppCommand::StartDeviceLogin;
assert_eq!(
serde_json::from_str::<AppCommand>(&serde_json::to_string(&login).expect("序列化"))
.expect("反序列化"),
login
);
assert!(DeviceLoginPhase::Waiting.busy());
// 第三方轨(M114):请求进、状态出,两边都不带 token 字段
let authlib = AppCommand::AddAuthlibAccount(AuthlibRequest::new(
"https://littleskin.cn/api/yggdrasil/",
"me@example.com",
"hunter2",
));
let text = serde_json::to_string(&authlib).expect("序列化");
assert_eq!(
serde_json::from_str::<AppCommand>(&text).expect("反序列化"),
authlib
);
let view = AppEvent::Core(CoreEvent::AuthlibLoginChanged(AuthlibLoginView::reachable(
"https://littleskin.cn/api/yggdrasil/",
"LittleSkin",
)));
let text = serde_json::to_string(&view).expect("序列化");
let back: AppEvent = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, view);
assert!(
!text.to_lowercase().contains("token"),
"回程视图里不该有 token"
);
}
+7
View File
@@ -15,6 +15,8 @@
//! 序列化能力由 derive 钉住,`dto` 的单测做 round-trip 断言。 //! 序列化能力由 derive 钉住,`dto` 的单测做 round-trip 断言。
mod accounts; mod accounts;
// M223: 导入已有版本(从别的启动器只读登记 versions/<id>)
mod adopt;
mod agent; mod agent;
mod content; mod content;
mod events; mod events;
@@ -38,6 +40,11 @@ pub use accounts::{
AccountView, AuthlibLoginView, AuthlibRequest, BrowserLoginNote, DeviceLoginPhase, AccountView, AuthlibLoginView, AuthlibRequest, BrowserLoginNote, DeviceLoginPhase,
DeviceLoginView, LoginFailure, SkinImage, REDACTED, 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 agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView};
pub use content::{ pub use content::{
loader_facet, ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind, loader_facet, ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind,
+261
View File
@@ -0,0 +1,261 @@
//! 「导入已有版本」的宿主侧(M223 GUIADOPT):扫描 / 预检 / 确认三条后台线程。
//!
//! 能力全在 core(`unlucky_core::game::adopt`,M221):本模块只做三件事——**起线程、翻 DTO、
//! 按代次回流**。这里不重新判定任何东西:能不能登记、落到哪个运行目录、有哪些问题,全部由 core
//! 说了算,本模块逐字段照搬([`scan_view`] / [`plan_view`] / [`done_view`] / [`failure_view`])。
//!
//! ## 三个命令与写盘面
//!
//! | 命令 | core 调用 | 写盘 |
//! |---|---|---|
//! | [`AdoptCommand::Scan`] | `adopt::scan` | 零 |
//! | [`AdoptCommand::Preview`] | `adopt::plan` | 零(预检就是 core 的 dry-run) |
//! | [`AdoptCommand::Confirm`] | `adopt::adopt` | 实例设置 + `profiles.json`(+ 缺省运行目录) |
//!
//! 预检与确认走**同一个** `AdoptRequestView`:页面在预检拿到落点与后果原文,用户点确认时把
//! **同一份请求**发回来(不是「预检 A、写 B」)。这也是「运行目录只认回程」的实现方式——
//! 页面从头到尾没有机会自己派生一个目录名。
//!
//! ## 代次:为什么要它
//!
//! 三条命令各起一条线程,用户可能连点(扫完再扫、预检后改目标再预检、失败后关面板)。
//! 每条线程带一个代次回来,`adopt_epoch` 只认**最新**那一条:关面板([`AdoptCommand::Dismiss`])
//! 也会把代次 +1,于是「点了关闭,两秒后扫描结果又把面板点亮」这种旧病不会发生
//! (它与 M105 安装取消、M113 导入取消是同一套做法)。
//!
//! ## 不做什么
//!
//! - **不自己派生运行目录**:`run_dir` 只从 `plan`/`adopt` 的返回里取(缺省档可能带 `-2` 后缀);
//! - **不自己拼后果话术**:`notes` 原样来自 core 的 `target_notes()`,与 CLI 打的是同一句;
//! - **不折叠问题**:`problems[]` 逐条搬进 [`AdoptFailureView`],一条都不省。
use unlucky_core::game::adopt::{
self, AdoptCandidate, AdoptError, AdoptOutcome, AdoptPlan, AdoptProblem, AdoptRequest,
AdoptTarget,
};
use unlucky_core::game::install::instance::Isolation;
use unlucky_core::launch::time::{format_rfc3339_millis, now};
use crate::dto::{
AdoptCandidateView, AdoptCommand, AdoptDoneView, AdoptEvent, AdoptFailureView,
AdoptIsolationView, AdoptPlanView, AdoptProblemView, AdoptRequestView, AdoptScanView,
AdoptTargetView, CoreEvent,
};
use super::env::HostEnv;
use super::job::{self, HostTask, JobSender};
use super::CoreHost;
/// 后台那一步是「只看不写」还是「真的写」。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Mode {
/// `adopt::plan`(预检,零写盘)
Preview,
/// `adopt::adopt`(真写)
Confirm,
}
impl CoreHost {
/// 作废在飞的 adopt 结果,返回新代次(每条命令都调它,见模块文档「代次」一节)。
fn next_adopt_epoch(&mut self) -> u64 {
self.adopt_epoch = self.adopt_epoch.wrapping_add(1);
self.adopt_epoch
}
/// `AppCommand::Adopt` 的落点(M223)。
///
/// 每条命令都先把代次 +1:这一条之后回来的旧结果一律被 [`Self::handle_adopt`] 丢掉。
pub(super) fn adopt(&mut self, command: AdoptCommand) {
match command {
AdoptCommand::Scan => {
let epoch = self.next_adopt_epoch();
spawn_scan(self.env.clone(), self.tx.clone(), epoch);
}
AdoptCommand::Preview(request) => {
let epoch = self.next_adopt_epoch();
spawn_adopt(
self.env.clone(),
self.tx.clone(),
*request,
Mode::Preview,
epoch,
);
}
AdoptCommand::Confirm(request) => {
let epoch = self.next_adopt_epoch();
spawn_adopt(
self.env.clone(),
self.tx.clone(),
*request,
Mode::Confirm,
epoch,
);
}
AdoptCommand::Dismiss => {
// 关面板 = 作废在飞的一切(否则迟到的扫描结果会把面板重新点亮)
self.next_adopt_epoch();
self.send(CoreEvent::Adopt(Box::new(AdoptEvent::Cleared)));
}
}
}
/// 后台线程的回流落点:代次不符就丢(见模块文档「代次」一节)。
///
/// 登记成功之后补一次实例快照刷新:新实例必须**这一拍就出现在实例列表里**,
/// 而不是等用户手点刷新(否则「导入了但看不见」)。
pub(super) fn handle_adopt(&mut self, event: AdoptEvent, epoch: u64) {
if epoch != self.adopt_epoch {
return;
}
let registered = matches!(event, AdoptEvent::Done(_));
self.send(CoreEvent::Adopt(Box::new(event)));
if registered {
self.refresh_instances();
}
}
}
/// 扫描当前游戏根的 `versions/`(只读)。
pub(super) fn spawn_scan(env: HostEnv, tx: JobSender, epoch: u64) {
job::spawn("adopt-scan", move || {
let event = match adopt::scan(&env.game, &env.layout) {
Ok(scan) => AdoptEvent::Scan(Box::new(AdoptScanView {
versions_dir: scan.versions_dir.clone(),
candidates: scan.candidates.iter().map(candidate_view).collect(),
})),
// 读目录失败也是**具名**失败(断链的 versions 联接 / 权限),不降级成空列表
Err(error) => AdoptEvent::Failed(Box::new(failure_view(error))),
};
let _ = tx.send(HostTask::Adopt {
event: Box::new(event),
epoch,
});
});
}
/// 预检(零写盘)或确认登记。
pub(super) fn spawn_adopt(
env: HostEnv,
tx: JobSender,
request: AdoptRequestView,
mode: Mode,
epoch: u64,
) {
job::spawn("adopt", move || {
let request = core_request(request);
let event = match mode {
Mode::Preview => match adopt::plan(&env.game, &env.layout, &request) {
Ok(plan) => AdoptEvent::Planned(Box::new(plan_view(plan))),
Err(error) => AdoptEvent::Failed(Box::new(failure_view(error))),
},
Mode::Confirm => match adopt::adopt(&env.game, &env.layout, &request) {
Ok(outcome) => AdoptEvent::Done(Box::new(done_view(outcome))),
Err(error) => AdoptEvent::Failed(Box::new(failure_view(error))),
},
};
let _ = tx.send(HostTask::Adopt {
event: Box::new(event),
epoch,
});
});
}
/// 视图请求 → core 请求(`created_at` 由宿主给:core 不碰时钟)。
fn core_request(view: AdoptRequestView) -> AdoptRequest {
AdoptRequest {
version_id: view.version_id,
name: view.name.filter(|name| !name.trim().is_empty()),
target: target_of(view.target),
created_at: Some(format_rfc3339_millis(now())),
}
}
/// 三档视图 → core 的运行目录选择。
///
/// `custom` **不映射**:GUI 不开「用户手输绝对路径」这一档(那要目录选择器),
/// 需要它的调用方走 CLI 的 `ull instances adopt --isolation custom --game-dir DIR`。
fn target_of(view: AdoptTargetView) -> AdoptTarget {
match view {
AdoptTargetView::InstanceDir => AdoptTarget::InstanceDir,
AdoptTargetView::Shared => AdoptTarget::Shared,
AdoptTargetView::VersionDir => AdoptTarget::VersionDir,
}
}
/// core 隔离档 → 视图。
fn isolation_view(isolation: Isolation) -> AdoptIsolationView {
match isolation {
Isolation::Isolated => AdoptIsolationView::Isolated,
Isolation::Shared => AdoptIsolationView::Shared,
Isolation::Custom => AdoptIsolationView::Custom,
}
}
/// core 候选 → 视图(`registered` 只带实例名:界面要的是「给谁登记过了」)。
fn candidate_view(candidate: &AdoptCandidate) -> AdoptCandidateView {
AdoptCandidateView {
version_id: candidate.version_id.clone(),
jar_present: candidate.jar_present,
inherits: candidate.inherits.clone(),
registered: candidate
.registered
.iter()
.map(|instance| instance.name.clone())
.collect(),
problems: candidate.problems.iter().map(problem_view).collect(),
}
}
/// core 问题 → 视图(`code` 稳定 + `Display` 原文:路径、行列号、缺哪个父版本都在里面)。
fn problem_view(problem: &AdoptProblem) -> AdoptProblemView {
AdoptProblemView {
code: problem.code().to_string(),
message: problem.to_string(),
}
}
/// core 计划 → 视图。`run_dir` / `notes` **原样**搬运,界面不许再算一遍。
fn plan_view(plan: AdoptPlan) -> AdoptPlanView {
AdoptPlanView {
version_id: plan.candidate.version_id.clone(),
name: plan.name,
target: target_view(&plan.target),
isolation: isolation_view(plan.isolation),
run_dir: plan.run_dir,
notes: plan.notes,
}
}
/// core 结果 → 视图。
fn done_view(outcome: AdoptOutcome) -> AdoptDoneView {
AdoptDoneView {
instance_id: outcome.instance_id,
name: outcome.name,
version_id: outcome.version_id,
isolation: isolation_view(outcome.isolation),
run_dir: outcome.run_dir,
dir_created: outcome.dir_created,
notes: outcome.notes,
}
}
/// core 运行目录选择 → 视图(`Custom` 只在 core 侧出现,界面按「缺省档」显示)。
fn target_view(target: &AdoptTarget) -> AdoptTargetView {
match target {
AdoptTarget::InstanceDir | AdoptTarget::Custom(_) => AdoptTargetView::InstanceDir,
AdoptTarget::Shared => AdoptTargetView::Shared,
AdoptTarget::VersionDir => AdoptTargetView::VersionDir,
}
}
/// core 失败 → 视图:**`problems[]` 一条不少地带出来**(契约 §5.3)。
fn failure_view(error: AdoptError) -> AdoptFailureView {
AdoptFailureView {
code: error.code().to_string(),
message: error.to_string(),
problems: error.problems().iter().map(problem_view).collect(),
}
}
#[cfg(test)]
mod tests;
+217
View File
@@ -0,0 +1,217 @@
//! `host::adopt` 的用例(M223):真盘夹具 + 三条命令的回程形状。
//!
//! 抓得住:扫描只读(`.ULL/config` 不出现)、预检与确认的落点一致且都来自 core、
//! 坏清单失败时 `problems[]` 逐条带出(带行列号)、三档映射。
//! 抓不住:真启动游戏用不用这个运行目录(那是 launch 链路的活);D30 视觉(另走截图通道)。
//!
//! 为什么单独一个文件:`host/adopt.rs` 带用例会到 440 行,越过 400 行硬线(AGENTS §3.1)。
use super::*;
use crate::dto::AdoptStage;
/// 真盘夹具:`<root>/.minecraft/versions/<id>/`(清单 + jar)+ `<root>/.ULL`。
struct Fixture {
root: std::path::PathBuf,
}
impl Fixture {
fn new(tag: &str) -> Self {
let root = std::env::temp_dir().join(format!(
"ull-presenter-adopt-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
));
std::fs::create_dir_all(&root).expect("建临时根");
Self { root }
}
fn env(&self) -> HostEnv {
HostEnv::at_root(&self.root)
}
/// 铺一个「别的启动器装好的」版本(`manifest` 原样写,便于造坏清单)。
fn version(&self, id: &str, manifest: &str) {
let dir = self.root.join(".minecraft/versions").join(id);
std::fs::create_dir_all(&dir).expect("建版本目录");
std::fs::write(dir.join(format!("{id}.json")), manifest).expect("写清单");
std::fs::write(dir.join(format!("{id}.jar")), b"jar-bytes").expect("写 jar");
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
const MANIFEST: &str = r#"{
"id": "1.20.1",
"type": "release",
"mainClass": "net.minecraft.main.Main",
"libraries": []
}"#;
fn request(version_id: &str, target: AdoptTargetView) -> AdoptRequestView {
AdoptRequestView {
version_id: version_id.to_string(),
name: None,
target,
}
}
/// 收一条 `HostTask::Adopt`(同步等后台线程交卷)。
fn recv(rx: &super::super::job::JobReceiver) -> AdoptEvent {
match rx.recv().expect("后台线程应回一条") {
HostTask::Adopt { event, .. } => *event,
other => panic!("期望 HostTask::Adopt,收到 {other:?}"),
}
}
#[test]
fn scan_lists_the_foreign_versions_without_writing() {
let fixture = Fixture::new("scan");
fixture.version("1.20.1", MANIFEST);
let env = fixture.env();
let (tx, rx) = job::channel();
spawn_scan(env.clone(), tx, 1);
let AdoptEvent::Scan(scan) = recv(&rx) else {
panic!("扫描应回 AdoptEvent::Scan");
};
assert_eq!(scan.versions_dir, fixture.root.join(".minecraft/versions"));
assert_eq!(scan.total(), 1);
assert_eq!(scan.adoptable(), 1);
assert!(scan.candidates[0].jar_present);
// 扫描零写盘:`.ULL/config` 下不该出现任何东西
assert!(!fixture.root.join(".ULL/config/profiles.json").exists());
}
#[test]
fn confirm_registers_and_reports_the_core_run_dir() {
let fixture = Fixture::new("confirm");
fixture.version("1.20.1", MANIFEST);
let env = fixture.env();
// 先预检:零写盘,落点与后果原文都来自 core
let (tx, rx) = job::channel();
spawn_adopt(
env.clone(),
tx,
request("1.20.1", AdoptTargetView::InstanceDir),
Mode::Preview,
1,
);
let AdoptEvent::Planned(plan) = recv(&rx) else {
panic!("预检应回 AdoptEvent::Planned");
};
assert_eq!(plan.target, AdoptTargetView::InstanceDir);
assert_eq!(plan.isolation, AdoptIsolationView::Custom);
// 缺省档的运行目录落数据区(**不是** `versions/<id>`)
assert!(
plan.run_dir
.starts_with(fixture.root.join(".ULL/data/instances")),
"落点应在 ULL 数据区:{}",
plan.run_dir.display()
);
assert!(!plan.notes.is_empty(), "缺省档也要说清后果");
assert!(!fixture.root.join(".ULL/config/profiles.json").exists());
// 确认:同一份请求发回去,真的登记
let (tx, rx) = job::channel();
spawn_adopt(
env,
tx,
request("1.20.1", AdoptTargetView::InstanceDir),
Mode::Confirm,
2,
);
let AdoptEvent::Done(done) = recv(&rx) else {
panic!("确认应回 AdoptEvent::Done");
};
assert_eq!(done.version_id, "1.20.1");
assert_eq!(done.run_dir, plan.run_dir, "确认的落点必须与预检一致");
assert!(done.dir_created);
let profiles = std::fs::read_to_string(fixture.root.join(".ULL/config/profiles.json"))
.expect("profiles.json 应被写出来");
assert!(profiles.contains("1.20.1"));
// 游戏根的版本目录一个字节都没写(只有夹具自己那两个文件)
let dir = fixture.root.join(".minecraft/versions/1.20.1");
let mut names: Vec<String> = std::fs::read_dir(&dir)
.expect("读版本目录")
.map(|entry| {
entry
.expect("条目")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect();
names.sort();
assert_eq!(names, ["1.20.1.jar", "1.20.1.json"]);
}
#[test]
fn a_bad_manifest_comes_back_as_every_problem_not_one_sentence() {
let fixture = Fixture::new("problems");
fixture.version("1.20.1", "{ this is not json");
let (tx, rx) = job::channel();
spawn_adopt(
fixture.env(),
tx,
request("1.20.1", AdoptTargetView::InstanceDir),
Mode::Preview,
1,
);
let AdoptEvent::Failed(failure) = recv(&rx) else {
panic!("坏清单应回 AdoptEvent::Failed");
};
assert_eq!(failure.code, "adopt_not_adoptable");
assert_eq!(failure.problems.len(), 1);
assert_eq!(failure.problems[0].code, "manifest_invalid_json");
assert!(
failure.problems[0].message.contains("line"),
"清单问题要带行列号:{}",
failure.problems[0].message
);
}
#[test]
fn targets_map_to_core_and_custom_stays_out_of_the_gui() {
assert_eq!(
target_of(AdoptTargetView::InstanceDir),
AdoptTarget::InstanceDir
);
assert_eq!(target_of(AdoptTargetView::Shared), AdoptTarget::Shared);
assert_eq!(
target_of(AdoptTargetView::VersionDir),
AdoptTarget::VersionDir
);
// `Custom` 只在 core 侧出现(CLI 的 `--game-dir`):界面按缺省档显示
assert_eq!(
target_view(&AdoptTarget::Custom(std::path::PathBuf::from("/x"))),
AdoptTargetView::InstanceDir
);
assert_eq!(
isolation_view(Isolation::Custom),
AdoptIsolationView::Custom
);
assert_eq!(
isolation_view(Isolation::Shared),
AdoptIsolationView::Shared
);
assert_eq!(
isolation_view(Isolation::Isolated),
AdoptIsolationView::Isolated
);
// 空名回落到版本 id 由 core 负责;这里只钉「空白名不进请求」
let request = core_request(AdoptRequestView {
version_id: "1.20.1".to_string(),
name: Some(" ".to_string()),
target: AdoptTargetView::Shared,
});
assert_eq!(request.name, None);
assert!(request.created_at.is_some());
assert_eq!(AdoptStage::Pick.slug(), "pick");
}
+2
View File
@@ -253,6 +253,8 @@ impl CoreHost {
// (`CancelInstall` 会把标记置位,下载在阶段边界收尾)。 // (`CancelInstall` 会把标记置位,下载在阶段边界收尾)。
// M113: import/export —— 导入 / 预检 / 确认写盘 / 关面板 // M113: import/export —— 导入 / 预检 / 确认写盘 / 关面板
AppCommand::Pack(command) => self.pack(*command), AppCommand::Pack(command) => self.pack(*command),
// M223: 导入已有版本 —— 扫描 / 预检 / 确认登记 / 关面板
AppCommand::Adopt(command) => self.adopt(*command),
AppCommand::InstallContent(request) => { AppCommand::InstallContent(request) => {
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
self.install_cancel = Some(std::sync::Arc::clone(&cancel)); self.install_cancel = Some(std::sync::Arc::clone(&cancel));
+1
View File
@@ -69,6 +69,7 @@ impl CoreHost {
// M105 的形状(代次丢包)为准:旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉。 // M105 的形状(代次丢包)为准:旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉。
// 这是「点了取消没反应」的根因:取消时 `InstallProgress(None)` 刚把面板收回, // 这是「点了取消没反应」的根因:取消时 `InstallProgress(None)` 刚把面板收回,
// 旧任务的下一条「下载中」事件立刻又把它点亮。 // 旧任务的下一条「下载中」事件立刻又把它点亮。
HostTask::Adopt { event, epoch } => self.handle_adopt(*event, epoch),
HostTask::Install { progress, epoch } => { HostTask::Install { progress, epoch } => {
if epoch != self.install_epoch { if epoch != self.install_epoch {
return; return;
+1 -1
View File
@@ -106,7 +106,7 @@ pub fn supported(kind: LoaderKind) -> bool {
/// 不支持的加载器 → 给用户的可执行建议。 /// 不支持的加载器 → 给用户的可执行建议。
pub fn unsupported_reason(kind: LoaderKind) -> String { pub fn unsupported_reason(kind: LoaderKind) -> String {
format!( format!(
"{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge/LiteLoader);请先在实例页导入已有版本,或换成受支持的加载器。", "{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge/LiteLoader);如果别的启动器已经装好了这个版本,可以在实例页用「导入已有版本」把它只读接进来(不复制文件),否则请换成受支持的加载器。",
kind.label() kind.label()
) )
} }
+11
View File
@@ -68,6 +68,17 @@ pub enum HostTask {
/// 任务代次 /// 任务代次
epoch: u64, epoch: u64,
}, },
// M223: 导入已有版本
/// 扫描 / 预检 / 确认的结果(一条事件 + 任务代次)
///
/// 代次是 adopt 自己的(`CoreHost::adopt_epoch`),**不**与安装或 pack 的代次共用:
/// 关一次导入面板不该把下载页在跑的安装静默丢掉。
Adopt {
/// 事件载荷(扫描 / 预检 / 完成 / 失败)
event: Box<crate::dto::AdoptEvent>,
/// 任务代次
epoch: u64,
},
/// 安装进度(高频,host 只做聚合与转发) /// 安装进度(高频,host 只做聚合与转发)
Install { Install {
/// 进度快照 /// 进度快照
+7
View File
@@ -14,6 +14,8 @@
mod accounts; mod accounts;
mod addon; mod addon;
// M223:导入已有版本(扫描 / 预检 / 确认三条后台线程)
mod adopt;
mod dispatch; mod dispatch;
mod engine; mod engine;
pub mod env; pub mod env;
@@ -106,6 +108,10 @@ pub struct CoreHost {
/// 一眼能看出「这是旧任务的」,于是既不会被取消掉的安装重新点亮面板,也不会串到下一轮安装 /// 一眼能看出「这是旧任务的」,于是既不会被取消掉的安装重新点亮面板,也不会串到下一轮安装
/// (两轮安装同时存在时,旧任务的事件曾经把新任务的进度条顶掉)。 /// (两轮安装同时存在时,旧任务的事件曾经把新任务的进度条顶掉)。
install_epoch: u64, install_epoch: u64,
/// 「导入已有版本」的代次(M223):每条 scan/preview/confirm 命令 +1,关面板也 +1。
///
/// 只认最新代次的回程事件——用户连点或点了关闭之后,旧线程带回来的结果不许再把面板点亮。
adopt_epoch: u64,
/// 自更新(M159):检查结论 / 应用状态 / 更新域偏好。 /// 自更新(M159):检查结论 / 应用状态 / 更新域偏好。
update: update_state::Runtime, update: update_state::Runtime,
} }
@@ -160,6 +166,7 @@ impl CoreHost {
chat: MockBackend::default(), chat: MockBackend::default(),
install_cancel: None, install_cancel: None,
install_epoch: 0, install_epoch: 0,
adopt_epoch: 0,
update, update,
}; };
if refresh { if refresh {
+8 -26
View File
@@ -8,7 +8,7 @@ use crate::dialogs::DialogQueue;
use crate::dto::{ use crate::dto::{
// 主线(M105 的 `LoaderKind` / M113 的 `PackState`)与 M114 的 `AuthlibLoginView` 并集。 // 主线(M105 的 `LoaderKind` / M113 的 `PackState`)与 M114 的 `AuthlibLoginView` 并集。
AccountView, AccountView,
AgentEvent, AdoptState,
AppEvent, AppEvent,
AuthlibLoginView, AuthlibLoginView,
CoreEvent, CoreEvent,
@@ -42,6 +42,8 @@ use crate::wizard::WizardState;
// 所以这里没有 `mod ai;` / `pub use ai::AiSession;`——M114 r1 曾把旧结构搬到 `state/ai.rs`, // 所以这里没有 `mod ai;` / `pub use ai::AiSession;`——M114 r1 曾把旧结构搬到 `state/ai.rs`,
// 该文件已随 rebase 删除,**以 main 的 agent 契约为准**。 // 该文件已随 rebase 删除,**以 main 的 agent 契约为准**。
mod accounts; mod accounts;
// M223:AI 栏的事件投递与可见性(纯搬移,守 400 行硬线)
mod agent;
mod live; mod live;
mod tiles; mod tiles;
@@ -132,6 +134,8 @@ pub struct AppState {
pub content: ContentState, pub content: ContentState,
/// 整合包导入/导出:进度 / 结果 / 探测诊断 / 导出预检(M113) /// 整合包导入/导出:进度 / 结果 / 探测诊断 / 导出预检(M113)
pub pack: PackState, pub pack: PackState,
/// 导入已有版本(M223):扫描 / 预检 / 失败 / 完成(**只装事件态**,表单在页面本地态)
pub adopt: AdoptState,
} }
impl Default for AppState { impl Default for AppState {
@@ -177,6 +181,7 @@ impl AppState {
live_session: None, live_session: None,
content: ContentState::default(), content: ContentState::default(),
pack: PackState::default(), pack: PackState::default(),
adopt: AdoptState::default(),
} }
} }
@@ -295,6 +300,8 @@ impl AppState {
CoreEvent::Update(view) => self.update = *view, CoreEvent::Update(view) => self.update = *view,
// M113: import/export // M113: import/export
CoreEvent::Pack(event) => self.pack.apply(*event), 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::ContentResults(results) => self.apply_content(*results),
CoreEvent::ContentFailed(failure) => self.apply_content_failure(*failure), CoreEvent::ContentFailed(failure) => self.apply_content_failure(*failure),
// M162:详情/版本列表(判断逻辑在 `state/content.rs`,这里只有一行投递) // M162:详情/版本列表(判断逻辑在 `state/content.rs`,这里只有一行投递)
@@ -304,31 +311,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<TileCell<'_>> { pub fn tiles(&self) -> Vec<TileCell<'_>> {
let query = self.instance_query.trim().to_lowercase(); let query = self.instance_query.trim().to_lowercase();
+39
View File
@@ -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
}
}