fix(m161): 收更新键/按钮/矮窗口/启动错误/导入结果五笔债

① update.auto_check / update.channel 登记进 core setting schema(1.6.0)
   - 新增 setting/keys/update.rs(两键 + UPDATE_CHANNELS,只允许 stable);
     SettingSchema 加 with_all 供域键表批量登记。
   - 新增 setting/update_prefs.rs:cache/update/prefs.json → launcher.json 的一次性迁移。
     四条硬约束:源指纹闸门(幂等)、不丢 false、新值胜出、旧文件改名 .migrated 后不再被读;
     主配置不可写时不动旧文件。收据 config/update-prefs-migration.json。
   - presenter Runtime 建会话时迁一次并把结局留在 migration();prefs 字段换成
     auto_check + 每次派发从主配置读 channel(不再硬编码 Channel::Stable)。
     保留旧字段 auto_check 的真实来源(原有测试 cases 会断言)。
   - CLI config get/set/dump 先跑同一次迁移(幂等),输出里给 migration 段与收据路径。

② presenter/dto/events.rs 的「本次会话不再弹提示条」注释改成真实语义
   (记的是版本号:同版本压住、更高版本照弹、手动检查清掉)。

③ 检查/下载进行中按钮禁用:pages/update/busy.rs 的 busy/can_check/can_apply;
   设置页两颗按钮改画禁用(不是消失),app 层 on_update_action 再挡一道
   (忙碌时 CheckNow/Apply 不派发);新增两条用例(含「忙完恢回可点」)。

④ 内容库低高度:content_layout::MIN_BODY_HEIGHT=460 + BodyMode(按固定件+三整行算出来,
   用例守着算式);frame::body_height 从 Ui::metrics 算正文可用高,pages::Model 带 window_height。
   ≥460 列表自己滚;<460 主列整列一个滚动条、列表按自然高度铺开。

⑤ 启动错误文案本地化:presenter 删掉 launch_summary / 「启动失败:」前缀 / 五条中文阶段前缀,
   改发 LaunchFailureView{stage,detail};LaunchFinished 改带 exit_code/uptime_ms/failure,
   Toast 由 app 层按 i18n key 现求(与 install_cancelled 同构)。InstancesFailed 拆成
   InstancesFailedStage(列表单行)+ LaunchStageFailed(详情/Toast 全文)。

⑥ 导入结果块:ImportReportView 加 failure_stage(NotStarted/RolledBack/RollbackIncomplete,
   由 core 的 rollback 机器位推出);标题、图标、底色三处分档,预检失败不再说「已回滚」。

顺带:六处注释/文档漂移(update.rs 的分工表与偏好落点、pack 的「已整包回滚」、
build_request 的 Errors、settings/update 的按钮禁用说明等),
以及为守 400 行把 update/cli-config/core-keys/presenter-dto/presenter-launch/i18n-golden
里贴线或越线的文件按模块拆开(纯搬移,行为不变)。
This commit is contained in:
AskaEth committed 2026-11-07 15:02:13 +08:00
1 parent b2d9f3927f
commit 03741ecd2d
69 files changed
+2653 -707

No files matched your search

+3
View File
@@ -168,6 +168,9 @@ pub struct App {
/// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」, /// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」,
/// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。 /// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。
pub(super) install_cancelled_seen: u64, pub(super) install_cancelled_seen: u64,
/// 已经提示过的启动收尾次数(M161):`AppState::launch_ends` 是「收尾过几场」,
/// 这里是「其中几场已经弹过 Toast」。两者不等 ⇒ 弹一次(与 `install_cancelled_seen` 同构)。
pub(super) launch_ends_seen: u64,
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。 /// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
/// ///
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉; /// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
@@ -89,6 +89,7 @@ impl App {
let mut view = pages::Model::new( let mut view = pages::Model::new(
pages::Screen::Instances, pages::Screen::Instances,
width, width,
height,
Some(pages::Screen::Instances.nav()), Some(pages::Screen::Instances.nav()),
); );
view.lang = lang.lang(); view.lang = lang.lang();
@@ -126,6 +127,7 @@ impl App {
download_draft: pages::InstallDraft::default(), download_draft: pages::InstallDraft::default(),
launch_form: pages::LaunchForm::default(), launch_form: pages::LaunchForm::default(),
install_cancelled_seen: 0, install_cancelled_seen: 0,
launch_ends_seen: 0,
content: pages::content::PageState::default(), content: pages::content::PageState::default(),
account_form: pages::account::Form::new(), account_form: pages::account::Form::new(),
account: pages::account::Page::build( account: pages::account::Page::build(
+19
View File
@@ -6,6 +6,7 @@
use unlucky_presenter::toast::ToastKind; use unlucky_presenter::toast::ToastKind;
use crate::app::view;
use crate::pages; use crate::pages;
use unlucky_presenter::router::Route; use unlucky_presenter::router::Route;
@@ -36,6 +37,23 @@ impl App {
self.state.toasts.push(ToastKind::Info, text); self.state.toasts.push(ToastKind::Info, text);
} }
// M161:启动收尾的提示语也在这里弹(presenter 只发结构:结局 + 退出码 + 失败阶段)。
// 此前 presenter 直接推一条自己拼好的中文 Toast,英文界面会弹中文。
if self.state.launch_ends != self.launch_ends_seen {
self.launch_ends_seen = self.state.launch_ends;
if let Some(finished) = self.state.last_launch_end.as_deref() {
let text = view::instances::launch_toast(finished, self.lang());
let kind = if finished.outcome == unlucky_presenter::dto::LogOutcome::Ok
&& finished.failure.is_none()
{
ToastKind::Success
} else {
ToastKind::Error
};
self.state.toasts.push(kind, text);
}
}
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数): // 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。 // 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
let category = self.view.download.category; let category = self.view.download.category;
@@ -48,6 +66,7 @@ impl App {
self.view = super::super::view::model( self.view = super::super::view::model(
&self.state, &self.state,
width, width,
self.height,
self.options.force_state, self.options.force_state,
category, category,
&draft, &draft,
@@ -20,8 +20,18 @@ impl App {
/// 一条更新动作。 /// 一条更新动作。
pub(super) fn on_update_action(&mut self, action: UpdateAction) -> Task<Message> { pub(super) fn on_update_action(&mut self, action: UpdateAction) -> Task<Message> {
match action { match action {
UpdateAction::CheckNow => self.dispatch(AppCommand::CheckUpdate), UpdateAction::CheckNow => {
UpdateAction::Apply => self.dispatch(AppCommand::ApplyUpdate), if self.update_busy() {
return self.refuse_busy("立即检查");
}
self.dispatch(AppCommand::CheckUpdate)
}
UpdateAction::Apply => {
if self.update_busy() {
return self.refuse_busy("下载并安装");
}
self.dispatch(AppCommand::ApplyUpdate)
}
UpdateAction::Dismiss => self.dispatch(AppCommand::DismissUpdate), UpdateAction::Dismiss => self.dispatch(AppCommand::DismissUpdate),
UpdateAction::SetAutoCheck(on) => self.dispatch(AppCommand::SetUpdateAutoCheck(on)), UpdateAction::SetAutoCheck(on) => self.dispatch(AppCommand::SetUpdateAutoCheck(on)),
UpdateAction::RestartNow => return self.restart_now(), UpdateAction::RestartNow => return self.restart_now(),
@@ -29,6 +39,25 @@ impl App {
Task::none() Task::none()
} }
/// 有没有一条检查/下载正在跑。
///
/// 与 `pages::Update::busy()` **同一个判据**(`applying` 或 `status == Checking`):
/// presenter 的 `begin_check` / `begin_apply` 在 inflight 时静默 `return None`,
/// 所以「忙碌」这件事必须在按下之前就挡住——否则用户点了没反应,还以为按钮坏了(M161)。
///
/// 这一层是**最后一道**:按钮已经画成禁用(`pages/settings/update.rs` 的 `can_check` /
/// `can_apply`),但快捷键/未来新增的入口仍会走到这里。
fn update_busy(&self) -> bool {
let view = &self.state.update;
view.applying || view.outcome.is_checking()
}
/// 忙碌时把这条动作丢掉(不派发、不乐观改状态),并在日志里留一行取证。
fn refuse_busy(&self, what: &str) -> Task<Message> {
eprintln!("[update] 已有一条检查/下载在跑,忽略重复的「{what}」");
Task::none()
}
/// 立即重启:按同样的命令行拉起自己,然后退出。 /// 立即重启:按同样的命令行拉起自己,然后退出。
/// ///
/// 截图通道(`--screenshot`)**不重启**:那条路每跑一次就该落一张图然后退出,重启会覆盖同一 /// 截图通道(`--screenshot`)**不重启**:那条路每跑一次就该落一张图然后退出,重启会覆盖同一
@@ -142,6 +171,47 @@ mod tests {
assert_eq!(last(&seen), AppCommand::SetUpdateAutoCheck(false)); assert_eq!(last(&seen), AppCommand::SetUpdateAutoCheck(false));
} }
/// **忙碌时按下去不发命令**(M161:此前点了没反应,因为 presenter 在 inflight 时静默返回)。
///
/// 三条都要钉住:①`Checking` 时「立即检查」不派发;②`applying` 时「下载并安装」不派发;
/// ③忙碌结束后**恢回可点**(失败/超时不该把按钮永久钉死)。
#[test]
fn pressing_while_busy_dispatches_nothing() {
let (mut app, seen) = app();
app.state.update.outcome = unlucky_presenter::dto::UpdateOutcome::Checking;
let _ = app.on_update_action(UpdateAction::CheckNow);
assert!(
seen.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.is_empty(),
"正在检查时再按「立即检查」不许派发第二条命令"
);
// 检查回来(失败也算回来):不忙了,能再点。
app.state.update.outcome = unlucky_presenter::dto::UpdateOutcome::Failed(
unlucky_presenter::dto::UpdateFailureView::new("network", "连不上", true),
);
let _ = app.on_update_action(UpdateAction::CheckNow);
assert_eq!(last(&seen), AppCommand::CheckUpdate, "失败之后要能重试");
// 下载/校验/替换期间:`Apply` 与 `CheckNow` 都不许派发。
let before = seen.lock().unwrap_or_else(|p| p.into_inner()).len();
app.state.update.applying = true;
let _ = app.on_update_action(UpdateAction::Apply);
let _ = app.on_update_action(UpdateAction::CheckNow);
assert_eq!(
seen.lock().unwrap_or_else(|p| p.into_inner()).len(),
before,
"applying 期间不许派发任何新的检查/安装"
);
// 应用结束:恢复可点。
app.state.update.applying = false;
let _ = app.on_update_action(UpdateAction::Apply);
assert_eq!(last(&seen), AppCommand::ApplyUpdate, "失败/完成后恢回可点");
}
/// 截图通道不重启:取证进程不许把自己重启成死循环。 /// 截图通道不重启:取证进程不许把自己重启成死循环。
#[test] #[test]
fn the_screenshot_channel_never_restarts() { fn the_screenshot_channel_never_restarts() {
+5 -3
View File
@@ -21,12 +21,12 @@ use super::state::{log_first_frame, App};
use super::APP_TITLE; use super::APP_TITLE;
mod account_page; mod account_page;
mod instances; pub(super) mod instances;
mod logs; mod logs;
mod rail; mod rail;
mod update; mod update;
use instances::{detail_of, runtime_status, supporting_line}; use instances::{detail_of, runtime_status_row, supporting_line};
use logs::logs_model; use logs::logs_model;
use update::update_of; use update::update_of;
@@ -140,6 +140,7 @@ impl App {
pub(super) fn model( pub(super) fn model(
state: &AppState, state: &AppState,
width: f32, width: f32,
height: f32,
forced: Option<crate::cli::ForcedState>, forced: Option<crate::cli::ForcedState>,
category: pages::Category, category: pages::Category,
draft: &pages::InstallDraft, draft: &pages::InstallDraft,
@@ -158,7 +159,7 @@ pub(super) fn model(
id: cell.instance.id.clone(), id: cell.instance.id.clone(),
name: cell.instance.name.clone(), name: cell.instance.name.clone(),
supporting: supporting_line(cell.instance, catalog), supporting: supporting_line(cell.instance, catalog),
status: runtime_status(&cell.instance.runtime, catalog), status: runtime_status_row(&cell.instance.runtime, catalog),
}) })
.collect(); .collect();
@@ -174,6 +175,7 @@ pub(super) fn model(
pages::Model { pages::Model {
screen, screen,
window_width: width, window_width: width,
window_height: height,
active: route.nav(), active: route.nav(),
instances: pages::Instances { rows, detail }, instances: pages::Instances { rows, detail },
download: pages::Download::from_snapshot(state, category, draft, lang.catalog()), download: pages::Download::from_snapshot(state, category, draft, lang.catalog()),
+75 -7
View File
@@ -18,8 +18,11 @@
//! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、 //! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、
//! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。 //! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。
use unlucky_i18n::{Catalog, Key}; use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind}; use unlucky_presenter::dto::{
InstanceRuntime, InstanceSummary, LaunchFailureStage, LaunchFinished, LaunchStage, LoaderKind,
LogOutcome,
};
use crate::pages::{Detail, LaunchFacts, Meta}; use crate::pages::{Detail, LaunchFacts, Meta};
@@ -59,7 +62,7 @@ fn isolation(raw: &str, catalog: Catalog) -> String {
} }
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。 /// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> { pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
match runtime { match runtime {
InstanceRuntime::Idle => None, InstanceRuntime::Idle => None,
// 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言 // 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言
@@ -71,13 +74,78 @@ pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Opt
InstanceRuntime::Running { pid } => { InstanceRuntime::Running { pid } => {
Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())])) Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())]))
} }
// `reason` 是 core 给的诊断原文(**数据**,不翻译);只有前半句是界面文案 // `detail` 是 core/io 给的原文(**数据**,不翻译);「哪一步」与前半句是界面文案。
InstanceRuntime::Failed { reason } => { // M161 之前这里只有 `reason`,而 reason 里已经是「启动失败:…」,于是英文界面会出现
Some(catalog.render(Key::InstancesFailed, &[("reason", reason)])) // 「Launch failed · 启动失败:找不到 Java」——半句英文半句中文。
} //
// 这一份是**详情页**用的全文(详情列会自动换行);列表行那份是 [`runtime_status_row`]
// (单行、不许折行,见 D-M79-5)。
InstanceRuntime::Failed { stage, detail } => Some(catalog.render(
Key::LaunchStageFailed,
&[
("stage", failure_stage_label(*stage, catalog)),
("detail", detail.as_str()),
],
)),
} }
} }
/// 列表行用的运行状态:**单行**(`ListItem` 的 overline 不折行,D-M79-5)。
///
/// 与 [`runtime_status`] 只差一处:失败时不带 `detail`——那一段是原始错误文本(可能是一长串
/// 路径),放进 60/76 高的单行 overline 会被裁成半截字。全文在**详情页**([`runtime_status`])。
pub(crate) fn runtime_status_row(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
match runtime {
InstanceRuntime::Failed { stage, .. } => Some(catalog.render(
Key::InstancesFailedStage,
&[("stage", failure_stage_label(*stage, catalog))],
)),
other => runtime_status(other, catalog),
}
}
/// 失败阶段文案(presenter 给 slug,界面在这里选词)。
///
/// 与 [`stage_label`] 同一套做法:**不**复用 presenter 侧的任何中文常量。
pub(crate) fn failure_stage_label(stage: LaunchFailureStage, catalog: Catalog) -> &'static str {
catalog.text(match stage {
LaunchFailureStage::VersionManifest => Key::LaunchStageVersionManifest,
LaunchFailureStage::Inheritance => Key::LaunchStageInheritance,
LaunchFailureStage::InstanceConfig => Key::LaunchStageInstanceConfig,
LaunchFailureStage::Isolation => Key::LaunchStageIsolation,
LaunchFailureStage::Identity => Key::LaunchStageIdentity,
LaunchFailureStage::Spawn => Key::LaunchStageSpawn,
})
}
/// 一次启动收尾的 Toast 文案(**唯一的产地**:presenter 只发结构,不拼句子)。
///
/// 三种结局各有各的说法:正常结束报时长,崩溃/静默失败报「结局 + 阶段 + 原文」,
/// 组装阶段就失败(`failure` 有值)报「哪一步 + 原文」。
#[must_use]
pub(crate) fn launch_toast(finished: &LaunchFinished, lang: Lang) -> String {
let catalog = lang.catalog();
if let Some(failure) = &finished.failure {
return catalog.render(
Key::LaunchStageFailed,
&[
("stage", failure_stage_label(failure.stage, catalog)),
("detail", failure.detail.as_str()),
],
);
}
let outcome = catalog.text(match finished.outcome {
LogOutcome::Ok => Key::LaunchOutcomeNormal,
LogOutcome::Crashed => Key::LaunchOutcomeCrash,
LogOutcome::SilentFail => Key::LaunchOutcomeSilent,
});
let seconds = format!("{:.1}", finished.uptime_ms as f64 / 1000.0);
catalog.render(
Key::LaunchToastEnded,
&[("outcome", outcome), ("seconds", seconds.as_str())],
)
}
/// 阶段文案:**不**复用 `LaunchStage::label()`(那是核心层的中文诊断串,英文界面会漏中文)。 /// 阶段文案:**不**复用 `LaunchStage::label()`(那是核心层的中文诊断串,英文界面会漏中文)。
fn stage_label(stage: LaunchStage, catalog: Catalog) -> &'static str { fn stage_label(stage: LaunchStage, catalog: Catalog) -> &'static str {
catalog.text(match stage { catalog.text(match stage {
+6 -2
View File
@@ -74,7 +74,9 @@ fn crashed_only() -> AppState {
id: "dev".to_string(), id: "dev".to_string(),
session: CRASH.to_string(), session: CRASH.to_string(),
outcome: LogOutcome::Crashed, outcome: LogOutcome::Crashed,
summary: "崩溃(exit 1)".to_string(), exit_code: Some(1),
uptime_ms: 3_000,
failure: None,
})); }));
state state
@@ -166,7 +168,9 @@ fn an_imported_crash_file_still_shows_its_findings() {
id: "dev".to_string(), id: "dev".to_string(),
session: CRASH.to_string(), session: CRASH.to_string(),
outcome: LogOutcome::Crashed, outcome: LogOutcome::Crashed,
summary: "崩溃(exit 1)".to_string(), exit_code: Some(1),
uptime_ms: 3_000,
failure: None,
})); }));
state.apply_core(CoreEvent::DiagnosisReady(report( state.apply_core(CoreEvent::DiagnosisReady(report(
"file:crash-2026-11-02_19.40.15-client.txt", "file:crash-2026-11-02_19.40.15-client.txt",
+75 -3
View File
@@ -39,6 +39,7 @@ fn at_lang(state: &AppState, forced: Option<crate::cli::ForcedState>, lang: Lang
model( model(
state, state,
1180.0, 1180.0,
760.0,
forced, forced,
pages::Category::All, pages::Category::All,
&draft, &draft,
@@ -125,11 +126,18 @@ fn a_busy_instance_offers_stop_instead_of_play() {
#[test] #[test]
fn failed_instances_can_be_retried() { fn failed_instances_can_be_retried() {
let failed = instance(InstanceRuntime::Failed { let failed = instance(InstanceRuntime::Failed {
reason: "exit 1".to_string(), stage: unlucky_presenter::dto::LaunchFailureStage::Spawn,
detail: "exit 1".to_string(),
}); });
// 列表行是**单行**:只放「哪一步」(原文太长会被 `Wrapping::None` 裁成半截字)。
assert_eq!( assert_eq!(
runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(), super::instances::runtime_status_row(&failed.runtime, Lang::En.catalog()).as_deref(),
Some("Launch failed · exit 1") Some("Launch failed · Starting the game process")
);
// 详情页给全文(阶段 + 原文)。
assert_eq!(
super::instances::runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
Some("Launch failed · Starting the game process: exit 1")
); );
assert!( assert!(
detail_of(&failed, Lang::En.catalog()).can_launch, detail_of(&failed, Lang::En.catalog()).can_launch,
@@ -319,3 +327,67 @@ fn the_agent_route_has_no_page() {
assert!(model.instances.detail.is_none()); assert!(model.instances.detail.is_none());
assert_eq!(model.active, None, "AI 页不高亮任何导航项"); assert_eq!(model.active, None, "AI 页不高亮任何导航项");
} }
/// **启动收尾的提示语跟着界面语言走**(M161 收的债)。
///
/// 此前 `LaunchFinished.summary` 是 presenter 拼的中文,英文界面上弹的就是中文;
/// 现在 presenter 只给结构(结局 + 退出码 + 失败阶段),文案在这里按 key 现求。
///
/// 抓得住:①谁把中文串再写回 presenter(英文那一行会露中文);②失败时丢掉 `detail`
/// (原文是取证信息,不许翻也不许删);③「跑起来再崩」被说成「启动失败在某一步」。
#[test]
fn the_launch_toast_speaks_the_interface_language() {
use unlucky_presenter::dto::{
LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome,
};
let finished = |outcome, failure| LaunchFinished {
id: "survival".to_string(),
session: "20261031T120000-abcd12".to_string(),
outcome,
exit_code: Some(1),
uptime_ms: 12_500,
failure,
};
// 正常结束:只报结局 + 时长(`正常结束 · 12.5 秒` / `Finished normally · 12.5s`)。
let ok = finished(LogOutcome::Ok, None);
let en = super::instances::launch_toast(&ok, Lang::En);
let zh = super::instances::launch_toast(&ok, Lang::ZhCn);
assert_eq!(en, "Finished normally · 12.5s");
assert_eq!(zh, "正常结束 · 12.5 秒");
assert_ne!(en, zh, "切语言时提示语要变");
// 装配阶段失败:说清**哪一步**,并把原文原样带出来。
let failed = finished(
LogOutcome::Crashed,
Some(LaunchFailureView::new(
LaunchFailureStage::InstanceConfig,
"permission denied (os error 5)",
)),
);
let en = super::instances::launch_toast(&failed, Lang::En);
let zh = super::instances::launch_toast(&failed, Lang::ZhCn);
assert!(
en.starts_with("Launch failed · Reading the instance settings"),
"{en}"
);
assert!(zh.starts_with("启动失败 · 读实例设置"), "{zh}");
for text in [&en, &zh] {
assert!(
text.contains("permission denied (os error 5)"),
"原文(取证信息)不许丢:{text}"
);
}
// 英文界面里不许出现中文(反之亦然)——这条是本次债的核心。
assert!(!en.chars().any(is_cjk), "英文提示里混了中文:{en}");
assert!(
!zh.contains("Launch failed"),
"中文提示里混了英文前缀:{zh}"
);
}
/// 有没有中文字符(上面那条「不许混语言」的判据)。
fn is_cjk(ch: char) -> bool {
('\u{4e00}'..='\u{9fff}').contains(&ch)
}
+2 -1
View File
@@ -81,8 +81,9 @@ pub fn view<'a>(
metrics: FrameMetrics, metrics: FrameMetrics,
model: &'a Content, model: &'a Content,
lang: Lang, lang: Lang,
window_height: f32,
) -> Element<'a, PageMessage> { ) -> Element<'a, PageMessage> {
view::view(ui, metrics, model, lang) view::view(ui, metrics, model, lang, window_height)
} }
/// 演示数据(`pages_demo` 出图与单测用;产品路径走 `Content::from_snapshot`)。 /// 演示数据(`pages_demo` 出图与单测用;产品路径走 `Content::from_snapshot`)。
@@ -74,6 +74,53 @@ impl Tier {
} }
} }
/// 主列在**矮窗口**下切成「整列一个滚动条」的门槛(M161)。
///
/// ## 为什么需要这条门槛
///
/// 主列自上而下是:搜索条(48)+ 筛选四排(4×32 + 3×8 = 152)+ 计数头(20)+ 结果列表 + 「加载更多」(40)
/// 再加两条 `Space::X2` 间距。列表自己没有最小高度,于是窗口一矮,**列表视口只剩几十像素**:
/// 一行 `ListItem` 是 60/76 高,被切成半行——两行文字(overline + 标题)看起来像「折行挤压」。
///
/// ## 明确的两档行为(不是到处加滚动条)
///
/// | 正文可用高 | 版面 |
/// |---|---|
/// | ≥ [`MIN_BODY_HEIGHT`] | 现状:**列表自己滚**(筛选条常驻,滚的时候不跑掉) |
/// | < [`MIN_BODY_HEIGHT`] | 主列整列变成**一个**纵向滚动区:列表按自然高度铺开,一行都不压 |
///
/// 阈值 = 固定件(48 + 152 + 20 + 40 + 间距 ≈ 276)+ 至少三整行(3×60 = 180)= 456,
/// 取 **460**(往上抹到 4 的倍数,给 divider / 取整留余量)。数值改了就改这里的算式与
/// `layout/tests` 的等式用例。
pub const MIN_BODY_HEIGHT: f32 = 460.0;
/// 正文高度的两档行为。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BodyMode {
/// 高够:列表自己滚(筛选条常驻)
Split,
/// 矮窗口:主列整列一个滚动区,列表按自然高度铺开
ScrollWhole,
}
impl BodyMode {
/// 判据(纯函数,测得到)。
#[must_use]
pub fn of(body_height: f32) -> Self {
if body_height >= MIN_BODY_HEIGHT {
Self::Split
} else {
Self::ScrollWhole
}
}
/// 列表要不要自己滚。
#[must_use]
pub const fn list_scrolls(self) -> bool {
matches!(self, Self::Split)
}
}
/// `Wide` 的门槛:标签栏 + 间距 + 列表下限 + 间距 + 详情。 /// `Wide` 的门槛:标签栏 + 间距 + 列表下限 + 间距 + 详情。
pub fn wide_min() -> f32 { pub fn wide_min() -> f32 {
RAIL_LABEL + GAP + layout::LINE_SAFE_WIDTH_PX + GAP + DETAIL RAIL_LABEL + GAP + layout::LINE_SAFE_WIDTH_PX + GAP + DETAIL
@@ -192,3 +239,49 @@ mod tests {
assert!(!Tier::Narrow.rail_has_labels()); assert!(!Tier::Narrow.rail_has_labels());
} }
} }
#[cfg(test)]
mod body_mode_tests {
use super::*;
/// 门槛是**算出来的**:固定件(搜索 48 + 筛选 152 + 计数 20 + 加载更多 40 + 两条 8 间距)
/// 加三整行列表(3 × 60)。谁把 460 改成拍脑袋的数,这条会红。
#[test]
fn the_minimum_body_height_is_fixed_parts_plus_three_rows() {
let fixed = 48.0 + 152.0 + 20.0 + 40.0 + 2.0 * Space::X2.dp();
let three_rows = 3.0 * 60.0;
assert!(
MIN_BODY_HEIGHT >= fixed + three_rows,
"门槛 {MIN_BODY_HEIGHT} 小于「固定件 {fixed} + 三行 {three_rows}」——矮窗口还是会切半行"
);
assert!(
MIN_BODY_HEIGHT <= fixed + three_rows + 16.0,
"门槛留了太多余量:{} 会把还放得下的窗口也推进「整列滚动」档",
MIN_BODY_HEIGHT - (fixed + three_rows)
);
}
/// 两档在门槛处切开,且只有 `Split` 让列表自己滚。
#[test]
fn the_body_mode_flips_at_the_threshold() {
assert_eq!(BodyMode::of(MIN_BODY_HEIGHT), BodyMode::Split);
assert_eq!(BodyMode::of(MIN_BODY_HEIGHT - 0.5), BodyMode::ScrollWhole);
assert!(BodyMode::of(900.0).list_scrolls());
assert!(!BodyMode::of(300.0).list_scrolls());
}
/// 窗口最小尺寸(880×560)落在**矮**档:这是本条债的真实场景,别让它悄悄变回 `Split`。
#[test]
fn the_minimum_window_size_uses_the_scrolling_body() {
// 560 窗口 − 顶栏 56 − 页头 88(两行标题 + 留白)− 上下页边距 2×16 ≈ 384
let body = 560.0 - 56.0 - 88.0 - 32.0;
assert_eq!(
BodyMode::of(body),
BodyMode::ScrollWhole,
"最小窗口(560 高)必须走整列滚动:正文只剩 {body}"
);
// 验收尺寸 760 高 → 约 584,仍走「列表自己滚」。
let body = 760.0 - 56.0 - 88.0 - 32.0;
assert_eq!(BodyMode::of(body), BodyMode::Split, "验收尺寸不该换版面");
}
}
@@ -20,8 +20,8 @@ use unlucky_i18n::Lang;
use unlucky_presenter::dto::{ use unlucky_presenter::dto::{
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView, ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView,
ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportErrorView, ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportErrorView,
ImportProgressView, ImportReportView, ImportStageView, ImportState, ImportStatusView, ImportFailureStageView, ImportProgressView, ImportReportView, ImportStageView, ImportState,
InstanceId, LoaderKind, MissingFileView, MissingReasonView, PackFormatView, ImportStatusView, InstanceId, LoaderKind, MissingFileView, MissingReasonView, PackFormatView,
}; };
use crate::pages::content::Target; use crate::pages::content::Target;
@@ -88,8 +88,22 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) {
import.report = Some(report(ImportStatusView::Partial, true)); import.report = Some(report(ImportStatusView::Partial, true));
page.expanded = Some("config/sodium-options.json".to_string()); page.expanded = Some("config/sodium-options.json".to_string());
} }
// 致命态:整包回滚 + 容忍域仍在(展示两类失败的视觉分野) // 致命态(M161 分成三档出图:预检没过 / 已回滚 / 回滚没做完)。
"failed" => { // `failed` 保留旧名字(= 已回滚),另外两个是新档位。
"failed" | "failed_rolled_back" => {
let mut fatal = report(ImportStatusView::Failed, false);
fatal.error = Some(ImportErrorView {
code: "import_integrity".to_string(),
label: "Integrity check".to_string(),
message: "mods/sodium.jar: sha1 mismatch after write".to_string(),
hint: Some("Re-download the pack from its source".to_string()),
});
fatal.rolled_back = true;
fatal.failure_stage = Some(ImportFailureStageView::RolledBack);
import.report = Some(fatal);
}
// 预检没过:**一个字节都没写**(与上一条只差 `failure_stage`,图上要能一眼分辨)
"failed_preflight" => {
let mut fatal = report(ImportStatusView::Failed, false); let mut fatal = report(ImportStatusView::Failed, false);
fatal.error = Some(ImportErrorView { fatal.error = Some(ImportErrorView {
code: "pack_traversal".to_string(), code: "pack_traversal".to_string(),
@@ -99,6 +113,21 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) {
}); });
fatal.files.clear(); fatal.files.clear();
fatal.bytes_written = 0; fatal.bytes_written = 0;
fatal.rolled_back = true;
fatal.failure_stage = Some(ImportFailureStageView::NotStarted);
import.report = Some(fatal);
}
// 回滚没做完:目标目录可能与导入前不同
"failed_incomplete" => {
let mut fatal = report(ImportStatusView::Failed, false);
fatal.error = Some(ImportErrorView {
code: "import_integrity".to_string(),
label: "Integrity check".to_string(),
message: "mods/sodium.jar: sha1 mismatch after write".to_string(),
hint: None,
});
fatal.rolled_back = false;
fatal.failure_stage = Some(ImportFailureStageView::RollbackIncomplete);
import.report = Some(fatal); import.report = Some(fatal);
} }
// 取消态:面板收回了,但「后台还在跑」这句话要留在屏上 // 取消态:面板收回了,但「后台还在跑」这句话要留在屏上
@@ -182,6 +211,7 @@ fn report(status: ImportStatusView, conflicts: bool) -> ImportReportView {
registered: Some("Survival".to_string()), registered: Some("Survival".to_string()),
bytes_written: 3_400_000, bytes_written: 3_400_000,
rolled_back: true, rolled_back: true,
failure_stage: None,
}; };
if conflicts { if conflicts {
report.conflicts = vec![ report.conflicts = vec![
@@ -23,7 +23,10 @@
//! ## 容忍 / 致命在模型层就分开 //! ## 容忍 / 致命在模型层就分开
//! //!
//! [`report::Report::missing`] 是「搞不到某个文件」(实例可用),[`report::Report::fatal`] //! [`report::Report::missing`] 是「搞不到某个文件」(实例可用),[`report::Report::fatal`]
//! 是「已整包回滚」。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。 //! 是致命失败。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。
//!
//! 致命那一类内部还要再分三档(M161):预检没过(一个字节都没写)/ 已回滚 / 回滚没做完,
//! 见 [`report::FatalKind`]——措辞、图标、底色三处都分开。
mod export; mod export;
mod pick; mod pick;
@@ -33,7 +36,7 @@ mod report;
pub use export::{format_label, request_of as request_export, Export, Preview}; pub use export::{format_label, request_of as request_export, Export, Preview};
pub use pick::{Pick, PickRow}; pub use pick::{Pick, PickRow};
pub use progress::Running; pub use progress::Running;
pub use report::{ConflictRow, Report, Row}; pub use report::{ConflictRow, FatalKind, Report, Row};
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -14,8 +14,8 @@
use unlucky_i18n::{Catalog, Key}; use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{ use unlucky_presenter::dto::{
ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView, ImportReportView, ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView,
ImportStatusView, MissingFileView, MissingReasonView, ImportFailureStageView, ImportReportView, ImportStatusView, MissingFileView, MissingReasonView,
}; };
use super::PackPageState; use super::PackPageState;
@@ -48,10 +48,40 @@ pub struct ConflictRow {
pub expanded: bool, pub expanded: bool,
} }
/// 致命失败块(**已整包回滚**)。 /// 致命失败块(**两种措辞,不共用**)。
///
/// 为什么不是一句「失败」:`NotStarted`(预检就没过,一个字节都没写)与 `RolledBack`
/// (写进去了,又整包回滚干净)对用户是不同的消息——前者「去修包再试」,后者「刚才动过你的目录,
/// 已经复原」。M161 之前两者共用一句「已回滚 —— 目标目录零残留」,预检失败时那句话是假的。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FatalKind {
/// 预检/装配阶段失败:目标目录**一个字节都没写**
NotStarted,
/// 写过目标目录,回滚**完整**完成
RolledBack,
/// 写过,但回滚有失败项(目标目录可能与导入前不同)
RollbackIncomplete,
}
impl FatalKind {
/// 图标(与 [`Fatal::title`] 各说一遍:颜色/图标关掉时标题仍然分得清)。
#[must_use]
pub const fn icon(self) -> unlucky_design::icons::IconKind {
match self {
// 预检没过 = 「还没开始」,用警示而不是「已出错」:目录是干净的
Self::NotStarted => unlucky_design::icons::IconKind::Warn,
Self::RolledBack => unlucky_design::icons::IconKind::Restore,
Self::RollbackIncomplete => unlucky_design::icons::IconKind::Error,
}
}
}
/// 致命失败块。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Fatal { pub struct Fatal {
/// 「已回滚 —— 目标目录零残留」 /// 三种阶段之一(决定标题/图标/底色)
pub kind: FatalKind,
/// 标题(预检未通过 / 已回滚 / 回滚不完整)
pub title: String, pub title: String,
/// 大类名(`路径穿越` / `清单损坏` …) /// 大类名(`路径穿越` / `清单损坏` …)
pub label: String, pub label: String,
@@ -124,7 +154,7 @@ pub(super) fn report_of(
Report { Report {
status: report.status, status: report.status,
title: status_title(report.status, catalog).to_owned(), title: status_title(report, catalog).to_owned(),
subtitle: subtitle(report, catalog), subtitle: subtitle(report, catalog),
written_line: catalog.render( written_line: catalog.render(
Key::PackReportWritten, Key::PackReportWritten,
@@ -133,7 +163,10 @@ pub(super) fn report_of(
("bytes", &human(report)), ("bytes", &human(report)),
], ],
), ),
fatal: report.error.as_ref().map(|error| fatal_of(error, catalog)), fatal: report
.error
.as_ref()
.map(|error| fatal_of(error, report.failure_stage, catalog)),
missing_title, missing_title,
missing: report missing: report
.missing .missing
@@ -177,11 +210,22 @@ fn human(report: &ImportReportView) -> String {
unlucky_presenter::dto::human_bytes(report.bytes_written) unlucky_presenter::dto::human_bytes(report.bytes_written)
} }
fn status_title(status: ImportStatusView, catalog: Catalog) -> &'static str { /// 标题:三态 + **失败阶段**(预检未通过 ≠ 已回滚,M161)。
match status { fn status_title(report: &ImportReportView, catalog: Catalog) -> &'static str {
match report.status {
ImportStatusView::Success => catalog.text(Key::PackStatusSuccess), ImportStatusView::Success => catalog.text(Key::PackStatusSuccess),
ImportStatusView::Partial => catalog.text(Key::PackStatusPartial), ImportStatusView::Partial => catalog.text(Key::PackStatusPartial),
ImportStatusView::Failed => catalog.text(Key::PackStatusFailed), ImportStatusView::Failed => match report.failure_stage {
Some(ImportFailureStageView::NotStarted) => {
catalog.text(Key::PackStatusFailedNotStarted)
}
Some(ImportFailureStageView::RollbackIncomplete) => {
catalog.text(Key::PackStatusFailedIncomplete)
}
// `None` 不该出现(失败必有阶段);退回「已回滚」这一句是旧行为,
// 只有拿不到阶段时才会走到(例如未来的旧版对端)。
_ => catalog.text(Key::PackStatusFailed),
},
} }
} }
@@ -203,9 +247,24 @@ fn subtitle(report: &ImportReportView, catalog: Catalog) -> String {
parts.join(" · ") parts.join(" · ")
} }
fn fatal_of(error: &ImportErrorView, catalog: Catalog) -> Fatal { fn fatal_of(
error: &ImportErrorView,
stage: Option<ImportFailureStageView>,
catalog: Catalog,
) -> Fatal {
let kind = match stage {
Some(ImportFailureStageView::NotStarted) => FatalKind::NotStarted,
Some(ImportFailureStageView::RollbackIncomplete) => FatalKind::RollbackIncomplete,
_ => FatalKind::RolledBack,
};
let title = match kind {
FatalKind::NotStarted => catalog.text(Key::PackFatalTitleNotStarted),
FatalKind::RolledBack => catalog.text(Key::PackFatalTitle),
FatalKind::RollbackIncomplete => catalog.text(Key::PackFatalTitleIncomplete),
};
Fatal { Fatal {
title: catalog.text(Key::PackFatalTitle).to_owned(), kind,
title: title.to_owned(),
label: error.label.clone(), label: error.label.clone(),
message: error.message.clone(), message: error.message.clone(),
code: error.code.clone(), code: error.code.clone(),
@@ -4,6 +4,7 @@
//! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。 //! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。
mod pick; mod pick;
mod stage;
use std::path::PathBuf; use std::path::PathBuf;
@@ -226,6 +227,7 @@ fn report_keeps_tolerable_and_fatal_apart() {
registered: None, registered: None,
bytes_written: 0, bytes_written: 0,
rolled_back: true, rolled_back: true,
failure_stage: None,
}; };
let block = report_of(&report, &PackPageState::default(), catalog()); let block = report_of(&report, &PackPageState::default(), catalog());
assert!(block.fatal.is_some(), "致命块必须单独存在"); assert!(block.fatal.is_some(), "致命块必须单独存在");
@@ -262,6 +264,7 @@ fn conflicts_default_to_keeping_the_user_copy() {
registered: None, registered: None,
bytes_written: 0, bytes_written: 0,
rolled_back: true, rolled_back: true,
failure_stage: None,
}; };
// 「以包为准」只在导入到**已有实例**时给(复审 r1 P2-2),所以这里选一个实例 // 「以包为准」只在导入到**已有实例**时给(复审 r1 P2-2),所以这里选一个实例
let page = PackPageState { let page = PackPageState {
@@ -359,6 +362,7 @@ fn overwrite_is_offered_only_when_importing_into_an_existing_instance() {
registered: None, registered: None,
bytes_written: 0, bytes_written: 0,
rolled_back: true, rolled_back: true,
failure_stage: None,
}; };
let new_instance = PackPageState::default(); let new_instance = PackPageState::default();
@@ -0,0 +1,109 @@
//! M161:导入失败的**两种阶段**不许共用一句话。
//!
//! 从 `tests.rs` 拆出(那边贴着 400 行硬线):本文件只守「预检没过(一个字节都没写)」与
//! 「已回滚」的措辞/图标分野。夹具在父模块(`use super::*`)。
use unlucky_presenter::dto::ImportFailureStageView;
use super::*;
/// **「预检没过」与「已回滚」必须各说各的话**(M161 收的债)。
///
/// 此前两者共用 `rolled_back: true`,于是预检失败(一个字节都没写)也被写成
/// 「已回滚 —— 目标目录零残留」——一个没发生的回滚。现在标题、图标、底色三处都分开,
/// 且 `NotStarted` 的标题**不许**出现「回滚 / rolled back」这类词(那才是真正的谎)。
#[test]
fn the_two_failure_stages_never_share_one_sentence() {
fn failed(stage: Option<ImportFailureStageView>) -> ImportReportView {
ImportReportView {
status: ImportStatusView::Failed,
format: Some(PackFormatView::Modrinth),
pack_name: Some("Broken".to_string()),
pack_version: None,
game_version: None,
loader: None,
version_id: None,
target: PathBuf::from("C:/tmp"),
files: Vec::new(),
conflicts: Vec::new(),
missing: Vec::new(),
rejected: Vec::new(),
warnings: Vec::new(),
error: Some(ImportErrorView {
code: "pack_traversal".to_string(),
label: "path traversal".to_string(),
message: "../etc/passwd".to_string(),
hint: None,
}),
registered: None,
bytes_written: 0,
rolled_back: true,
failure_stage: stage,
}
}
let zh = Lang::ZhCn.catalog();
let not_started = report_of(
&failed(Some(ImportFailureStageView::NotStarted)),
&PackPageState::default(),
zh,
);
let rolled_back = report_of(
&failed(Some(ImportFailureStageView::RolledBack)),
&PackPageState::default(),
zh,
);
let incomplete = report_of(
&failed(Some(ImportFailureStageView::RollbackIncomplete)),
&PackPageState::default(),
zh,
);
// 三种标题互不相同(三态压力:谁把它们折叠回同一句,这里就红)。
let titles = [
not_started.title.as_str(),
rolled_back.title.as_str(),
incomplete.title.as_str(),
];
assert_eq!(
titles
.iter()
.collect::<std::collections::HashSet<_>>()
.len(),
3,
"三种失败阶段的标题撞了:{titles:?}"
);
// 「预检没过」那一句里不许出现「回滚」(那是没发生的事)。
let fatal = not_started.fatal.expect("致命块");
assert_eq!(
fatal.kind,
crate::pages::content::packui::model::FatalKind::NotStarted
);
assert!(
!fatal.title.contains("回滚"),
"预检失败不该说「回滚」:{}",
fatal.title
);
assert!(!not_started.title.contains("回滚"), "{}", not_started.title);
// 已回滚那一句必须说清「回滚」这件事。
assert!(
rolled_back.title.contains("回滚"),
"已回滚那句要说明回滚:{}",
rolled_back.title
);
// 图标分开:预检没过不是「错误」字形(目录没动过),回滚不完整才是。
assert_ne!(
crate::pages::content::packui::model::FatalKind::NotStarted.icon(),
crate::pages::content::packui::model::FatalKind::RolledBack.icon(),
"两种阶段的图标不许一样"
);
// 成功/部分成功没有「失败阶段」可言。
let mut ok = failed(None);
ok.status = ImportStatusView::Success;
ok.error = None;
assert!(report_of(&ok, &PackPageState::default(), zh)
.fatal
.is_none());
}
@@ -15,7 +15,7 @@ use crate::pages::frame;
use crate::pages::Message as PageMessage; use crate::pages::Message as PageMessage;
use crate::theme::Ui; use crate::theme::Ui;
use super::super::model::{ConflictRow, Report}; use super::super::model::{ConflictRow, FatalKind, Report};
use super::super::PackMessage; use super::super::PackMessage;
use super::intent; use super::intent;
use super::parts::{bounded, marker_box, rows_block, tolerable_title}; use super::parts::{bounded, marker_box, rows_block, tolerable_title};
@@ -62,34 +62,46 @@ pub(super) fn report_block<'a>(
.width(Length::Fill); .width(Length::Fill);
if let Some(fatal) = &report.fatal { if let Some(fatal) = &report.fatal {
// 三种阶段各自的底色/图标:预检没过用 `SecondaryContainer`(**不是错的颜色**——
// 目标目录一个字节都没写,没什么可修的残留),已回滚用 `ErrorContainer`,
// 回滚不完整留 `ErrorContainer`(它才是真的要对一眼目录的状态)。
let (role, on_role) = match fatal.kind {
FatalKind::NotStarted => (Role::SecondaryContainer, Role::OnSecondaryContainer),
_ => (Role::ErrorContainer, Role::OnErrorContainer),
};
block = block.push(marker_box( block = block.push(marker_box(
ui, ui,
Role::ErrorContainer, role,
column![ row![
frame::text_role( frame::glyph(fatal.kind.icon(), ui.color(on_role), frame::ICON_PX),
ui, column![
TypeRole::LabelLarge, frame::text_role(
true, ui,
fatal.title.as_str(), TypeRole::LabelLarge,
ui.color(Role::OnErrorContainer), true,
), fatal.title.as_str(),
frame::text_role_owned( ui.color(on_role),
ui, ),
TypeRole::BodyMedium, frame::text_role_owned(
false, ui,
format!("{} · {}", fatal.label, fatal.code), TypeRole::BodyMedium,
ui.color(Role::OnErrorContainer), false,
), format!("{} · {}", fatal.label, fatal.code),
frame::text_role( ui.color(on_role),
ui, ),
TypeRole::BodySmall, frame::text_role(
false, ui,
fatal.message.as_str(), TypeRole::BodySmall,
ui.color(Role::OnErrorContainer), false,
), fatal.message.as_str(),
ui.color(on_role),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill),
] ]
.spacing(ui.space(Space::X1)) .spacing(ui.space(Space::X2))
.width(Length::Fill), .align_y(Alignment::Start),
)); ));
if let Some(hint) = &fatal.hint { if let Some(hint) = &fatal.hint {
block = block.push( block = block.push(
+33 -15
View File
@@ -98,12 +98,18 @@ pub(super) fn search<'a>(
.into() .into()
} }
/// 结果列表(独立滚动;底部「加载更多」钉在列表之外,见 [`more`])。 /// 结果列表。
///
/// 两档(M161,判据同 [`super::layout::BodyMode`]):
///
/// * `mode.list_scrolls()`:列表**自己滚**(筛选条常驻,高窗口的常态);
/// * 否则:行按自然高度铺开,滚动交给外层主列——矮窗口里一行 `ListItem` 不许被压成半行。
pub(super) fn list<'a>( pub(super) fn list<'a>(
ui: &'a Ui, ui: &'a Ui,
catalog: Catalog, catalog: Catalog,
model: &'a Content, model: &'a Content,
rails: Rails, rails: Rails,
mode: super::layout::BodyMode,
) -> Element<'a, PageMessage> { ) -> Element<'a, PageMessage> {
let rows = column( let rows = column(
model model
@@ -117,20 +123,32 @@ pub(super) fn list<'a>(
.width(Length::Fill); .width(Length::Fill);
let _ = catalog; let _ = catalog;
container( let inner: Element<'a, PageMessage> = if mode.list_scrolls() {
scrollable(rows) container(
.direction(scrollable::Direction::Vertical( scrollable(rows)
scrollable::Scrollbar::new() .direction(scrollable::Direction::Vertical(
.width(frame::SCROLLBAR_WIDTH) scrollable::Scrollbar::new()
.scroller_width(frame::SCROLLBAR_WIDTH) .width(frame::SCROLLBAR_WIDTH)
.margin(frame::SCROLLBAR_MARGIN), .scroller_width(frame::SCROLLBAR_WIDTH)
)) .margin(frame::SCROLLBAR_MARGIN),
.height(Length::Fill) ))
.style(frame::scroll_style(ui)), .height(Length::Fill)
) .style(frame::scroll_style(ui)),
.width(Length::Fixed(rails.list)) )
.height(Length::Fill) .height(Length::Fill)
.into() .into()
} else {
// 自然高度(不给 `Fill`):外层主列的那个滚动条负责滚。
rows.into()
};
container(inner)
.width(Length::Fixed(rails.list))
.height(if mode.list_scrolls() {
Length::Fill
} else {
Length::Shrink
})
.into()
} }
fn hit_row<'a>(ui: &'a Ui, model: &Content, hit: &'a Hit) -> Element<'a, PageMessage> { fn hit_row<'a>(ui: &'a Ui, model: &Content, hit: &'a Hit) -> Element<'a, PageMessage> {
+42 -9
View File
@@ -49,12 +49,17 @@ pub(super) fn view<'a>(
metrics: FrameMetrics, metrics: FrameMetrics,
model: &'a Content, model: &'a Content,
lang: unlucky_i18n::Lang, lang: unlucky_i18n::Lang,
window_height: f32,
) -> Element<'a, PageMessage> { ) -> Element<'a, PageMessage> {
let catalog = lang.catalog(); let catalog = lang.catalog();
// 正文可用高 → 版面两档(`Split`:列表自己滚;`ScrollWhole`:主列整列一个滚动条)。
// 门槛与判据在 [`content_layout`] 里,那里有用例钉住「不算拍脑袋的数」。
let body_height = frame::body_height(ui, metrics, window_height, true);
let mode = content_layout::BodyMode::of(body_height);
let header = Header::new(catalog.text(Key::ContentTitle)) let header = Header::new(catalog.text(Key::ContentTitle))
.subtitle(catalog.text(Key::ContentSubtitle)) .subtitle(catalog.text(Key::ContentSubtitle))
.action(parts::section_switcher(ui, catalog, true)); .action(parts::section_switcher(ui, catalog, true));
frame::page(ui, metrics, header, body(ui, metrics, catalog, model)) frame::page(ui, metrics, header, body(ui, metrics, catalog, model, mode))
} }
fn body<'a>( fn body<'a>(
@@ -62,6 +67,7 @@ fn body<'a>(
metrics: FrameMetrics, metrics: FrameMetrics,
catalog: Catalog, catalog: Catalog,
model: &'a Content, model: &'a Content,
mode: content_layout::BodyMode,
) -> Element<'a, PageMessage> { ) -> Element<'a, PageMessage> {
let rails = content_layout::resolve(metrics.available); let rails = content_layout::resolve(metrics.available);
@@ -75,14 +81,14 @@ fn body<'a>(
let main = column![ let main = column![
kind_rail(ui, catalog, model, rails), kind_rail(ui, catalog, model, rails),
divider::full(ui), divider::full(ui),
main_column(ui, catalog, model, rails) main_column(ui, catalog, model, rails, mode)
] ]
.spacing(ui.space(Space::X2)) .spacing(ui.space(Space::X2))
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill); .height(Length::Fill);
columns = row![main].width(Length::Fill).height(Length::Fill); columns = row![main].width(Length::Fill).height(Length::Fill);
} else { } else {
columns = columns.push(main_column(ui, catalog, model, rails)); columns = columns.push(main_column(ui, catalog, model, rails, mode));
if rails.tier.has_detail() { if rails.tier.has_detail() {
columns = columns.push(detail_column(ui, catalog, model, rails)); columns = columns.push(detail_column(ui, catalog, model, rails));
} }
@@ -151,11 +157,19 @@ fn kind_rail<'a>(
} }
/// 主列:搜索条 + 筛选 + 计数 + 结果(或四态之一)。 /// 主列:搜索条 + 筛选 + 计数 + 结果(或四态之一)。
///
/// `mode` 决定**谁滚**(M161,判据在 `content_layout::BodyMode`):
///
/// * `Split`:列表自己滚(筛选条常驻,高窗口的常态);
/// * `ScrollWhole`:矮窗口——主列(含搜索条与筛选)整列进**一个**纵向滚动区,列表按自然高度铺开。
/// 这样一行 `ListItem` 不会被压成半行(那看起来就是「折行挤压」),也不会出现「列表里一个滚动条、
/// 外层又一个」的双滚动。
fn main_column<'a>( fn main_column<'a>(
ui: &'a Ui, ui: &'a Ui,
catalog: Catalog, catalog: Catalog,
model: &'a Content, model: &'a Content,
rails: Rails, rails: Rails,
mode: content_layout::BodyMode,
) -> Element<'a, PageMessage> { ) -> Element<'a, PageMessage> {
// 主列的横向内边距不在这里加:`rails.list` 已经把可用宽算干了,再加 2×X2 会让整排 // 主列的横向内边距不在这里加:`rails.list` 已经把可用宽算干了,再加 2×X2 会让整排
// 溢出 32px(详情列被切掉一半,复审截图上正是这条) // 溢出 32px(详情列被切掉一半,复审截图上正是这条)
@@ -178,10 +192,15 @@ fn main_column<'a>(
} else { } else {
// **必须给间距**(复审 P2-1):这一列原先漏了 `.spacing(..)`,而列表自身的纵向内边距是 0, // **必须给间距**(复审 P2-1):这一列原先漏了 `.spacing(..)`,而列表自身的纵向内边距是 0,
// 于是「列表视口底边」与「加载更多」贴在一起,末行正好被切在半行上。 // 于是「列表视口底边」与「加载更多」贴在一起,末行正好被切在半行上。
let mut list = column![parts::list(ui, catalog, model, rails)] let mut list = column![parts::list(ui, catalog, model, rails, mode)]
.spacing(ui.space(Space::X2)) .spacing(ui.space(Space::X2))
.width(Length::Fill) .width(Length::Fill);
.height(Length::Fill); // 列表高度:`Split` 吃掉剩余高度(自己滚);`ScrollWhole` 按内容铺开(外层滚)。
list = if mode.list_scrolls() {
list.height(Length::Fill)
} else {
list.height(Length::Shrink)
};
// 紧凑档没有独立详情列:详情压在列表下面(整页替换的成本比「多一块」高) // 紧凑档没有独立详情列:详情压在列表下面(整页替换的成本比「多一块」高)
if !rails.tier.has_detail() { if !rails.tier.has_detail() {
list = list list = list
@@ -191,10 +210,11 @@ fn main_column<'a>(
list.push(parts::more(ui, catalog, model)).into() list.push(parts::more(ui, catalog, model)).into()
}; };
// `ScrollWhole` 档:主列整列一个滚动区(列表按自然高度)。**只有这一处**加滚动条,
// 不在列表里再套一个(双滚动是「到处加滚动条糊」的典型症状)。
let mut column = column![] let mut column = column![]
.spacing(ui.space(Space::X2)) .spacing(ui.space(Space::X2))
.width(Length::Fixed(rails.list)) .width(Length::Fixed(rails.list));
.height(Length::Fill);
// M113: import/export —— 只有整合包这一类才有「把本地的包装进来 / 把实例打出去」。 // M113: import/export —— 只有整合包这一类才有「把本地的包装进来 / 把实例打出去」。
// 其它三类(Mod / 资源包 / 光影)的导入就是把文件拖进目录,不属于本条任务。 // 其它三类(Mod / 资源包 / 光影)的导入就是把文件拖进目录,不属于本条任务。
@@ -204,7 +224,20 @@ fn main_column<'a>(
.push(divider::full(ui)); .push(divider::full(ui));
} }
column.push(head).push(body).into() column = column.push(head).push(body);
if mode.list_scrolls() {
return column.height(Length::Fill).into();
}
iced::widget::scrollable(column.height(Length::Shrink))
.direction(iced::widget::scrollable::Direction::Vertical(
iced::widget::scrollable::Scrollbar::new()
.width(frame::SCROLLBAR_WIDTH)
.scroller_width(frame::SCROLLBAR_WIDTH)
.margin(frame::SCROLLBAR_MARGIN),
))
.height(Length::Fill)
.style(frame::scroll_style(ui))
.into()
} }
/// 详情列(只在 `Wide` / `Narrow` 存在)。 /// 详情列(只在 `Wide` / `Narrow` 存在)。
+13
View File
@@ -114,6 +114,19 @@ pub fn page<'a>(
.into() .into()
} }
/// 页面正文的**可用高度**(窗口高 − 自绘标题栏 − 页头带 − 上下页边距)。
///
/// M161:内容库要靠它决定「矮窗口换版面」。三个被减掉的量都取自 [`page`]/[`header_bar`]
/// 用的**同一个**来源(`Ui::metrics` 与 [`FrameMetrics::gutter`]),不另抄字面量——
/// 谁改了页头高,这里跟着变。
#[must_use]
pub fn body_height(ui: &Ui, metrics: FrameMetrics, window_height: f32, has_subtitle: bool) -> f32 {
let chrome = ui.metrics().titlebar_height
+ ui.metrics().header_height(has_subtitle)
+ 2.0 * metrics.gutter;
(window_height - chrome).max(0.0)
}
fn header_bar<'a>(ui: &'a Ui, metrics: FrameMetrics, header: Header<'a>) -> Element<'a, Message> { fn header_bar<'a>(ui: &'a Ui, metrics: FrameMetrics, header: Header<'a>) -> Element<'a, Message> {
let spec = Component::TopAppBar.spec(); let spec = Component::TopAppBar.spec();
// 16.4:标题 TitleLarge(emphasized w500),副标题 LabelMedium + OnSurfaceVariant // 16.4:标题 TitleLarge(emphasized w500),副标题 LabelMedium + OnSurfaceVariant
+1 -1
View File
@@ -140,7 +140,7 @@ pub fn run(request: Request) -> Result<(), String> {
let written = Arc::clone(&written); let written = Arc::clone(&written);
iced::application( iced::application(
move || { move || {
let mut model = demo_model(boot.screen, boot.width); let mut model = demo_model(boot.screen, boot.width, boot.height);
// 截图通道把**消费 forced 的那批控件**(实例页)钉成常态,与产品 `--screenshot` // 截图通道把**消费 forced 的那批控件**(实例页)钉成常态,与产品 `--screenshot`
// 的 `ForcedState::Rest` 同一条纪律。它管不到设置页 / 外观区(那边不读 forced), // 的 `ForcedState::Rest` 同一条纪律。它管不到设置页 / 外观区(那边不读 forced),
// 那两屏的密闭靠窗口级 passthrough——别把这条当成万能的。 // 那两屏的密闭靠窗口级 passthrough——别把这条当成万能的。
@@ -61,12 +61,17 @@ const PAGE_SOURCES: [(&str, &str); 6] = [
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 | /// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) | /// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) | /// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
const ALLOWED_LITERALS: [&str; 19] = [ const ALLOWED_LITERALS: [&str; 22] = [
"{date} {time} UTC", "{date} {time} UTC",
"isolated", "isolated",
"shared", "shared",
"pid", "pid",
"reason", // M161:`InstancesFailed` 拆成「阶段 + 原文」之后,失败行改由 `LaunchStageFailed` 渲染,
// 它的两个占位符名是 `{stage}` / `{detail}`(占位符名不是文案)。
"stage",
"detail",
"outcome",
"seconds",
"Instances", "Instances",
// M157:图形后端三态的**稳定 id**(与 core 的 `GraphicsBackend::IDS` 同一份词表), // M157:图形后端三态的**稳定 id**(与 core 的 `GraphicsBackend::IDS` 同一份词表),
// `backend_label` 只认这三档,其余原样上屏。 // `backend_label` 只认这三档,其余原样上屏。
@@ -87,6 +92,7 @@ const ALLOWED_LITERALS: [&str; 19] = [
"1.20.4", "1.20.4",
"C:/Java/temurin-21/bin/java.exe", "C:/Java/temurin-21/bin/java.exe",
"ULL_LAUNCH_ARGS_FIXTURE", "ULL_LAUNCH_ARGS_FIXTURE",
// M161:示例「被门槛挡下」的取值(夹具)。
"gated", "gated",
]; ];
@@ -219,11 +225,23 @@ fn the_running_line_keeps_its_number_and_separator() {
assert!(text.contains(" · pid "), "分隔符或 pid 变形了:{text}"); assert!(text.contains(" · pid "), "分隔符或 pid 变形了:{text}");
} }
// 失败那一行同理:core 给的原因原文原样保留,只翻前半句 // 失败那一行同理(M161:`InstancesFailed` 已被 `LaunchStageFailed` 取代——「哪一步」也要翻,
let failed_en = Catalog::of(Lang::En).render(Key::InstancesFailed, &[("reason", "exit 1")]); // 只有 `detail` 那段原文原样保留)
let failed_zh = Catalog::of(Lang::ZhCn).render(Key::InstancesFailed, &[("reason", "exit 1")]); let args = [("stage", "Starting the game process"), ("detail", "exit 1")];
assert_eq!(failed_en, "Launch failed · exit 1"); let failed_en = Catalog::of(Lang::En).render(Key::LaunchStageFailed, &args);
assert_eq!(failed_zh, "启动失败 · exit 1"); let failed_zh = Catalog::of(Lang::ZhCn).render(
Key::LaunchStageFailed,
&[("stage", "启动游戏进程"), ("detail", "exit 1")],
);
assert_eq!(
failed_en,
"Launch failed · Starting the game process: exit 1"
);
assert_eq!(failed_zh, "启动失败 · 启动游戏进程:exit 1");
for text in [&failed_en, &failed_zh] {
assert!(text.contains("exit 1"), "原文丢了:{text}");
}
assert_ne!(failed_en, failed_zh, "切换语言时前半句要变");
} }
/// **页面里不许再写死界面文案**(白名单):带 ASCII 单词的字面量必须在 [`ALLOWED_LITERALS`] 里。 /// **页面里不许再写死界面文案**(白名单):带 ASCII 单词的字面量必须在 [`ALLOWED_LITERALS`] 里。
+10 -3
View File
@@ -230,7 +230,11 @@ fn render<'a>(
// M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。 // M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。
Screen::Download => download::view(ui, metrics, &model.download, model.lang), Screen::Download => download::view(ui, metrics, &model.download, model.lang),
// M109:内容库页同样跟 `model.lang` // M109:内容库页同样跟 `model.lang`
Screen::Content => content::view(ui, metrics, &model.content, model.lang), // M161:内容库要按**窗口高度**选版面(矮窗口不能把列表压扁),
// 正文可用高由 `frame::body_height` 从窗口高减掉外壳算出(唯一来源)。
Screen::Content => {
content::view(ui, metrics, &model.content, model.lang, model.window_height)
}
// M159:设置页「更新」组要画 presenter 的更新快照(不在 `States` 里,理由见该组文档)。 // M159:设置页「更新」组要画 presenter 的更新快照(不在 `States` 里,理由见该组文档)。
Screen::Settings => settings::view(ui, metrics, states, language, &model.update), Screen::Settings => settings::view(ui, metrics, states, language, &model.update),
Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang), Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang),
@@ -304,8 +308,11 @@ pub fn app_with_appearance<'a>(
/// 演示模型(`pages_demo` example 与单测用;真页面走 `app/view.rs` 的 `AppState` 转换)。 /// 演示模型(`pages_demo` example 与单测用;真页面走 `app/view.rs` 的 `AppState` 转换)。
/// ///
/// 实例 / 下载 / 日志三页带演示数据,设置页仍是占位视觉。 /// 实例 / 下载 / 日志三页带演示数据,设置页仍是占位视觉。
pub fn demo_model(screen: Screen, window_width: f32) -> Model { ///
let mut model = Model::new(screen, window_width, Some(screen.nav())); /// 高度也是模型的一部分(M161):内容库在矮窗口下要换版面,出图时 `--height` 必须
/// 真的传进来,否则「矮窗口那几张图」画的是高窗口的版面。
pub fn demo_model(screen: Screen, window_width: f32, window_height: f32) -> Model {
let mut model = Model::new(screen, window_width, window_height, Some(screen.nav()));
match screen { match screen {
Screen::Instances => model.instances = instances::demo(), Screen::Instances => model.instances = instances::demo(),
Screen::Download => model.download = download::demo(unlucky_i18n::Lang::En.catalog()), Screen::Download => model.download = download::demo(unlucky_i18n::Lang::En.catalog()),
+12 -1
View File
@@ -107,6 +107,11 @@ pub struct Model {
pub screen: Screen, pub screen: Screen,
/// 窗口宽度(**含外壳**:布局档位判据就是它) /// 窗口宽度(**含外壳**:布局档位判据就是它)
pub window_width: f32, pub window_width: f32,
/// 窗口高度(**含外壳**)。
///
/// 只有「矮窗口要换版面」这一类判据用它(M161:内容库在低高度下不能把列表压扁)——
/// 宽度档位仍只看 [`Self::window_width`]。
pub window_height: f32,
/// 导航轨高亮项(`None` = 没有高亮,如 AI 独立页) /// 导航轨高亮项(`None` = 没有高亮,如 AI 独立页)
pub active: Option<NavSection>, pub active: Option<NavSection>,
/// 实例页的数据(其它三页还是占位视觉,不读) /// 实例页的数据(其它三页还是占位视觉,不读)
@@ -134,10 +139,16 @@ pub struct Model {
impl Model { impl Model {
/// 空模型(只有外壳参数,页面数据全空)——窄窗退化与空态的构造入口。 /// 空模型(只有外壳参数,页面数据全空)——窄窗退化与空态的构造入口。
pub fn new(screen: Screen, window_width: f32, active: Option<NavSection>) -> Self { pub fn new(
screen: Screen,
window_width: f32,
window_height: f32,
active: Option<NavSection>,
) -> Self {
Self { Self {
screen, screen,
window_width, window_width,
window_height,
active, active,
instances: Instances::default(), instances: Instances::default(),
download: Download::loading(), download: Download::loading(),
@@ -107,7 +107,7 @@ fn the_banner_priority_is_ready_then_applying_then_failure_then_available() {
/// 演示模型里更新组能画(`pages_demo` 的 harness 路径):`Model.update` 有默认值,不是 `None` 才画。 /// 演示模型里更新组能画(`pages_demo` 的 harness 路径):`Model.update` 有默认值,不是 `None` 才画。
#[test] #[test]
fn the_demo_model_carries_an_update_snapshot() { fn the_demo_model_carries_an_update_snapshot() {
let model = crate::pages::demo_model(Screen::Settings, 1180.0); let model = crate::pages::demo_model(Screen::Settings, 1180.0, 760.0);
assert_eq!(model.update, crate::pages::update::demo()); assert_eq!(model.update, crate::pages::update::demo());
assert!( assert!(
model.update.available().is_some(), model.update.available().is_some(),
@@ -115,9 +115,73 @@ fn the_demo_model_carries_an_update_snapshot() {
); );
// 其它页不掺演示更新状态(实例页/下载页的基线图不该多出一条提示条)。 // 其它页不掺演示更新状态(实例页/下载页的基线图不该多出一条提示条)。
assert_eq!( assert_eq!(
crate::pages::demo_model(Screen::Instances, 1180.0).update, crate::pages::demo_model(Screen::Instances, 1180.0, 760.0).update,
Update::default() Update::default()
); );
let catalog: Catalog = Lang::En.catalog(); let catalog: Catalog = Lang::En.catalog();
assert!(!catalog.text(Key::SettingsGroupUpdate).is_empty()); assert!(!catalog.text(Key::SettingsGroupUpdate).is_empty());
} }
/// **忙碌时按钮禁用,且禁用态与状态行同源**(M161 收的债:以前点了没反应)。
///
/// 三条判据:
/// ① `Checking` / `applying` 时 `can_check()` 与 `can_apply()` 都是 false(按钮画成置灰);
/// ② 这两态下设置页的状态行**必须**是「正在检查…」/「正在安装…」——否则用户看到一颗灰按钮
/// 却没有任何解释;
/// ③ 结论回来(失败也算)之后恢回可点:超时/网络失败不许把按钮永久钉死。
#[test]
fn the_buttons_are_disabled_exactly_while_something_is_running() {
use super::super::update::status_line;
let catalog = Catalog::of(Lang::ZhCn);
let available = UpdateStatus::Available {
version: "0.2.3".to_string(),
size: 1024,
notes: String::new(),
forced: false,
};
// 静息:有候选 ⇒ 两颗按钮都能点。
let idle = Update {
status: available.clone(),
..Update::default()
};
assert!(!idle.busy());
assert!(idle.can_check(), "静息时「立即检查」可点");
assert!(idle.can_apply(), "有候选且不忙 ⇒ 「下载并安装」可点");
// 正在查:两颗都置灰,状态行说「正在检查」。
let checking = Update {
status: UpdateStatus::Checking,
..idle.clone()
};
assert!(checking.busy(), "Checking 就是忙碌");
assert!(!checking.can_check(), "查的时候不许再点「立即检查」");
assert!(!checking.can_apply(), "查的时候不许装(presenter 也拒)");
assert_eq!(
status_line(catalog, &checking).0,
catalog.text(Key::SettingsUpdateChecking),
"灰按钮旁边必须有「正在检查…」这句话"
);
// 正在装:`applying` 也算忙碌(此时 status 可能仍是 Available)。
let applying = Update {
applying: true,
..idle.clone()
};
assert!(applying.busy());
assert!(!applying.can_apply(), "装的时候安装键置灰,不是消失");
assert!(!applying.can_check(), "装的时候也不许再来一条检查");
// 结论回来(这里是最坏的结论:失败)⇒ 恢回可点。
let failed = Update {
status: UpdateStatus::Failed {
reason: "连不上".to_string(),
retryable: true,
},
..Update::default()
};
assert!(!failed.busy(), "失败 = 这一轮结束了");
assert!(failed.can_check(), "失败之后必须能重试");
assert!(!failed.can_apply(), "没有候选时安装键不可点");
}
+35 -23
View File
@@ -73,7 +73,7 @@ pub(super) fn body<'a>(ui: &'a Ui, update: &'a Update, lang: Lang) -> Element<'a
channel_label(catalog, &update.channel), channel_label(catalog, &update.channel),
)); ));
stacked = stacked.push(divider(ui)); stacked = stacked.push(divider(ui));
stacked = stacked.push(auto_check_row(ui, catalog, update.auto_check)); stacked = stacked.push(auto_check_row(ui, catalog, update));
stacked = stacked.push(divider(ui)); stacked = stacked.push(divider(ui));
stacked = stacked.push(status_block(ui, catalog, update)); stacked = stacked.push(status_block(ui, catalog, update));
@@ -117,23 +117,30 @@ fn info_row<'a>(ui: &'a Ui, label: &'a str, value: String) -> Element<'a, Messag
} }
/// 自动检查开关 + 「立即检查」按钮。 /// 自动检查开关 + 「立即检查」按钮。
fn auto_check_row<'a>(ui: &'a Ui, catalog: Catalog, auto_check: bool) -> Element<'a, Message> { ///
/// **忙碌时「立即检查」置灰**(M161):此前它永远可点,而 presenter 的 `begin_check` 在
/// inflight 时静默 `return None`——用户点了没反应,还以为启动器卡了。禁用态与状态行同源
/// (同一个 [`Update::busy`]):按钮灰着的时候,下面那句状态行一定是「正在检查…」/「正在安装…」。
fn auto_check_row<'a>(ui: &'a Ui, catalog: Catalog, update: &Update) -> Element<'a, Message> {
let check_now = catalog.text(Key::SettingsUpdateCheckNow);
let check = if update.can_check() {
buttons::text(
ui,
Button::new(check_now, Message::Update(UpdateAction::CheckNow)),
)
} else {
buttons::text(ui, Button::disabled(check_now))
};
row![ row![
chip( chip(
ui, ui,
Chip::filter( Chip::filter(
catalog.text(Key::SettingsUpdateAutoCheck), catalog.text(Key::SettingsUpdateAutoCheck),
auto_check, update.auto_check,
Message::Update(UpdateAction::SetAutoCheck(!auto_check)), Message::Update(UpdateAction::SetAutoCheck(!update.auto_check)),
),
),
buttons::text(
ui,
Button::new(
catalog.text(Key::SettingsUpdateCheckNow),
Message::Update(UpdateAction::CheckNow),
), ),
), ),
check,
] ]
.spacing(ui.space(Space::X3)) .spacing(ui.space(Space::X3))
.width(Length::Fill) .width(Length::Fill)
@@ -171,17 +178,21 @@ fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element
}); });
block = block.push(text_role_owned(ui, TypeRole::BodySmall, false, text, color)); block = block.push(text_role_owned(ui, TypeRole::BodySmall, false, text, color));
if let Some((_version, _size, notes, forced)) = update.available().filter(|_| !update.applying) if let Some((_version, _size, notes, forced)) = update.available() {
{ // 有候选就**永远画出**这一颗按钮:忙碌时置灰(`can_apply` = false),而不是整颗消失。
let mut actions = row![buttons::filled( // 消失会让人以为「装到一半的功能没了」;灰着 + 状态行那句「正在安装…」才说得清。
ui, let apply_label = catalog.text(Key::SettingsUpdateApply);
Button::new( let apply = if update.can_apply() {
catalog.text(Key::SettingsUpdateApply), buttons::filled(
Message::Update(UpdateAction::Apply), ui,
), Button::new(apply_label, Message::Update(UpdateAction::Apply)),
)] )
.spacing(ui.space(Space::X2)) } else {
.align_y(Alignment::Center); buttons::filled(ui, Button::disabled(apply_label))
};
let mut actions = row![apply]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
if forced { if forced {
actions = actions.push(text_role_owned( actions = actions.push(text_role_owned(
ui, ui,
@@ -234,7 +245,8 @@ fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element
/// 一条状态行(文案 + 是不是「错」那一类)。 /// 一条状态行(文案 + 是不是「错」那一类)。
/// ///
/// 四种结论各写各的句子,**不共用**「已是最新」那句:失败/缺产物时那句就是撒谎。 /// 四种结论各写各的句子,**不共用**「已是最新」那句:失败/缺产物时那句就是撒谎。
fn status_line(catalog: Catalog, update: &Update) -> (String, bool) { /// `pub(super)` 只为让 `settings/tests` 能断言「忙碌时状态行与按钮禁用态同源」。
pub(super) fn status_line(catalog: Catalog, update: &Update) -> (String, bool) {
let (key, args, error) = match &update.status { let (key, args, error) = match &update.status {
UpdateStatus::Idle => (Key::SettingsUpdateNever, Vec::new(), false), UpdateStatus::Idle => (Key::SettingsUpdateNever, Vec::new(), false),
UpdateStatus::Checking => (Key::SettingsUpdateChecking, Vec::new(), false), UpdateStatus::Checking => (Key::SettingsUpdateChecking, Vec::new(), false),
+4 -4
View File
@@ -57,7 +57,7 @@ fn every_screen_builds_at_the_three_comparison_widths() {
let form = demo_launch_form(); let form = demo_launch_form();
for width in [1180.0, 900.0, 700.0] { for width in [1180.0, 900.0, 700.0] {
for screen in Screen::ALL { for screen in Screen::ALL {
let model = demo_model(screen, width); let model = demo_model(screen, width, 760.0);
let _element: Element<'_, Message> = app(&ui, &model, &form); let _element: Element<'_, Message> = app(&ui, &model, &form);
} }
} }
@@ -69,7 +69,7 @@ fn an_empty_snapshot_still_builds() {
let ui = ui(); let ui = ui();
let form = demo_launch_form(); let form = demo_launch_form();
for screen in Screen::ALL { for screen in Screen::ALL {
let model = Model::new(screen, 1180.0, Some(screen.nav())); let model = Model::new(screen, 1180.0, 760.0, Some(screen.nav()));
let _element: Element<'_, Message> = app(&ui, &model, &form); let _element: Element<'_, Message> = app(&ui, &model, &form);
} }
} }
@@ -368,7 +368,7 @@ fn every_rail_item_points_at_a_route_that_highlights_it() {
/// 演示模型:12 行 + 1 条选中详情(M84 的出图基线就靠这份数据)。 /// 演示模型:12 行 + 1 条选中详情(M84 的出图基线就靠这份数据)。
#[test] #[test]
fn the_demo_model_carries_the_m84_baseline() { fn the_demo_model_carries_the_m84_baseline() {
let model = demo_model(Screen::Instances, 1180.0); let model = demo_model(Screen::Instances, 1180.0, 760.0);
assert_eq!(model.instances.rows.len(), 12); assert_eq!(model.instances.rows.len(), 12);
let detail = model.instances.detail.as_ref().expect("演示数据有选中项"); let detail = model.instances.detail.as_ref().expect("演示数据有选中项");
assert_eq!(detail.meta.len(), 6, "元信息 6 格"); assert_eq!(detail.meta.len(), 6, "元信息 6 格");
@@ -377,7 +377,7 @@ fn the_demo_model_carries_the_m84_baseline() {
// 其余三页不带实例数据(占位视觉不读它) // 其余三页不带实例数据(占位视觉不读它)
for screen in [Screen::Download, Screen::Settings, Screen::Logs] { for screen in [Screen::Download, Screen::Settings, Screen::Logs] {
let model = demo_model(screen, 1180.0); let model = demo_model(screen, 1180.0, 760.0);
assert!( assert!(
model.instances.rows.is_empty(), model.instances.rows.is_empty(),
"{} 不该带实例数据", "{} 不该带实例数据",
+33 -56
View File
@@ -102,6 +102,8 @@ pub struct Update {
pub silent: bool, pub silent: bool,
} }
mod busy;
impl Update { impl Update {
/// 有没有可装的候选(有的话设置页给「下载并安装」)。 /// 有没有可装的候选(有的话设置页给「下载并安装」)。
#[must_use] #[must_use]
@@ -155,8 +157,37 @@ impl Update {
/// 出图 / 单测用的演示快照(一个可装的新版本 + 一次成功的检查)。 /// 出图 / 单测用的演示快照(一个可装的新版本 + 一次成功的检查)。
/// ///
/// **不是产品数据**:产品路径的更新状态只来自 presenter(`app/view/update.rs` 转换)。 /// **不是产品数据**:产品路径的更新状态只来自 presenter(`app/view/update.rs` 转换)。
///
/// 取证开关 `ULL_UPDATE_STATE`(M161,沿用 `ULL_CONTENT_*` 那套范式):不设 = 缺省那一态;
/// `checking` / `applying` 用来出「按钮置灰 + 状态行说正在干活」的那两张图,
/// `failed` / `up_to_date` 用来出另外两条结论。
#[must_use] #[must_use]
pub fn demo() -> Update { pub fn demo() -> Update {
let mut snapshot = demo_available();
match std::env::var("ULL_UPDATE_STATE")
.unwrap_or_default()
.as_str()
{
"checking" => snapshot.status = UpdateStatus::Checking,
"applying" => snapshot.applying = true,
"failed" => {
snapshot.status = UpdateStatus::Failed {
reason: "Could not fetch the release manifest: connection reset".to_string(),
retryable: true,
}
}
"up_to_date" => {
snapshot.status = UpdateStatus::UpToDate {
latest: Some("0.2.2".to_string()),
}
}
_ => {}
}
snapshot
}
/// 缺省那一态(可装的新版本)。
fn demo_available() -> Update {
Update { Update {
current_version: "0.2.2".to_string(), current_version: "0.2.2".to_string(),
channel: "stable".to_string(), channel: "stable".to_string(),
@@ -332,60 +363,6 @@ pub fn banner<'a>(
.into(), .into(),
) )
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "update/tests.rs"]
use super::*; mod tests;
use unlucky_i18n::Lang;
/// 提示条的正文**同时**给体积与说明摘要(mission:提示条含版本号、体积、说明摘要)。
#[test]
fn the_available_banner_carries_both_size_and_notes() {
for lang in [Lang::En, Lang::ZhCn] {
let body = available_body(lang.catalog(), 21_500_000, "修掉启动崩溃");
assert!(body.contains("20.5 MB"), "{lang:?} 缺体积:{body}");
assert!(body.contains("修掉启动崩溃"), "{lang:?} 缺说明:{body}");
}
// 发布没写说明时只留体积,不留一个空尾巴。
let bare = available_body(Lang::En.catalog(), 1024, " ");
assert!(bare.contains("1.0 KB"), "{bare}");
assert!(!bare.contains('·'), "没有说明时不该留分隔符:{bare}");
}
/// 视图模型的提示条判据与 presenter 同序(就位 > 进行中 > 失败 > 新版本)。
#[test]
fn the_banner_priority_matches_the_presenter() {
let mut update = Update {
status: UpdateStatus::Available {
version: "0.2.3".to_string(),
size: 1,
notes: String::new(),
forced: false,
},
..Update::default()
};
assert!(matches!(update.banner(), Some(Banner::Available { .. })));
update.skipped = Some("0.2.3".to_string());
assert!(update.banner().is_none(), "忽略之后不再打扰");
// 忽略的是**那一个版本**:同一次运行里冒出 0.2.4 仍然提示(与 presenter 同一语义)。
update.status = UpdateStatus::Available {
version: "0.2.4".to_string(),
size: 1,
notes: String::new(),
forced: false,
};
assert!(
matches!(update.banner(), Some(Banner::Available { version, .. }) if version == "0.2.4"),
"0.2.3 被忽略不该压住 0.2.4"
);
update.skipped = None;
update.applying = true;
assert!(matches!(update.banner(), Some(Banner::Applying)));
update.ready = Some("0.2.3".to_string());
assert!(matches!(update.banner(), Some(Banner::Ready { .. })));
}
}
+43
View File
@@ -0,0 +1,43 @@
//! 「这一条更新动作此刻能不能点」(M161:把「按钮亮着、点了没反应」收掉)。
//!
//! 判据只有一条:有没有检查/下载正在跑。三处必须同源——
//!
//! | 处 | 读的是 |
//! |---|---|
//! | presenter `Runtime::inflight()` | `begin_check` / `begin_apply` 置位,`adopt` 清位 |
//! | 设置页两颗按钮 | [`Update::can_check`] / [`Update::can_apply`](本文件) |
//! | 设置页状态行 | 「正在检查…」/「正在安装…」(同一份 `busy()` 判据) |
//! | app 的消息入口 | `App::update_busy`(`app/update/update_action.rs`)——最后一道闸 |
//!
//! 为什么拆分到本文件:`pages/update.rs` 贴着 400 行硬线(AGENTS §3-1),
//! 而这一组判据是「独立的一块问题」(能不能点),独立成文件后更好找。
use super::{Update, UpdateStatus};
impl Update {
/// 有一条检查/下载正在跑。
///
/// 两个字段都要看:`status == Checking` 是「查版本」,`applying` 是「下载/校验/替换」。
/// presenter 侧同一个判据是 `Runtime::inflight()`(`begin_check` / `begin_apply` 置位,
/// `adopt` 清位),页面这层是它的**只读投影**——按钮的禁用态必须与它同源,
/// 否则会出现「按钮亮着、点了没反应」。
#[must_use]
pub fn busy(&self) -> bool {
self.applying || matches!(self.status, UpdateStatus::Checking)
}
/// 「立即检查」此刻能点吗。
///
/// 忙碌时**禁用而不是消失**:入口消失会让人以为功能没了,禁用 + 状态行那句
/// 「正在检查…」才说得清「它在干活,等一下」。
#[must_use]
pub fn can_check(&self) -> bool {
!self.busy()
}
/// 「下载并安装」此刻能点吗(要有候选,且不忙)。
#[must_use]
pub fn can_apply(&self) -> bool {
self.available().is_some() && !self.busy()
}
}
+56
View File
@@ -0,0 +1,56 @@
//! 自更新视图模型的单测(从 `pages/update.rs` 拆出,那边贴着 400 行硬线)。
use super::*;
use unlucky_i18n::Lang;
/// 提示条的正文**同时**给体积与说明摘要(mission:提示条含版本号、体积、说明摘要)。
#[test]
fn the_available_banner_carries_both_size_and_notes() {
for lang in [Lang::En, Lang::ZhCn] {
let body = available_body(lang.catalog(), 21_500_000, "修掉启动崩溃");
assert!(body.contains("20.5 MB"), "{lang:?} 缺体积:{body}");
assert!(body.contains("修掉启动崩溃"), "{lang:?} 缺说明:{body}");
}
// 发布没写说明时只留体积,不留一个空尾巴。
let bare = available_body(Lang::En.catalog(), 1024, " ");
assert!(bare.contains("1.0 KB"), "{bare}");
assert!(!bare.contains('·'), "没有说明时不该留分隔符:{bare}");
}
/// 视图模型的提示条判据与 presenter 同序(就位 > 进行中 > 失败 > 新版本)。
#[test]
fn the_banner_priority_matches_the_presenter() {
let mut update = Update {
status: UpdateStatus::Available {
version: "0.2.3".to_string(),
size: 1,
notes: String::new(),
forced: false,
},
..Update::default()
};
assert!(matches!(update.banner(), Some(Banner::Available { .. })));
update.skipped = Some("0.2.3".to_string());
assert!(update.banner().is_none(), "忽略之后不再打扰");
// 忽略的是**那一个版本**:同一次运行里冒出 0.2.4 仍然提示(与 presenter 同一语义)。
update.status = UpdateStatus::Available {
version: "0.2.4".to_string(),
size: 1,
notes: String::new(),
forced: false,
};
assert!(
matches!(update.banner(), Some(Banner::Available { version, .. }) if version == "0.2.4"),
"0.2.3 被忽略不该压住 0.2.4"
);
update.skipped = None;
update.applying = true;
assert!(matches!(update.banner(), Some(Banner::Applying)));
update.ready = Some("0.2.3".to_string());
assert!(matches!(update.banner(), Some(Banner::Ready { .. })));
}
+55 -21
View File
@@ -32,6 +32,10 @@ use crate::output::Report;
/// 敏感值在输出里的占位。 /// 敏感值在输出里的占位。
const MASK: &str = "<redacted>"; const MASK: &str = "<redacted>";
mod prefs_migration;
use prefs_migration::migrate_update_prefs;
/// `ull config get [<key>] [--show-secret]`。 /// `ull config get [<key>] [--show-secret]`。
/// ///
/// # Errors /// # Errors
@@ -46,6 +50,9 @@ pub fn get(ctx: &Ctx, key: Option<&str>, show_secret: bool) -> Result<Report, Cl
/// 单键 get:读该键自己的落点文件(数据根键走便携目录那份)。 /// 单键 get:读该键自己的落点文件(数据根键走便携目录那份)。
fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError> { fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError> {
// 先跑一次遗留偏好迁移(幂等;没有旧文件时只是一次 stat)。必须在 `load` **之前**:
// 迁移写的正是这份 `launcher.json`,先读后迁会让这一条输出还是旧值。
let migration = migrate_update_prefs(ctx);
let path = ctx.setting_path_for(key); let path = ctx.setting_path_for(key);
let store = SettingStore::load(&path, launcher_schema()); let store = SettingStore::load(&path, launcher_schema());
let schema = store.doc().schema().clone(); let schema = store.doc().schema().clone();
@@ -65,7 +72,7 @@ fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError>
value.clone() value.clone()
}; };
let problems = render_problems(&store); let problems = render_problems(&store);
let data = json!({ let mut data = json!({
"key": key, "key": key,
"value": shown, "value": shown,
"redacted": hidden, "redacted": hidden,
@@ -83,6 +90,12 @@ fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError>
} }
)]; )];
human.push(format!("来自 {}", path.display())); human.push(format!("来自 {}", path.display()));
if let Some(note) = migration {
human.push(note.human);
if let Some(object) = data.as_object_mut() {
object.insert("migration".to_string(), note.data);
}
}
for problem in &problems { for problem in &problems {
human.push(format!("问题:{problem}")); human.push(format!("问题:{problem}"));
} }
@@ -92,6 +105,7 @@ fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError>
/// 无键全量 dump:以数据根那份为底,`core.*` 数据根键换成便携目录那份的生效值。 /// 无键全量 dump:以数据根那份为底,`core.*` 数据根键换成便携目录那份的生效值。
fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> { fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
let migration = migrate_update_prefs(ctx);
let path = ctx.launcher_config(); let path = ctx.launcher_config();
let store = SettingStore::load(&path, launcher_schema()); let store = SettingStore::load(&path, launcher_schema());
let schema = store.doc().schema().clone(); let schema = store.doc().schema().clone();
@@ -140,27 +154,33 @@ fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
ctx.data.game_home.display() ctx.data.game_home.display()
)); ));
human.extend(text.lines().map(str::to_string)); human.extend(text.lines().map(str::to_string));
if let Some(note) = &migration {
human.push(note.human.clone());
}
for problem in &problems { for problem in &problems {
human.push(format!("问题:{problem}")); human.push(format!("问题:{problem}"));
} }
if masked { if masked {
human.push("(敏感值已掩码;加 --show-secret 显示明文)".to_string()); human.push("(敏感值已掩码;加 --show-secret 显示明文)".to_string());
} }
Ok(Report::new( let mut data = json!({
json!({ "path": path.display().to_string(),
"path": path.display().to_string(), "core_path": core_path.as_ref().map(|core| core.display().to_string()),
"core_path": core_path.as_ref().map(|core| core.display().to_string()), "config": document,
"config": document, "redacted": masked,
"redacted": masked, "problems": problems,
"problems": problems, "data_root": {
"data_root": { "mode": ctx.data.mode.as_str(),
"mode": ctx.data.mode.as_str(), "launcher_home": ctx.data.launcher_home.display().to_string(),
"launcher_home": ctx.data.launcher_home.display().to_string(), "game_home": ctx.data.game_home.display().to_string(),
"game_home": ctx.data.game_home.display().to_string(), },
}, });
}), if let Some(note) = migration {
human, if let Some(object) = data.as_object_mut() {
)) object.insert("migration".to_string(), note.data);
}
}
Ok(Report::new(data, human))
} }
/// `ull config set <key> <value>`。 /// `ull config set <key> <value>`。
@@ -171,6 +191,8 @@ fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
/// ///
/// 键不在 schema、值不合法、文件只读/不可读、写盘失败时返回用户错误。 /// 键不在 schema、值不合法、文件只读/不可读、写盘失败时返回用户错误。
pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> { pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> {
// 同一份遗留偏好先迁进来:否则用户「set 一个、get 另一个」会看到两份真相。
let migration = migrate_update_prefs(ctx);
let path = ctx.setting_path_for(key); let path = ctx.setting_path_for(key);
let mut store = SettingStore::load(&path, launcher_schema()); let mut store = SettingStore::load(&path, launcher_schema());
let sensitive = is_sensitive(store.doc().schema(), key); let sensitive = is_sensitive(store.doc().schema(), key);
@@ -195,7 +217,7 @@ pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> {
})?; })?;
match &outcome { match &outcome {
SaveOutcome::Saved(saved) => { SaveOutcome::Saved(saved) => {
let data = json!({ let mut data = json!({
"key": key, "key": key,
"old": old, "old": old,
"new": value, "new": value,
@@ -209,13 +231,25 @@ pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> {
), ),
format!("已保存:{}", saved.display()), format!("已保存:{}", saved.display()),
]; ];
if let Some(note) = migration {
human.push(note.human);
if let Some(object) = data.as_object_mut() {
object.insert("migration".to_string(), note.data);
}
}
human.extend(source_note(ctx, key)); human.extend(source_note(ctx, key));
Ok(Report::new(data, human)) Ok(Report::new(data, human))
} }
SaveOutcome::Skipped(SkipReason::NotDirty) => Ok(Report::new( SaveOutcome::Skipped(SkipReason::NotDirty) => {
json!({ "key": key, "new": value, "saved": null }), let mut human = vec![format!("{key} 已经是该值,未写盘")];
vec![format!("{key} 已经是该值,未写盘")], if let Some(note) = &migration {
)), human.push(note.human.clone());
}
Ok(Report::new(
json!({ "key": key, "new": value, "saved": null }),
human,
))
}
SaveOutcome::Skipped(reason) => { SaveOutcome::Skipped(reason) => {
let error = CliError::user( let error = CliError::user(
"config_read_only", "config_read_only",
@@ -0,0 +1,70 @@
//! M159 的遗留更新偏好 → 主配置(`update.auto_check`)的迁移(M161)。
//!
//! 从 `commands/config.rs` 拆出:那边贴着 400 行硬线,而这块是**独立的一件事**
//! (遗留文件的桥接),与 get/set 的排版逻辑无关。
use serde_json::{json, Value};
use unlucky_core::setting::update_prefs::{self, Migration};
use crate::context::Ctx;
/// M159 的遗留更新偏好 → 主配置的迁移结果(只在**有话说**时返回)。
pub(super) struct PrefsNote {
/// 人读一行(`config get/set` 的人类可读输出里追加)
pub(super) human: String,
/// 同一件事的机器可读形状(`--json` 信封里追加)
pub(super) data: Value,
}
/// 把 `<数据根>/cache/update/prefs.json` 迁进 `launcher.json`(M161)。
///
/// 为什么 `config` 这个**读命令**也会写盘:那份遗留文件对 CLI 本来就不可见——用户在手头没 GUI
/// 的机器上 `config get update.auto_check`,旧实现永远回默认 `true`,而他的开关可能是关的。
/// 迁移是**幂等**的(源指纹闸门 + 旧文件改名 `.migrated`),有收据,且在写不进去时什么都不动
/// (见 [`unlucky_core::setting::update_prefs`])。没有旧文件时这里只是一次 `stat`。
pub(super) fn migrate_update_prefs(ctx: &Ctx) -> Option<PrefsNote> {
let layout = ctx.layout();
let paths = update_prefs::Targets {
launcher: &ctx.launcher_config(),
legacy: &layout.cache().join("update").join("prefs.json"),
receipt: &layout.config().join(update_prefs::RECEIPT_FILE),
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX))
.unwrap_or(0);
let migration = update_prefs::migrate(paths, now);
let (human, detail) = match &migration {
Migration::Nothing => return None,
Migration::Migrated { auto_check, .. } => (
format!(
"已把旧更新偏好迁入主配置:update.auto_check = {auto_check}(收据 {})",
paths.receipt.display()
),
format!("update.auto_check = {auto_check} 已迁移"),
),
Migration::NewValueWins { auto_check, .. } => (
format!(
"旧更新偏好文件已退役(主配置里已有 update.auto_check = {auto_check},新值胜出;收据 {})",
paths.receipt.display()
),
"旧文件已退役,主配置的值未改动".to_string(),
),
Migration::Unreadable { path, reason } => (
format!("旧更新偏好读不出来({}):{reason}", path.display()),
"旧文件坏,未迁移".to_string(),
),
Migration::Refused { reason } => (
format!("旧更新偏好未迁移:{reason}"),
"主配置不可写,未迁移".to_string(),
),
};
Some(PrefsNote {
human,
data: json!({
"id": migration.as_str(),
"receipt": migration.receipt().map(|path| path.display().to_string()),
"detail": detail,
}),
})
}
+64
View File
@@ -254,3 +254,67 @@ fn unknown_schema_prefix_warns_and_still_shows_file_values() {
report.human report.human
); );
} }
/// M161:`ull config get update.auto_check` 读的是**主配置**,而且会把 M159 遗留的
/// `cache/update/prefs.json` 先迁进来(迁移收据在 `migration` 里可查)。
///
/// 这条是 mission「`ull config get update.auto_check` 真能读到」的端到端证明:
/// 旧文件里 `false` → 迁移 → 主配置 → CLI 输出 `false`(**不是**默认的 `true`)。
#[test]
fn the_update_switch_is_migrated_and_readable_from_the_cli() {
let temp = TempDir::new("update-prefs");
let exe = temp.path().join("exe");
let ctx = ctx_with(Globals::default(), &exe);
// 旧域内文件(M159 的落点):开关是关的。
let legacy = ctx.layout().cache().join("update").join("prefs.json");
write_json(&legacy, &json!({ "auto_check": false }));
let report = get(&ctx, Some("update.auto_check"), false).expect("读 update.auto_check");
assert_eq!(
report.data["value"],
json!(false),
"旧文件里的 false 必须读到(默认值是 true,读成默认就是丢值)"
);
assert_eq!(
report.data["path"],
json!(ctx.launcher_config().display().to_string()),
"落点必须是主配置(schema API),不是更新域自己的文件"
);
assert_eq!(
report.data["migration"]["id"],
json!("migrated"),
"迁移要走一遍并如实报出来:{report:?}"
);
let receipt = report.data["migration"]["receipt"]
.as_str()
.expect("收据路径")
.to_string();
assert!(Path::new(&receipt).is_file(), "收据可查:{receipt}");
assert!(!legacy.exists(), "旧文件必须退役");
assert!(
report
.human
.iter()
.any(|line| line.contains("已把旧更新偏好迁入主配置")),
"{:?}",
report.human
);
// 第二遍:什么都不做(幂等),值仍是 false。
let again = get(&ctx, Some("update.auto_check"), false).expect("再读一遍");
assert_eq!(again.data["value"], json!(false));
assert!(
again.data.get("migration").is_none(),
"第二遍不该再报迁移:{again:?}"
);
// `channels` 里只有 stable 时,写 beta 要当面被拒(而不是静默写成「设了没生效」)。
let error = set(&ctx, "update.channel", "beta").expect_err("beta 不在允许值里");
assert!(error.to_string().contains("beta"), "{error}");
// 允许值写得进去,且读得回来。
set(&ctx, "update.channel", "stable").expect("写 stable");
let channel = get(&ctx, Some("update.channel"), false).expect("读 update.channel");
assert_eq!(channel.data["value"], json!("stable"));
}
+11
View File
@@ -42,6 +42,17 @@ impl SettingSchema {
self self
} }
/// 一次登记一批键(M161:某个域的键表拆到自己的文件后,主表只留一行)。
///
/// 重复键的断言与 [`Self::with`] 同源(`with` 负责),所以批量登记不会绕过那条守门。
#[must_use]
pub fn with_all(mut self, specs: impl IntoIterator<Item = SettingSpec>) -> Self {
for spec in specs {
self = self.with(spec);
}
self
}
/// 种类。 /// 种类。
#[must_use] #[must_use]
pub const fn id(&self) -> SchemaId { pub const fn id(&self) -> SchemaId {
+14 -13
View File
@@ -1,7 +1,5 @@
//! `config/launcher.json` 的键表(全局设置)。 //! `config/launcher.json` 的键表(全局设置)。键名与默认值对齐 `04-data-and-config.md` §2-§3;
//! //! AI 可写 + 敏感分级按 `02-ai-agent-design.md` §3.1 落:**密钥类一律 locked**。
//! 键名与默认值对齐 `DevDocs/PlanDoc/04-data-and-config.md` §2-§3 与 `05/06/07/08` 各专题;
//! AI 可写 + 敏感分级按 `02-ai-agent-design.md` §3.1 的边界落:**密钥类一律 locked**。
use super::catalog::SettingSchema; use super::catalog::SettingSchema;
use super::schema::{SchemaId, SchemaVersion}; use super::schema::{SchemaId, SchemaVersion};
@@ -36,11 +34,13 @@ pub const INSTANCE_MODES: &[&str] = &["isolated", "shared"];
/// `java.installRoot` 未设置时的哨兵(空串 = 按数据目录模式派生落点)。 /// `java.installRoot` 未设置时的哨兵(空串 = 按数据目录模式派生落点)。
pub const JAVA_INSTALL_ROOT_UNSET: &str = ""; pub const JAVA_INSTALL_ROOT_UNSET: &str = "";
/// `java.vendorPriority` 的允许值(D23:四家发行版,顺序即优先级)。 /// `java.vendorPriority` 的允许值(D23:四家发行版,顺序即优先级)。
/// /// 与 `game::java::vendor::VENDOR_IDS` 必须同一份集合(setting 层不依赖 game 层),
/// 与 `crate::game::java::vendor::VENDOR_IDS` 必须是同一份集合——setting 层不依赖 game 层 /// 一致性由 `tests/setting/java_install_keys.rs` 的契约测试固定。
/// (分层纪律),一致性由 `tests/setting/java_install_keys.rs` 的契约测试固定。
pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "termux"]; pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "termux"];
/// 自更新两键(M161;拆到自己的文件守 400 行)。
pub mod update;
/// launcher.json 当前 schema 版本。 /// launcher.json 当前 schema 版本。
/// ///
/// `1.1.0` 登记 D20 实例隔离键(`instances.mode` 等)。 /// `1.1.0` 登记 D20 实例隔离键(`instances.mode` 等)。
@@ -49,11 +49,11 @@ pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "te
/// 迁移链(按 major 走)不需要新步骤;`1.1.0 → 1.2.0` 的差异只体现在文件里出现新键之后。 /// 迁移链(按 major 走)不需要新步骤;`1.1.0 → 1.2.0` 的差异只体现在文件里出现新键之后。
/// `1.3.0` 登记 D17 的「外观(高级)」P1 键(`appearance.bg_image*` / `panel_alpha` / `glass` / /// `1.3.0` 登记 D17 的「外观(高级)」P1 键(`appearance.bg_image*` / `panel_alpha` / `glass` /
/// `accent_color`):同一套纯增量语义——出厂全默认(高级特性全关)即等于老文件缺键。 /// `accent_color`):同一套纯增量语义——出厂全默认(高级特性全关)即等于老文件缺键。
/// `1.4.0` 登记 P4 的外观下层三键(`appearance.window_opacity` / `titlebar_mode` / /// `1.4.0` 登记 P4 的外观下层三键(`window_opacity` / `titlebar_mode` / `palette_source`,M91):
/// `palette_source`,M91 · D36/D37/D38):仍然只推 minor、不加迁移步骤——三者的出厂值 /// 仍然只推 minor、不加迁移步骤——三者的出厂值正是「老文件没有这些键」时的等价物。
/// (不透明 / auto / 内置色卡)正是「老文件没有这些键」时的等价物。 /// `1.5.0` 登记 M116 的窗口圆角策略(`appearance.window_corner`,出厂 `system`):纯增量。
/// `1.5.0` 登记 M116 的窗口圆角策略(`appearance.window_corner`,出厂 `system`): /// `1.6.0` 登记 M161 的「自更新」两键(见 [`update`]):纯增量但带一步数据迁移
/// 同一套纯增量语义——**新读旧**:老文件缺该键 ⇒ 读默认 `system` ⇒ 跟随系统,与写进文件后行为一致。 /// (此前住在 `cache/update/prefs.json`,迁移见 [`super::update_prefs`])。
/// ///
/// **旧读新(方向相反,措辞必须精确)**:1.4.0 的客户端读 1.5.0 的文件时, /// **旧读新(方向相反,措辞必须精确)**:1.4.0 的客户端读 1.5.0 的文件时,
/// [`super::schema::SchemaCompat::check_url`] 走 `found.minor > expected.minor` → /// [`super::schema::SchemaCompat::check_url`] 走 `found.minor > expected.minor` →
@@ -64,7 +64,7 @@ pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "te
/// `$schema` 那一行里,不看版本号无从排查。 /// `$schema` 那一行里,不看版本号无从排查。
#[must_use] #[must_use]
pub fn launcher_version() -> SchemaVersion { pub fn launcher_version() -> SchemaVersion {
SchemaVersion::new(1, 5, 0) SchemaVersion::new(1, 6, 0)
} }
/// `config/launcher.json` 的 schema。 /// `config/launcher.json` 的 schema。
@@ -395,4 +395,5 @@ pub fn launcher_schema() -> SettingSchema {
AiPolicy::audited(), AiPolicy::audited(),
"启动器日志级别", "启动器日志级别",
)) ))
.with_all(update::specs())
} }
+39
View File
@@ -0,0 +1,39 @@
//! 自更新两键(M161:从更新域自己的 `cache/update/prefs.json` 迁进主配置)。
//!
//! 从 `keys.rs` 拆出(那边贴着 400 行硬线):这两个键连同它们的允许值与理由一起搬,
//! 主表只留一行 `.with(update::register(..))`。
//!
//! 为什么必须进 schema:`ull config get update.auto_check` 读的是**这一份**文件;域内文件对
//! CLI 不可见(用户在 GUI 里关掉自动检查,CLI 永远报默认值 `true`)。存量迁移(幂等 + 收据)
//! 在 [`crate::setting::update_prefs`]。
use crate::setting::spec::{AiPolicy, SettingSpec};
/// `update.channel` 允许值。
///
/// D50:先只发 stable。**加值必须与宿主层同步**——`presenter::host::update::Runtime` 现在真的
/// 读这个键(`UpdateView::channel` 与检查用的渠道同源),但 core 的 `Job::check/apply` 仍按
/// 传入的渠道准入,所以只有清单里真有那个渠道的发布时才该加值。现在写死一个值:用户
/// `config set update.channel beta` 会被 schema **当面拒绝**,而不是写进文件后被静默忽略
/// (那是更坏的谎)。
pub const UPDATE_CHANNELS: &[&str] = &["stable"];
/// 两个自更新键的完整登记项(主表一行 `.with_all(specs())`)。
#[must_use]
pub fn specs() -> Vec<SettingSpec> {
vec![
SettingSpec::bool(
"update.auto_check",
true,
AiPolicy::audited(),
"启动时静默检查更新(关掉之后启动一次网络都不发;发现新版本才弹提示条)",
),
SettingSpec::enumeration(
"update.channel",
"stable",
UPDATE_CHANNELS,
AiPolicy::audited(),
"更新渠道(D50 先只发 stable;清单里没有的渠道不许写进文件)",
),
]
}
+3
View File
@@ -19,6 +19,8 @@
//! - [`store`]:单文件加载降级 + 原子保存 + 坏文件备份; //! - [`store`]:单文件加载降级 + 原子保存 + 坏文件备份;
//! - [`save`] / [`save_worker`]:200ms 去抖攒批与后台保存线程; //! - [`save`] / [`save_worker`]:200ms 去抖攒批与后台保存线程;
//! - [`migrate`]:`schemaVersion N → N+1` 迁移链 + 收据闸门。 //! - [`migrate`]:`schemaVersion N → N+1` 迁移链 + 收据闸门。
//! - [`update_prefs`]:自更新偏好(旧 `cache/update/prefs.json`)→ `launcher.json` 的一次性迁移
//! (M161:幂等、源指纹闸门、旧文件退役、收据可查)。
pub mod appearance; pub mod appearance;
pub mod catalog; pub mod catalog;
@@ -34,3 +36,4 @@ pub mod save_worker;
pub mod schema; pub mod schema;
pub mod spec; pub mod spec;
pub mod store; pub mod store;
pub mod update_prefs;
+303
View File
@@ -0,0 +1,303 @@
//! 自更新偏好 `<数据根>/cache/update/prefs.json` → `<数据根>/config/launcher.json` 的迁移(M161)。
//!
//! # 为什么要有这一步
//!
//! M159 把「启动时静默检查」临时落在更新域自己的文件里(当时 `crates/core/src/setting/**`
//! 不在那条 mission 的 scope)。后果是**同一个开关有两个真相源**:GUI 读写 `prefs.json`,
//! 而 `ull config get update.auto_check` 读 `launcher.json`——用户在 GUI 里关掉自动检查,
//! CLI 永远回答默认值 `true`。M161 把两个键([`KEY_AUTO_CHECK`] / [`KEY_CHANNEL`])登记进
//! schema,本模块负责把**存量值**搬过去。
//!
//! # 四条硬约束(每条都有对应用例,见 `tests/setting/update_keys.rs`)
//!
//! 1. **幂等**:跑两次的第二遍什么都不做。判据不是「文件还在不在」(改名可能失败),而是
//! [`crate::platform::receipt`] 里的源指纹——同一份源文件内容只迁一次;
//! 2. **不丢值**:旧文件里 `auto_check: false` 必须迁成 `false`。默认值是 `true`,所以
//! 「用默认值兜底」会把用户的关闭状态**静默打开**——这是本条里最贵的一种错;
//! 3. **新值胜出**:主配置里**已经有**这个键时绝不覆盖(用户可能在前一版 GUI 之后手改过
//! `launcher.json`,或者刚从 CLI 设过)。旧文件照样退役,但不写字;
//! 4. **旧文件迁完不能再被读**:改名为 `prefs.json.migrated`(不删——留着是取证材料,
//! 但不再有任何读路径,`Prefs::load` 已经从宿主层删掉)。
//!
//! 写不进去(只读/不可读的 `launcher.json`)时**不退役旧文件**:宁可下一次再试,
//! 也不能让用户的值在两边都不存在。
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::json;
use crate::platform::receipt::{self, MigrationReceipt};
use crate::setting::document::SettingDocument;
use crate::setting::keys::launcher_schema;
use crate::setting::store::SettingStore;
/// 主配置里的「启动时静默检查」键。
pub const KEY_AUTO_CHECK: &str = "update.auto_check";
/// 主配置里的「更新渠道」键。
pub const KEY_CHANNEL: &str = "update.channel";
/// 旧域内文件里的同义键(`serde` 的字段名,别改)。
pub const LEGACY_AUTO_CHECK: &str = "auto_check";
/// `update.auto_check` 的出厂值(与 `keys::launcher_schema` 里那份**必须一致**,契约用例守着)。
pub const DEFAULT_AUTO_CHECK: bool = true;
/// `update.channel` 的出厂值。
pub const DEFAULT_CHANNEL: &str = "stable";
/// 收据文件名(落在 `<数据根>/config/` 下;与数据目录迁移的 `migration-receipt.json` 分开)。
pub const RECEIPT_FILE: &str = "update-prefs-migration.json";
/// 旧文件退役后的后缀(`prefs.json` → `prefs.json.migrated`)。
pub const RETIRED_SUFFIX: &str = "migrated";
/// 迁移需要知道的三个路径(都由调用方按数据根算:core 不猜布局)。
#[derive(Debug, Clone, Copy)]
pub struct Targets<'a> {
/// `<数据根>/config/launcher.json`
pub launcher: &'a Path,
/// `<数据根>/cache/update/prefs.json`(旧域内文件)
pub legacy: &'a Path,
/// 收据落点(`<数据根>/config/update-prefs-migration.json`)
pub receipt: &'a Path,
}
/// 迁移结局(**每一种都要能被调用方如实转述**,不许折叠成 bool)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Migration {
/// 没有旧文件(绝大多数用户),或已经迁过(源指纹相同)。
Nothing,
/// 迁进去了:值来自旧文件。
Migrated {
/// 迁过去的取值
auto_check: bool,
/// 收据路径
receipt: PathBuf,
/// 退役后的旧文件路径
retired: PathBuf,
},
/// 主配置里已经有这个键 ⇒ 新值胜出,一个字都没写;旧文件照样退役。
NewValueWins {
/// 主配置里那个生效值
auto_check: bool,
/// 收据路径
receipt: PathBuf,
/// 退役后的旧文件路径
retired: PathBuf,
},
/// 旧文件读不出来(坏 JSON / 读失败):**什么都不动**,旧文件原样留着等下一次。
Unreadable {
/// 旧文件路径
path: PathBuf,
/// 原因(人读)
reason: String,
},
/// 主配置写不进去:不退役旧文件(用户的值不能两边都没有)。
Refused {
/// 原因(人读)
reason: String,
},
}
impl Migration {
/// 有没有真的改动主配置或旧文件。
#[must_use]
pub const fn touched(&self) -> bool {
matches!(self, Self::Migrated { .. } | Self::NewValueWins { .. })
}
/// 收据路径(没写收据时为 `None`)。
#[must_use]
pub fn receipt(&self) -> Option<&Path> {
match self {
Self::Migrated { receipt, .. } | Self::NewValueWins { receipt, .. } => Some(receipt),
_ => None,
}
}
/// 稳定 id(日志 / CLI 输出 / 测试用;**不要改名**)。
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Nothing => "nothing",
Self::Migrated { .. } => "migrated",
Self::NewValueWins { .. } => "new_value_wins",
Self::Unreadable { .. } => "unreadable",
Self::Refused { .. } => "refused",
}
}
}
/// 旧域内文件的结构(只有一个字段,但**保留 `serde(default)`**:
/// 少了字段的旧文件按出厂值处理,而不是整个解析失败)。
#[derive(Debug, Clone, Copy, Deserialize)]
struct LegacyPrefs {
#[serde(default = "default_auto_check")]
auto_check: bool,
}
const fn default_auto_check() -> bool {
DEFAULT_AUTO_CHECK
}
/// 执行一次迁移(幂等)。
///
/// `now_ms` = 毫秒 since UNIX_EPOCH(注入便于断言收据内容)。
#[must_use]
pub fn migrate(targets: Targets<'_>, now_ms: u64) -> Migration {
let Ok(bytes) = std::fs::read(targets.legacy) else {
// 文件不在(或读不动=一样当没有):绝大多数用户走这一支。
return Migration::Nothing;
};
let fingerprint = receipt::fingerprint_bytes(&bytes);
// ① 收据闸门:同一份源文件内容只迁一次。
if matches!(
receipt::read(targets.receipt),
Ok(Some(existing)) if existing.source_fingerprint == fingerprint
) {
retire(targets.legacy);
return Migration::Nothing;
}
// ② 旧文件必须能解析:坏文件不猜(猜错就等于替用户关掉/打开自动检查)。
let legacy: LegacyPrefs = match serde_json::from_slice(&bytes) {
Ok(value) => value,
Err(error) => {
return Migration::Unreadable {
path: targets.legacy.to_path_buf(),
reason: error.to_string(),
}
}
};
let mut store = SettingStore::load(targets.launcher, launcher_schema());
if !store.access().can_save() {
return Migration::Refused {
reason: format!(
"主配置不可写({}):{}",
targets.launcher.display(),
store.access().as_str()
),
};
}
// ③ 新值胜出:主配置里**显式**有这个键就不覆盖(`effective` 会兜默认值,用 `get`)。
let explicit = store
.doc()
.get(KEY_AUTO_CHECK)
.and_then(serde_json::Value::as_bool);
let outcome = if let Some(current) = explicit {
Migration::NewValueWins {
auto_check: current,
receipt: targets.receipt.to_path_buf(),
retired: retired_path(targets.legacy),
}
} else {
// ④ 只在**非出厂值**时写盘:出厂值(`true`)不落盘是 `document` 的既定语义
// (「默认值不落盘」),写进去只是给文件添一行噪音。`false` 必须写。
if legacy.auto_check != DEFAULT_AUTO_CHECK {
if let Err(error) = store.set(KEY_AUTO_CHECK, json!(legacy.auto_check)) {
return Migration::Refused {
reason: format!("写 {KEY_AUTO_CHECK} 被拒:{error}"),
};
}
match store.save() {
Ok(saved) if saved.wrote() || !store.is_dirty() => {}
Ok(saved) => {
return Migration::Refused {
reason: format!("主配置没落盘({})", format_save(&saved)),
}
}
Err(error) => {
return Migration::Refused {
reason: format!("写主配置失败:{error}"),
}
}
}
}
Migration::Migrated {
auto_check: legacy.auto_check,
receipt: targets.receipt.to_path_buf(),
retired: retired_path(targets.legacy),
}
};
// ⑤ 收据(写不成也不回滚主配置:值已经在主配置里生效了,收据丢了下一次会重跑一遍,
// 而重跑是幂等的——正如「新值胜出」那一支不会覆盖)。
let record = MigrationReceipt::legacy_config(
targets.legacy.to_path_buf(),
targets.launcher.to_path_buf(),
fingerprint,
now_ms,
);
let _ = receipt::write(targets.receipt, &record);
// ⑥ 旧文件退役(改名失败就留着:主配置已经是真相源,`prefs.json` 不再有读路径)。
retire(targets.legacy);
outcome
}
/// 退役后的路径(`prefs.json` → `prefs.json.migrated`)。
#[must_use]
pub fn retired_path(legacy: &Path) -> PathBuf {
let name = legacy
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| "prefs.json".to_string());
legacy.with_file_name(format!("{name}.{RETIRED_SUFFIX}"))
}
/// 把旧文件改名成 `.migrated`(不存在 / 改不动都算成功:它不是真相源了)。
fn retire(legacy: &Path) {
if legacy.exists() {
let _ = std::fs::rename(legacy, retired_path(legacy));
}
}
fn format_save(outcome: &crate::setting::store::SaveOutcome) -> String {
match outcome {
crate::setting::store::SaveOutcome::Saved(path) => format!("已写 {}", path.display()),
crate::setting::store::SaveOutcome::Skipped(reason) => reason.as_str().to_string(),
}
}
/// 「启动时静默检查」的生效值(**唯一**取值点:宿主层与 CLI 都从这里读)。
#[must_use]
pub fn auto_check(doc: &SettingDocument) -> bool {
doc.effective_bool(KEY_AUTO_CHECK)
.unwrap_or(DEFAULT_AUTO_CHECK)
}
/// 「更新渠道」的生效值(同上;空串/坏值都回出厂渠道)。
#[must_use]
pub fn channel(doc: &SettingDocument) -> &str {
doc.effective_str(KEY_CHANNEL)
.filter(|text| !text.trim().is_empty())
.unwrap_or(DEFAULT_CHANNEL)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retired_name_keeps_the_original_stem() {
assert_eq!(
retired_path(Path::new("/data/cache/update/prefs.json")),
Path::new("/data/cache/update/prefs.json.migrated")
);
}
#[test]
fn defaults_agree_with_the_schema() {
// 契约:本文件里的常量与 `keys::launcher_schema()` 的默认值必须一致
// (两份字面量漂移 = 「迁过去的值」和「不迁时的值」不同)。
let schema = launcher_schema();
assert_eq!(
schema.spec(KEY_AUTO_CHECK).expect("键已登记").default,
json!(DEFAULT_AUTO_CHECK)
);
assert_eq!(
schema.spec(KEY_CHANNEL).expect("键已登记").default,
json!(DEFAULT_CHANNEL)
);
}
}
+2 -2
View File
@@ -131,8 +131,8 @@ fn appearance_keys_are_all_ai_writable_and_registered() {
fn schema_registration_shape_is_right() { fn schema_registration_shape_is_right() {
assert_eq!( assert_eq!(
launcher_version().minor, launcher_version().minor,
5, 6,
"1.5.0 登记 M116 的窗口圆角策略键" "1.6.0 登记 M161 的自更新两键(update.auto_check / update.channel)"
); );
let schema = launcher_schema(); let schema = launcher_schema();
assert_eq!( assert_eq!(
+3 -2
View File
@@ -128,9 +128,10 @@ fn overrides_key_is_an_enum_whitelist() {
#[test] #[test]
fn schema_versions_register_the_new_keys_without_migration() { fn schema_versions_register_the_new_keys_without_migration() {
// 1.5.0 登记 M116 的窗口圆角策略键(1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」, // 1.6.0 登记 M161 的自更新两键(`update.auto_check` / `update.channel`;
// 1.5.0 = M116 窗口圆角,1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」,
// 1.2.0 = D23 的 JDK 落点/优先级键)。 // 1.2.0 = D23 的 JDK 落点/优先级键)。
assert_eq!(launcher_version(), SchemaVersion::new(1, 5, 0)); assert_eq!(launcher_version(), SchemaVersion::new(1, 6, 0));
assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0)); assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0));
assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER); assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER);
// major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。 // major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。
@@ -66,7 +66,8 @@ fn install_root_accepts_a_path_and_keeps_empty_as_unset() {
#[test] #[test]
fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() { fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() {
assert_eq!(launcher_version(), SchemaVersion::new(1, 5, 0)); // 1.6.0 = M161 的自更新两键(此前 1.5.0 是 M116 窗口圆角)。
assert_eq!(launcher_version(), SchemaVersion::new(1, 6, 0));
assert_eq!( assert_eq!(
launcher_version().major, launcher_version().major,
1, 1,
+1
View File
@@ -19,3 +19,4 @@ mod redirect;
mod schema_compat; mod schema_compat;
mod spec_ai; mod spec_ai;
mod store; mod store;
mod update_keys;
+234
View File
@@ -0,0 +1,234 @@
//! M161:`update.auto_check` / `update.channel` 的 schema 契约 + 旧 `cache/update/prefs.json` 的迁移。
//!
//! 迁移的四条硬约束在这里逐条钉住(幂等 / 不丢 `false` / 新值胜出 / 旧文件退役),
//! 另外还有一条 schema 层面的:`update.channel` 现在只允许 `stable`——清单里没有的渠道
//! 写进文件等于「设了但没生效」,宁可让 `config set` 当面拒绝。
use serde_json::json;
use unlucky_core::setting::document::{RenderMode, SettingDocument};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::keys::update::UPDATE_CHANNELS;
use unlucky_core::setting::spec::{Sensitivity, ValueKind};
use unlucky_core::setting::store::SettingStore;
use unlucky_core::setting::update_prefs::{
self, Migration, Targets, DEFAULT_AUTO_CHECK, DEFAULT_CHANNEL, KEY_AUTO_CHECK, KEY_CHANNEL,
RECEIPT_FILE,
};
use super::common::TempDir;
/// 三个路径(`<root>/config/launcher.json` + `<root>/cache/update/prefs.json` + 收据)。
fn targets(_temp: &TempDir) -> (String, String, String) {
(
"config/launcher.json".to_string(),
"cache/update/prefs.json".to_string(),
format!("config/{RECEIPT_FILE}"),
)
}
fn run(temp: &TempDir) -> Migration {
let (launcher, legacy, receipt) = targets(temp);
update_prefs::migrate(
Targets {
launcher: &temp.child(&launcher),
legacy: &temp.child(&legacy),
receipt: &temp.child(&receipt),
},
1_700_000_000_000,
)
}
/// 读主配置里的显式值(`None` = 文件里没这个键)。
fn explicit(temp: &TempDir, key: &str) -> Option<serde_json::Value> {
let store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
store.doc().get(key).cloned()
}
#[test]
fn the_two_keys_are_registered_with_the_expected_shape() {
let schema = launcher_schema();
let auto = schema
.spec(KEY_AUTO_CHECK)
.expect("update.auto_check 必须登记");
assert_eq!(auto.default, json!(DEFAULT_AUTO_CHECK), "出厂是开");
assert_eq!(auto.kind, ValueKind::Bool);
assert_eq!(
auto.ai.sensitivity,
Sensitivity::Medium,
"改它会让启动器自己下载并替换 exe,AI 改过要留审计痕迹"
);
let channel = schema.spec(KEY_CHANNEL).expect("update.channel 必须登记");
assert_eq!(channel.default, json!(DEFAULT_CHANNEL));
assert_eq!(channel.kind, ValueKind::Enum(UPDATE_CHANNELS));
assert!(schema.validate(KEY_CHANNEL, &json!("stable")).is_ok());
// 清单里还没有的渠道:**当面拒绝**(写进文件等于「设了但没生效」)。
assert!(schema.validate(KEY_CHANNEL, &json!("beta")).is_err());
assert!(schema.validate(KEY_CHANNEL, &json!("nightly")).is_err());
assert!(schema.validate(KEY_AUTO_CHECK, &json!(1)).is_err());
}
#[test]
fn the_keys_render_in_the_nested_shape() {
let (mut document, problems) = SettingDocument::load(&launcher_schema(), &json!({}));
assert!(problems.is_empty(), "{problems:?}");
// 默认值不落盘:空文档渲染出来不该有 update 段。
assert!(
document.render(RenderMode::Full).get("update").is_none(),
"出厂值不该写进文件"
);
document.set(KEY_AUTO_CHECK, json!(false)).expect("写");
document.set(KEY_CHANNEL, json!("stable")).expect("写");
let rendered = document.render(RenderMode::Full);
assert_eq!(
rendered["update"],
json!({ "auto_check": false, "channel": "stable" }),
"点分键在文件里是嵌套对象(与既有键同一形状)"
);
// 读回来也要是同一个值(IPC/CLI 都读这一份)。
let (reloaded, problems) = SettingDocument::load(&launcher_schema(), &rendered);
assert!(problems.is_empty(), "{problems:?}");
assert!(
!update_prefs::auto_check(&reloaded),
"读回来的生效值必须是文件里的 false"
);
assert_eq!(update_prefs::channel(&reloaded), "stable");
}
#[test]
fn a_false_switch_is_migrated_and_the_old_file_is_retired() {
let temp = TempDir::new("m161-migrate-false");
temp.write("cache/update/prefs.json", r#"{"auto_check": false}"#);
let outcome = run(&temp);
let Migration::Migrated {
auto_check,
retired,
receipt,
} = outcome.clone()
else {
panic!("应当迁入,实际 {outcome:?}");
};
assert!(!auto_check, "旧文件里的 false 不许被默认值盖掉");
// 主配置里是显式的 false(不是「缺键取默认」)。
assert_eq!(explicit(&temp, KEY_AUTO_CHECK), Some(json!(false)));
// 旧文件不能再被读到。
assert!(!temp.exists("cache/update/prefs.json"), "旧文件必须退役");
assert!(temp.exists("cache/update/prefs.json.migrated"), "留着取证");
assert_eq!(retired, temp.child("cache/update/prefs.json.migrated"));
// 收据可查:落盘、类型/指纹/端点齐全。
assert!(receipt.exists(), "收据要落盘:{}", receipt.display());
let text = temp.read(&format!("config/{RECEIPT_FILE}"));
assert!(text.contains("\"legacy_config\""), "{text}");
assert!(text.contains("prefs.json"), "{text}");
assert!(text.contains("launcher.json"), "{text}");
assert!(text.contains("\"applied_at_ms\": 1700000000000"), "{text}");
}
#[test]
fn running_twice_is_idempotent() {
let temp = TempDir::new("m161-migrate-twice");
temp.write("cache/update/prefs.json", r#"{"auto_check": false}"#);
assert!(run(&temp).touched(), "第一遍要真的动手");
let after_first = temp.read("config/launcher.json");
let receipt_first = temp.read(&format!("config/{RECEIPT_FILE}"));
// 第二遍:旧文件已经改名 ⇒ 什么都没得做。
let second = run(&temp);
assert_eq!(second, Migration::Nothing, "第二遍必须是空操作");
assert_eq!(temp.read("config/launcher.json"), after_first, "文件不许变");
assert_eq!(
temp.read(&format!("config/{RECEIPT_FILE}")),
receipt_first,
"收据也不该被重写(源指纹相同 = 跳过)"
);
}
#[test]
fn a_missing_legacy_file_is_a_silent_noop() {
let temp = TempDir::new("m161-migrate-absent");
assert_eq!(run(&temp), Migration::Nothing);
// 连主配置都不该建(没有值要写,就不该白建一个文件)。
assert!(!temp.exists("config/launcher.json"), "不该凭空建配置");
assert!(
!temp.exists(&format!("config/{RECEIPT_FILE}")),
"不该写收据"
);
}
#[test]
fn an_explicit_new_value_wins_over_the_legacy_file() {
let temp = TempDir::new("m161-migrate-new-wins");
temp.write(
"config/launcher.json",
&json!({
"$schema": schema_url(),
"schemaVersion": 1,
"update": { "auto_check": true }
})
.to_string(),
);
temp.write("cache/update/prefs.json", r#"{"auto_check": false}"#);
let outcome = run(&temp);
assert_eq!(
outcome.as_str(),
"new_value_wins",
"主配置里有显式值时不许覆盖:{outcome:?}"
);
assert_eq!(
explicit(&temp, KEY_AUTO_CHECK),
Some(json!(true)),
"新值胜出"
);
// 旧文件照样退役(它已经不是真相源),收据也写了。
assert!(!temp.exists("cache/update/prefs.json"));
assert!(temp.exists(&format!("config/{RECEIPT_FILE}")));
}
#[test]
fn the_default_true_is_not_written_but_the_file_still_retires() {
let temp = TempDir::new("m161-migrate-default");
temp.write("cache/update/prefs.json", r#"{"auto_check": true}"#);
let outcome = run(&temp);
assert_eq!(outcome.as_str(), "migrated");
// 「默认值不落盘」:写进去只是噪音,生效值本来就是 true。
assert_eq!(explicit(&temp, KEY_AUTO_CHECK), None, "出厂值不落盘");
assert!(!temp.exists("cache/update/prefs.json"), "旧文件仍要退役");
let store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
assert!(update_prefs::auto_check(store.doc()));
}
#[test]
fn a_corrupt_legacy_file_touches_nothing() {
let temp = TempDir::new("m161-migrate-corrupt");
temp.write("cache/update/prefs.json", "{ not json");
let outcome = run(&temp);
assert_eq!(outcome.as_str(), "unreadable", "{outcome:?}");
// 坏文件保持原样:下一次还能再试(我们既不猜值也不删证据)。
assert!(temp.exists("cache/update/prefs.json"));
assert!(!temp.exists(&format!("config/{RECEIPT_FILE}")));
}
#[test]
fn an_empty_legacy_file_still_means_the_default() {
// `serde(default)`:老文件里没有 auto_check 字段(或整份是 `{}`)⇒ 按出厂值处理,
// 而不是把整份文件判成坏文件。
let temp = TempDir::new("m161-migrate-empty");
temp.write("cache/update/prefs.json", "{}");
let outcome = run(&temp);
assert_eq!(outcome.as_str(), "migrated");
let store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
assert!(update_prefs::auto_check(store.doc()), "缺字段取出厂值 true");
}
/// 真 `$schema`(加载不降级才看得到显式值)。
fn schema_url() -> String {
use unlucky_core::setting::keys::launcher_version;
use unlucky_core::setting::schema::{SchemaId, SchemaRef};
SchemaRef::new(SchemaId::LAUNCHER, launcher_version()).url()
}
+20
View File
@@ -292,6 +292,26 @@ macro_rules! content_entries {
"路径过不了包内规整,已跳过" "路径过不了包内规整,已跳过"
), ),
PackReasonUnreadable => ("cannot read it, skipped", "读不出来,已跳过"), PackReasonUnreadable => ("cannot read it, skipped", "读不出来,已跳过"),
// --- M161:导入结果的两种失败**不许共用一句话** ---
// 「预检没通过(一个字节都没写)」与「写进去之后回滚完成」是两件事:
// 前者用户去修包/修配置再试,后者说明已经动过目标目录。此前两者共用
// `PackStatusFailed`("Import failed — rolled back"),预检失败时那句话是假的。
PackStatusFailedNotStarted => (
"Import failed — nothing was written",
"导入失败 —— 还没写任何文件"
),
PackStatusFailedIncomplete => (
"Import failed — rollback did not finish",
"导入失败 —— 回滚没做完"
),
PackFatalTitleNotStarted => (
"Preflight failed — nothing was written",
"预检未通过 —— 一个字节都没写"
),
PackFatalTitleIncomplete => (
"Rollback did not finish — the target folder may differ",
"回滚没做完 —— 目标目录可能与导入前不同"
),
} } } }
}; };
} }
+8 -4
View File
@@ -35,7 +35,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("InstancesIsolationIsolated", "Isolated", "独立目录"), ("InstancesIsolationIsolated", "Isolated", "独立目录"),
("InstancesIsolationShared", "Shared", "共用目录"), ("InstancesIsolationShared", "Shared", "共用目录"),
("InstancesRunning", "Running · pid {pid}", "运行中 · pid {pid}"), ("InstancesRunning", "Running · pid {pid}", "运行中 · pid {pid}"),
("InstancesFailed", "Launch failed · {reason}", "启动失败 · {reason}"), ("InstancesFailedStage", "Launch failed · {stage}", "启动失败 · {stage}"),
("InstancesStageDownload", "Downloading", "下载中"), ("InstancesStageDownload", "Downloading", "下载中"),
("InstancesStageVerify", "Verifying", "校验中"), ("InstancesStageVerify", "Verifying", "校验中"),
("InstancesStageLaunch", "Starting", "启动中"), ("InstancesStageLaunch", "Starting", "启动中"),
@@ -59,17 +59,21 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("InstancesLaunchReset", "Reset to global", "恢复跟随全局"), ("InstancesLaunchReset", "Reset to global", "恢复跟随全局"),
]; ];
/// 金表的**全量**行(前半段在本文件、后半段在 [`tail`],顺序即 `Key::ALL`)。 /// 金表的**全量**行(三段:本文件 + [`tail`] + [`tail2`],顺序即 `Key::ALL`)。
/// ///
/// 拆成两段只为守 400 行硬线(M157 加了 19 行启动参数文案后越线);对调用方是**一条**序列, /// 拆成两段只为守 400 行硬线(M157 加了 19 行启动参数文案后越线);对调用方是**一条**序列,
/// `catalog/tests.rs` 的逐字节断言不用关心分段。 /// `catalog/tests.rs` 的逐字节断言不用关心分段。
pub(super) fn rows() -> impl Iterator<Item = &'static (&'static str, &'static str, &'static str)> { pub(super) fn rows() -> impl Iterator<Item = &'static (&'static str, &'static str, &'static str)> {
GOLDEN.iter().chain(tail::ROWS.iter()) GOLDEN
.iter()
.chain(tail::ROWS.iter())
.chain(tail2::ROWS.iter())
} }
/// 金表总行数(`Key::ALL` 的条数)。 /// 金表总行数(`Key::ALL` 的条数)。
pub(super) fn len() -> usize { pub(super) fn len() -> usize {
GOLDEN.len() + tail::ROWS.len() GOLDEN.len() + tail::ROWS.len() + tail2::ROWS.len()
} }
mod tail; mod tail;
mod tail2;
+8 -208
View File
@@ -3,13 +3,20 @@
//! 为什么拆:`golden.rs` 在 M157 加了 19 行启动参数文案后到 416 行,越过 400 行硬线; //! 为什么拆:`golden.rs` 在 M157 加了 19 行启动参数文案后到 416 行,越过 400 行硬线;
//! 拆分口径与仓库其它 `-p1/-p2` 一致——**纯搬移、一行不改**,顺序即 `Key::ALL` 的后半段。 //! 拆分口径与仓库其它 `-p1/-p2` 一致——**纯搬移、一行不改**,顺序即 `Key::ALL` 的后半段。
//! 拼装与断言在 `golden.rs` 的 [`rows`] 与 `catalog/tests.rs`。 //! 拼装与断言在 `golden.rs` 的 [`rows`] 与 `catalog/tests.rs`。
pub(super) const ROWS: &[(&str, &str, &str)] = &[ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("LoaderVanilla", "Vanilla", "原版"), ("LoaderVanilla", "Vanilla", "原版"),
("LaunchOutcomeNormal", "Finished normally", "正常结束"), ("LaunchOutcomeNormal", "Finished normally", "正常结束"),
("LaunchOutcomeCrash", "The game crashed", "游戏崩溃"), ("LaunchOutcomeCrash", "The game crashed", "游戏崩溃"),
("LaunchOutcomeSilent", "Suspected silent failure: exit code 0, but the game never really started", "疑似静默失败:退出码 0,但游戏没真正跑起来"), ("LaunchOutcomeSilent", "Suspected silent failure: exit code 0, but the game never really started", "疑似静默失败:退出码 0,但游戏没真正跑起来"),
("LaunchFailedWithCode", "Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"), ("LaunchFailedWithCode", "Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"),
("LaunchStageVersionManifest", "Reading the version file", "读版本文件"),
("LaunchStageInheritance", "Resolving the inheritance chain", "解析继承链"),
("LaunchStageInstanceConfig", "Reading the instance settings", "读实例设置"),
("LaunchStageIsolation", "Preparing the isolated game folder", "准备隔离游戏目录"),
("LaunchStageIdentity", "Preparing the account", "准备账户"),
("LaunchStageSpawn", "Starting the game process", "启动游戏进程"),
("LaunchStageFailed", "Launch failed · {stage}: {detail}", "启动失败 · {stage}:{detail}"),
("LaunchToastEnded", "{outcome} · {seconds}s", "{outcome} · {seconds} 秒"),
("LaunchMsaNotConfigured", "Microsoft sign-in is not configured: it needs an Azure public client id (the login flow itself is implemented). Build with ULL_MSA_CLIENT_ID=<id>, or set that environment variable before launching.", "微软登录未配置:缺少 Azure 公共客户端 id(登录功能已实现,只差这一项配置)。构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build;或启动前设置同名环境变量 ULL_MSA_CLIENT_ID=<id>。"), ("LaunchMsaNotConfigured", "Microsoft sign-in is not configured: it needs an Azure public client id (the login flow itself is implemented). Build with ULL_MSA_CLIENT_ID=<id>, or set that environment variable before launching.", "微软登录未配置:缺少 Azure 公共客户端 id(登录功能已实现,只差这一项配置)。构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build;或启动前设置同名环境变量 ULL_MSA_CLIENT_ID=<id>。"),
("FontCjkNeedsDownload", "No Chinese font found on this system. Download an open-source (OFL) Chinese font?", "系统里没找到中文字体。要下载一套开源(OFL)中文字体吗?"), ("FontCjkNeedsDownload", "No Chinese font found on this system. Download an open-source (OFL) Chinese font?", "系统里没找到中文字体。要下载一套开源(OFL)中文字体吗?"),
("FontCjkDownloading", "Downloading Chinese font… {percent}%", "正在下载中文字体… {percent}%"), ("FontCjkDownloading", "Downloading Chinese font… {percent}%", "正在下载中文字体… {percent}%"),
@@ -112,8 +119,6 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("AgentSubtitleReal", "Conversation", "会话"), ("AgentSubtitleReal", "Conversation", "会话"),
("AgentPlaceholderReal", "Message the assistant…", "给助手发消息…"), ("AgentPlaceholderReal", "Message the assistant…", "给助手发消息…"),
("AgentEmptyBodyReal", "Ask about instances, downloads or logs.", "可以问实例、下载或日志。"), ("AgentEmptyBodyReal", "Ask about instances, downloads or logs.", "可以问实例、下载或日志。"),
// ── M106 `I18NFOLLOW`:General 分组真控件(语言选择器 + 其余三行)。**只追加在链尾**,
// 上一行到 `LogsEmptyAction` 的既有顺序一字未动(金表要证明的正是这件事)。
("SettingsLanguageFollowSystem", "Follow system", "跟随系统"), ("SettingsLanguageFollowSystem", "Follow system", "跟随系统"),
("SettingsLanguageZhCn", "Chinese (Simplified)", "简体中文"), ("SettingsLanguageZhCn", "Chinese (Simplified)", "简体中文"),
("SettingsLanguageEn", "English", "英文"), ("SettingsLanguageEn", "English", "英文"),
@@ -136,7 +141,6 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("SettingsOverridesEmptyBody", "Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest of the launcher keeps the defaults above.", "逐实例覆盖让某个实例保留自己的 Java 运行时、内存或 JVM 参数,其余实例沿用上面的默认值。"), ("SettingsOverridesEmptyBody", "Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest of the launcher keeps the defaults above.", "逐实例覆盖让某个实例保留自己的 Java 运行时、内存或 JVM 参数,其余实例沿用上面的默认值。"),
("SettingsOverridesAdd", "Add override", "新增覆盖"), ("SettingsOverridesAdd", "Add override", "新增覆盖"),
("AgentFallbackBody", "The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"), ("AgentFallbackBody", "The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"),
// --- M107 版本管理页(追加在链尾:`Key::ALL` 顺序 = 金表顺序) ---
("VersionRailTitle", "Version type", "版本类型"), ("VersionRailTitle", "Version type", "版本类型"),
("VersionMajorTitle", "Major version", "主版本"), ("VersionMajorTitle", "Major version", "主版本"),
("VersionMajorAll", "All versions", "全部版本"), ("VersionMajorAll", "All versions", "全部版本"),
@@ -147,11 +151,9 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("VersionSearchEmptyBody", "Try a shorter piece of the number — 1.21, 24w, a1.", "试试更短的片段 — 1.21、24w、a1。"), ("VersionSearchEmptyBody", "Try a shorter piece of the number — 1.21, 24w, a1.", "试试更短的片段 — 1.21、24w、a1。"),
("VersionSearchElsewhere", "{count} versions match, but they are in another type.", "有 {count} 个版本匹配,但它们在别的类型里。"), ("VersionSearchElsewhere", "{count} versions match, but they are in another type.", "有 {count} 个版本匹配,但它们在别的类型里。"),
("VersionSearchElsewhereAction", "Show all types", "看全部类型"), ("VersionSearchElsewhereAction", "Show all types", "看全部类型"),
// --- M107 r2(追加在末尾:`Key::ALL` 顺序 = 金表顺序) ---
("VersionSearchHitsOne", "1 match", "1 个匹配"), ("VersionSearchHitsOne", "1 match", "1 个匹配"),
("VersionSearchElsewhereTitle", "Found in another type", "在别的类型里找到了"), ("VersionSearchElsewhereTitle", "Found in another type", "在别的类型里找到了"),
("VersionSearchElsewhereOne", "1 version matches, but it is in another type.", "有 1 个版本匹配,但它在别的类型里。"), ("VersionSearchElsewhereOne", "1 version matches, but it is in another type.", "有 1 个版本匹配,但它在别的类型里。"),
// --- M109 内容库页(同样追加在链尾:`Key::ALL` 顺序 = 金表顺序) ---
("ContentTitle", "Content", "内容库"), ("ContentTitle", "Content", "内容库"),
("ContentSubtitle", "Modpacks, mods, resource packs and shaders", "整合包、Mod、资源包与光影"), ("ContentSubtitle", "Modpacks, mods, resource packs and shaders", "整合包、Mod、资源包与光影"),
("ContentSectionVersions", "Version management", "版本管理"), ("ContentSectionVersions", "Version management", "版本管理"),
@@ -206,206 +208,4 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("PackDismiss", "Dismiss", "关闭"), ("PackDismiss", "Dismiss", "关闭"),
("PackTargetLabel", "Install into", "装到实例"), ("PackTargetLabel", "Install into", "装到实例"),
("PackTargetNew", "New instance", "新建实例"), ("PackTargetNew", "New instance", "新建实例"),
("PackInstanceName", "Instance name", "实例名"),
("PackStageLine", "Step {index}/{total} · {stage}", "步骤 {index}/{total} · {stage}"),
("PackStageProbe", "Reading the manifest", "读取清单"),
("PackStageExtract", "Unpacking overrides", "解包覆盖内容"),
("PackStageFetch", "Fetching missing files", "补下载文件"),
("PackStageCommit", "Writing files", "落地写盘"),
("PackStageRegister", "Registering the instance", "登记实例"),
("PackProbeTitle", "This pack is almost a known format", "这个包差一点就是已知格式"),
("PackProbeLooksLike", "Looks like {format}, but missing: {missing}", "看起来像 {format},但缺:{missing}"),
("PackProbeUnknown", "No known format claims this pack", "没有任何已知格式认领这个包"),
("PackProbeSaw", "Found in the pack: {saw}", "包里看到:{saw}"),
("PackProbeNothing", "nothing recognisable", "没有可识别的东西"),
("PackStatusSuccess", "Imported", "已导入"),
("PackStatusPartial", "Imported with gaps", "导入完成,但有缺口"),
("PackStatusFailed", "Import failed — rolled back", "导入失败 —— 已整包回滚"),
("PackReportWritten", "Files written: {count} · {bytes}", "{count} 个文件已写盘 · {bytes}"),
("PackReportMissing", "Could not fetch (tolerated): {count}", "有 {count} 个文件没搞到(可容忍)"),
("PackReportConflicts", "Conflicts: {count}", "冲突:{count} 个"),
("PackReportRejected", "The client does not support these: {count}", "这些组件客户端不支持:{count} 个"),
("PackReportRejectedNote", "not supported by the client", "客户端不支持"),
("PackCancelled", "Import keeps running in the background — this result is discarded. Files already written stay.", "导入会在后台继续跑完 —— 本次结果作废;已经写下的文件会留下。"),
("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"),
("PackOverwriteNote", "Keeping both (renamed) is not supported yet: for each existing file you pick either “keep” or “overwrite”.", "「两者都留(改名)」暂不支持:每个已存在的文件只能选「保留」或「覆盖」。"),
("PackMissingOffline", "offline import", "离线导入"),
("PackMissingNoSource", "no download source available yet", "暂时拿不到下载地址"),
("PackMissingFetchFailed", "download failed: {detail}", "下载失败:{detail}"),
("PackFatalTitle", "Rolled back — nothing was left behind", "已回滚 —— 目标目录零残留"),
("PackFatalHint", "What to try next", "下一步可以试"),
("PackConflictUserModified", "you changed it", "你改过"),
("PackConflictUserDeleted", "you deleted it", "你删过"),
("PackConflictPreExisting", "was already there", "本来就在"),
("PackChoiceKeepUser", "kept yours", "保留我的"),
("PackChoiceOverwrite", "pack version used", "用包里的"),
("PackConflictHashes", "pack {pack} · base {base} · disk {disk}", "包内 {pack} · 基线 {base} · 磁盘 {disk}"),
("PackConflictDetail", "Hashes", "三方哈希"),
("PackPickTitle", "Before importing: {count} files already exist with different content", "导入前:有 {count} 个文件已存在且内容不同"),
("PackPickSummary", "Keep {keep} · overwrite {overwrite} · overwriting discards {bytes}", "保留 {keep} · 覆盖 {overwrite} · 覆盖将丢弃 {bytes}"),
("PackPickKeepAll", "Keep all mine", "全部保留我的"),
("PackPickOverwriteAll", "Overwrite all", "全部覆盖"),
("PackPickRowKeep", "Keep", "保留"),
("PackPickRowOverwrite", "Overwrite", "覆盖"),
("PackPickPage", "Items {from}-{to} of {total}", "第 {from}-{to} 项,共 {total} 项"),
("PackPickPrev", "Previous", "上一页"),
("PackPickNext", "Next", "下一页"),
("PackPickConfirm", "Import with these choices", "按这些选择导入"),
("PackPickCancel", "Cancel import", "取消导入"),
("PackPickDefaultNote", "Anything you do not change stays as you have it: file content is only replaced where you pick “Overwrite”.", "没有改动的一律按「保留」:只有你点了「覆盖」的文件才会被包内内容替换。"),
("PackPickUnknownRemote", "{count} remote entries only learn their path after download; they follow “keep”.", "还有 {count} 条远端条目要下载后才知道路径,一律按「保留」处置。"),
("PackPickSourceOverrides", "in the pack (overrides/)", "包内 overrides/"),
("PackPickSourceClientOverrides", "in the pack (client-overrides/)", "包内 client-overrides/"),
("PackPickSourceManifest", "manifest files[]", "清单 files[]"),
("PackPickDisk", "yours: {size}", "你的:{size}"),
("PackPickIncoming", "pack: {size}", "包内:{size}"),
("PackFormatModrinth", "Modrinth .mrpack", "Modrinth 的 .mrpack"),
("PackFormatCurseforge", "CurseForge manifest", "CurseForge 清单"),
("PackFormatMultimc", "MultiMC / Prism instance", "MultiMC·Prism 实例"),
("PackExportTitle", "Export this instance", "导出这个实例"),
("PackExportOpen", "Export…", "导出…"),
("PackExportFormat", "Format", "格式"),
("PackExportName", "Pack name", "包名"),
("PackExportVersion", "Version", "版本"),
("PackExportOutput", "Save to", "保存到"),
("PackExportOutputPlaceholder", "empty = suggested path", "留空 = 自动给一个路径"),
("PackExportPrecheck", "Dry run", "预检"),
("PackExportConfirm", "Write the pack", "确认写盘"),
("PackExportAbort", "Back", "返回"),
("PackExportMultimcNote", "MultiMC/Prism can only be imported, not exported (by design).", "MultiMC·Prism 只能导入、不能导出(有意省略)。"),
("PackExportDone", "Wrote {path} · {bytes}", "已写出 {path} · {bytes}"),
("PackExportFailed", "Export failed: {reason}", "导出失败:{reason}"),
("PackExportPending", "Scanning the instance…", "正在扫描实例…"),
("PackPreviewHead", "Will inline {inline} files · cannot export: {skipped}", "将内联 {inline} 个文件 · 无法导出 {skipped} 项"),
("PackPreviewRemote", "{count} files can be referenced remotely", "{count} 个文件可走远程引用"),
("PackPreviewInlineBytes", "{bytes} copied into overrides/", "{bytes} 会原样拷进 overrides/"),
("PackPreviewInlineTitle", "Copied into overrides/ (first {shown}):", "原样拷进 overrides/(只列前 {shown} 个):"),
("PackPreviewSkippedTitle", "Not in the pack: {count}", "没进包:{count} 项"),
("PackPreviewNotes", "Known losses", "已知损失"),
("PackPreviewTarget", "Will be written to {path}", "将写到 {path}"),
("PackPreviewMore", "…and {count} more files (not shown here)", "…还有 {count} 个文件(这里不展开)"),
("PackPreviewComplete", "Nothing is left out", "没有遗漏任何文件"),
("PackPreviewIncomplete", "Some files will not be in the pack", "有文件不会进包"),
("PackReasonModrinthSha1", "Modrinth match (SHA-1)", "Modrinth 命中(SHA-1)"),
("PackReasonCurseFingerprint", "CurseForge match (fingerprint)", "CurseForge 命中(文件指纹)"),
("PackReasonNoRemoteMatch", "unknown to both catalogs, inlined", "两个目录都不认识,已内联"),
("PackReasonCurseOnly", "only CurseForge knows it, this format cannot use it, inlined", "只有 CF 认识,目标格式用不上,已内联"),
("PackReasonModrinthOnly", "only Modrinth knows it, the CF manifest cannot use it, inlined", "只有 Modrinth 认识,CF 清单用不上,已内联"),
("PackReasonMissingSha512", "the catalogue entry has no sha512 hash, inlined", "命中缺 sha512,已内联"),
("PackReasonMissingCurseIds", "the catalogue entry has no CurseForge ids, inlined", "命中缺 projectID/fileID,已内联"),
("PackReasonDisabled", "disabled file, this format cannot express it (.disabled kept)", "禁用文件,目标格式表达不了(保留 .disabled)"),
("PackReasonSymlink", "symbolic link, skipped", "符号链接,已跳过"),
("PackReasonUnsafePath", "path fails the pack path rules, skipped", "路径过不了包内规整,已跳过"),
("PackReasonUnreadable", "cannot read it, skipped", "读不出来,已跳过"),
// ── M114 账户页:追加在 main 的全部域之后(金表只追加不重排)
("AccountTitle", "Accounts", "账户"),
("AccountSubtitle", "Microsoft, third-party skin servers and local profiles", "微软正版、第三方皮肤站与本地档案"),
("AccountListTitle", "All accounts", "全部账户"),
("AccountAddedOn", "Added {date}", "添加于 {date}"),
("AccountActiveChip", "Active", "当前"),
("AccountSwitch", "Use for launch", "切换为启动账户"),
("AccountRelogin", "Sign in again", "重新登录"),
("AccountRemove", "Remove", "删除"),
("AccountKindMicrosoft", "Microsoft", "微软正版"),
("AccountKindAuthlib", "Third-party skin server", "第三方皮肤站"),
("AccountKindOffline", "Local profile", "本地档案"),
("AccountServer", "Server {server}", "服务器 {server}"),
("AccountEmptyTitle", "No accounts yet", "还没有账户"),
("AccountEmptyBody", "Add a Microsoft account, a third-party skin server account, or a local profile.", "添加微软正版账户、第三方皮肤站账户,或建一个本地档案。"),
("AccountEmptyAction", "Add an account", "添加账户"),
("AccountTrackMicrosoftTitle", "Microsoft account", "微软正版"),
("AccountTrackMicrosoftBody", "Sign in with a device code or in your browser. Online servers require this kind of account.", "用设备码或浏览器登录。联机服务器必须用这种账户。"),
("AccountTrackAuthlibTitle", "Third-party skin server", "第三方皮肤站"),
("AccountTrackAuthlibBody", "An authlib-injector compatible server (LittleSkin and friends) brings its own skins and capes.", "兼容 authlib-injector 的皮肤站(如 LittleSkin)自带皮肤与披风。"),
("AccountTrackOfflineTitle", "Local profile", "本地档案"),
("AccountTrackOfflineBody", "Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"),
("AccountTrackOpen", "Sign in", "登录"),
("AccountTrackCreate", "Create profile", "创建档案"),
("AccountDialogClose", "Close", "关闭"),
("AccountOfflineNameLabel", "Profile name", "档案名"),
("AccountOfflineNameHint", "3–16 letters, digits or underscore", "3–16 位字母、数字或下划线"),
("AccountOfflineSubmit", "Create profile", "创建档案"),
("AccountOfflineWarning", "Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"),
("AccountDeviceCodeLabel", "Enter this code", "输入这个用户码"),
("AccountDeviceCodeCopy", "Copy code", "复制用户码"),
("AccountDeviceLinkCopy", "Copy link", "复制链接"),
("AccountDeviceCountdown", "{time} left", "剩 {time}"),
("AccountDeviceExpired", "This code has expired. Start again for a fresh one.", "这个用户码已过期,重新开始换一个新的。"),
("AccountDeviceRetry", "Try again", "重试"),
("AccountDeviceCancel", "Cancel", "取消"),
("AccountDeviceBrowser", "Sign in with browser", "用浏览器登录"),
("AccountDeviceUseCode", "Use a device code instead", "改用设备码"),
("AccountServerLabel", "Server address", "服务器地址"),
("AccountServerHint", "API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"),
("AccountServerProbe", "Check server", "校验服务器"),
("AccountLoginNameLabel", "Email or username", "邮箱或用户名"),
("AccountPasswordLabel", "Password", "密码"),
("AccountPasswordHint", "Used once for this sign-in. It is never stored.", "只在这次登录里用一次,不会保存。"),
("AccountAuthlibSubmit", "Sign in", "登录"),
("AccountAuthlibIdleHint", "The server is checked automatically when you sign in; Check server only does it up front.", "登录时会自动校验服务器;「校验服务器」只是提前跑一遍。"),
("AccountSkinTitle", "Skin", "皮肤"),
("AccountSkinNone", "No skin available", "没有可用皮肤"),
("AccountSkinHint", "Skins come from the account's own server; nothing is generated locally.", "皮肤来自账户自己的服务器,本地不生成任何假皮肤。"),
("AccountRailSignedOut", "Not signed in", "未登录"),
("AccountDeviceHint", "Open the link in your browser and enter the code above.", "在浏览器里打开链接,输入上面的用户码。"),
("AccountDeviceBrowserHint", "Finish the sign-in in the browser window we just opened; the link below also works.", "在我们刚打开的浏览器窗口里完成登录;下面的链接也能手动打开。"),
("AccountServerChecking", "Checking the server…", "正在校验服务器…"),
("AccountServerOk", "Server {server} is ready", "服务器 {server} 可用"),
("AccountLoginFailed", "Sign-in failed", "登录失败"),
("AccountCurrentTitle", "Current account", "当前账户"),
("AccountFailureCode", "Code: {code}", "代码:{code}"),
("AccountFailureDenied", "The server refused this sign-in (access denied).", "服务器拒绝了这次登录(拒绝访问)。"),
("AccountFailureWhitelist", "Microsoft has not approved this launcher's app registration yet. An administrator has to accept the app, then try again.", "微软还没批准本启动器的应用注册:需要管理员同意该应用后再试。"),
("AccountFailureUnreachable", "Could not reach the server. Check the network connection or the address, then try again.", "连不上服务器:检查网络或地址后重试。"),
("AccountFailureNotAuthlib", "This address does not look like an authlib-injector server (the API root usually ends with /api/yggdrasil/).", "这个地址不像 authlib-injector 服务器(API 根通常以 /api/yggdrasil/ 结尾)。"),
("AccountFailureBadCredentials", "The server rejected the username or password.", "服务器不认这个用户名或密码。"),
("AccountFailureNoProfile", "The account has no Minecraft profile on this server yet. Create the character on the server first.", "这个账号在该服务器上还没有角色:先在皮肤站里创建角色。"),
("AccountFailureStore", "Could not save the account to disk.", "账户没能写到磁盘上。"),
("AccountFailureOther", "The sign-in failed. The full reason is in the launcher log.", "登录失败,完整原因在启动器日志里。"),
("AccountLoginNotConfigured", "Microsoft sign-in is not configured", "微软登录未配置"),
("AccountFailureMsaNotConfigured", "This build has no Azure public client id, so Microsoft sign-in cannot start (the sign-in flow itself is implemented). Set the environment variable ULL_MSA_CLIENT_ID=<id> before starting the launcher, or build with ULL_MSA_CLIENT_ID=<id>. Then restart the launcher: a running process does not pick up a new environment variable.", "本启动器没有配置 Azure 公共客户端 id,微软登录无法开始(登录功能已实现,只差这一项配置)。启动前设置环境变量 ULL_MSA_CLIENT_ID=<id>,或构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build。改完请重启启动器——已在运行的进程读不到新设的环境变量。"),
("AccountDeviceRequesting", "Contacting Microsoft…", "正在联系微软…"),
("AccountLoginSucceeded", "Signed in as {name}", "已登录 {name}"),
("AccountNoticeAdded", "Added {name}", "已添加 {name}"),
("AccountNoticeRemoved", "Removed {name}", "已删除 {name}"),
("AccountNoticeSwitched", "{name} will be used for the next launch", "{name} 将作为下次启动的账户"),
("AccountNoticeOfflineNameInvalid", "Profile names must be 3–16 letters, digits or underscores.", "档案名必须是 3–16 位字母、数字或下划线。"),
("AccountNoticeAccountExists", "An account with that name already exists.", "同名账户已经存在。"),
("AccountNoticeStoreFailed", "Could not save the account store. Check the disk and try again.", "账户库写不进去:检查磁盘后重试。"),
("AccountNoticeNotFound", "That account no longer exists.", "这个账户已经不在了。"),
("AccountNoticeOther", "The account action failed. The full reason is in the launcher log.", "账户操作失败,完整原因在启动器日志里。"),
("AccountNoticeDismiss", "Dismiss", "关闭"),
("AccountNoticeTitleFailed", "Account action failed", "账户操作失败"),
("AccountNoticeTitleDone", "Account updated", "账户已更新"),
("AccountHintAuthlibReauth", "Enter the password again for {server}.", "请输入 {server} 的密码。"),
("AccountHintOfflineNoRelogin", "Local profiles have no server account to sign in again — create a new profile instead.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"),
("SettingsGroupUpdate", "Updates", "更新"),
("SettingsUpdateCurrent", "Current version", "当前版本"),
("SettingsUpdateChannel", "Channel", "更新渠道"),
("SettingsUpdateChannelStable", "Stable", "稳定版"),
("SettingsUpdateAutoCheck", "Check for updates on startup", "启动时检查更新"),
("SettingsUpdateCheckNow", "Check now", "立即检查"),
("SettingsUpdateChecking", "Checking…", "正在检查…"),
("SettingsUpdateNever", "Not checked yet", "还没检查过"),
("SettingsUpdateUpToDate", "You are on the latest version ({version})", "已是最新({version})"),
("SettingsUpdateAvailable", "Version {version} is available ({size})", "有新版本 {version}({size})"),
("SettingsUpdateMissingArtifact", "Version {version} is out, but there is no build for this platform yet.", "{version} 已发布,但还没有本平台的安装包。"),
("SettingsUpdateFailed", "Check failed: {reason}", "检查失败:{reason}"),
("SettingsUpdateApply", "Download and install", "下载并安装"),
("SettingsUpdateApplying", "Downloading and verifying…", "正在下载并校验…"),
("SettingsUpdateReady", "Version {version} is ready — restart to use it.", "版本 {version} 已就位,重启后生效。"),
("SettingsUpdateNotes", "Release notes: {notes}", "更新说明:{notes}"),
("SettingsUpdateForced", "This update is required", "这是一次强制更新"),
("SettingsUpdateLastChecked", "Last check", "上次检查"),
("SettingsUpdateHint", "Downloads are verified with SHA256 before they touch the launcher. The running copy stays as it is until you restart.", "更新包在落到启动器之前会做 SHA256 校验;正在运行的这一份在你重启之前不会变。"),
("UpdateBannerTitleAvailable", "Version {version} is available", "发现新版本 {version}"),
("UpdateBannerBodyAvailable", "Download size {size}", "下载体积 {size}"),
("UpdateBannerTitleReady", "Update ready", "更新已就位"),
("UpdateBannerBodyReady", "Version {version} will run after a restart.", "重启后运行版本 {version}。"),
("UpdateBannerTitleFailed", "Update failed", "更新失败"),
("UpdateBannerApplying", "Downloading the update…", "正在下载更新…"),
("UpdateBannerInstall", "Download and install", "下载并安装"),
("UpdateBannerDismiss", "Skip this version", "忽略这个版本"),
("UpdateBannerRestartNow", "Restart now", "立即重启"),
("UpdateBannerRetry", "Try again", "重试"),
]; ];
+212
View File
@@ -0,0 +1,212 @@
//! 金表的第三段(`tail.rs` 之后,M161 拆:`tail.rs` 到 M157 已有 411 行、加自更新那几条就 423)。
//!
//! 口径与 `golden.rs` / `tail.rs` 完全一致:**纯搬移、一行不改**,顺序即 `Key::ALL` 的续段。
//! 拼装见 `golden.rs` 的 [`super::rows`]。
pub(super) const ROWS: &[(&str, &str, &str)] = &[
("PackInstanceName", "Instance name", "实例名"),
("PackStageLine", "Step {index}/{total} · {stage}", "步骤 {index}/{total} · {stage}"),
("PackStageProbe", "Reading the manifest", "读取清单"),
("PackStageExtract", "Unpacking overrides", "解包覆盖内容"),
("PackStageFetch", "Fetching missing files", "补下载文件"),
("PackStageCommit", "Writing files", "落地写盘"),
("PackStageRegister", "Registering the instance", "登记实例"),
("PackProbeTitle", "This pack is almost a known format", "这个包差一点就是已知格式"),
("PackProbeLooksLike", "Looks like {format}, but missing: {missing}", "看起来像 {format},但缺:{missing}"),
("PackProbeUnknown", "No known format claims this pack", "没有任何已知格式认领这个包"),
("PackProbeSaw", "Found in the pack: {saw}", "包里看到:{saw}"),
("PackProbeNothing", "nothing recognisable", "没有可识别的东西"),
("PackStatusSuccess", "Imported", "已导入"),
("PackStatusPartial", "Imported with gaps", "导入完成,但有缺口"),
("PackStatusFailed", "Import failed — rolled back", "导入失败 —— 已整包回滚"),
("PackReportWritten", "Files written: {count} · {bytes}", "{count} 个文件已写盘 · {bytes}"),
("PackReportMissing", "Could not fetch (tolerated): {count}", "有 {count} 个文件没搞到(可容忍)"),
("PackReportConflicts", "Conflicts: {count}", "冲突:{count} 个"),
("PackReportRejected", "The client does not support these: {count}", "这些组件客户端不支持:{count} 个"),
("PackReportRejectedNote", "not supported by the client", "客户端不支持"),
("PackCancelled", "Import keeps running in the background — this result is discarded. Files already written stay.", "导入会在后台继续跑完 —— 本次结果作废;已经写下的文件会留下。"),
("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"),
("PackOverwriteNote", "Keeping both (renamed) is not supported yet: for each existing file you pick either “keep” or “overwrite”.", "「两者都留(改名)」暂不支持:每个已存在的文件只能选「保留」或「覆盖」。"),
("PackMissingOffline", "offline import", "离线导入"),
("PackMissingNoSource", "no download source available yet", "暂时拿不到下载地址"),
("PackMissingFetchFailed", "download failed: {detail}", "下载失败:{detail}"),
("PackFatalTitle", "Rolled back — nothing was left behind", "已回滚 —— 目标目录零残留"),
("PackFatalHint", "What to try next", "下一步可以试"),
("PackConflictUserModified", "you changed it", "你改过"),
("PackConflictUserDeleted", "you deleted it", "你删过"),
("PackConflictPreExisting", "was already there", "本来就在"),
("PackChoiceKeepUser", "kept yours", "保留我的"),
("PackChoiceOverwrite", "pack version used", "用包里的"),
("PackConflictHashes", "pack {pack} · base {base} · disk {disk}", "包内 {pack} · 基线 {base} · 磁盘 {disk}"),
("PackConflictDetail", "Hashes", "三方哈希"),
("PackPickTitle", "Before importing: {count} files already exist with different content", "导入前:有 {count} 个文件已存在且内容不同"),
("PackPickSummary", "Keep {keep} · overwrite {overwrite} · overwriting discards {bytes}", "保留 {keep} · 覆盖 {overwrite} · 覆盖将丢弃 {bytes}"),
("PackPickKeepAll", "Keep all mine", "全部保留我的"),
("PackPickOverwriteAll", "Overwrite all", "全部覆盖"),
("PackPickRowKeep", "Keep", "保留"),
("PackPickRowOverwrite", "Overwrite", "覆盖"),
("PackPickPage", "Items {from}-{to} of {total}", "第 {from}-{to} 项,共 {total} 项"),
("PackPickPrev", "Previous", "上一页"),
("PackPickNext", "Next", "下一页"),
("PackPickConfirm", "Import with these choices", "按这些选择导入"),
("PackPickCancel", "Cancel import", "取消导入"),
("PackPickDefaultNote", "Anything you do not change stays as you have it: file content is only replaced where you pick “Overwrite”.", "没有改动的一律按「保留」:只有你点了「覆盖」的文件才会被包内内容替换。"),
("PackPickUnknownRemote", "{count} remote entries only learn their path after download; they follow “keep”.", "还有 {count} 条远端条目要下载后才知道路径,一律按「保留」处置。"),
("PackPickSourceOverrides", "in the pack (overrides/)", "包内 overrides/"),
("PackPickSourceClientOverrides", "in the pack (client-overrides/)", "包内 client-overrides/"),
("PackPickSourceManifest", "manifest files[]", "清单 files[]"),
("PackPickDisk", "yours: {size}", "你的:{size}"),
("PackPickIncoming", "pack: {size}", "包内:{size}"),
("PackFormatModrinth", "Modrinth .mrpack", "Modrinth 的 .mrpack"),
("PackFormatCurseforge", "CurseForge manifest", "CurseForge 清单"),
("PackFormatMultimc", "MultiMC / Prism instance", "MultiMC·Prism 实例"),
("PackExportTitle", "Export this instance", "导出这个实例"),
("PackExportOpen", "Export…", "导出…"),
("PackExportFormat", "Format", "格式"),
("PackExportName", "Pack name", "包名"),
("PackExportVersion", "Version", "版本"),
("PackExportOutput", "Save to", "保存到"),
("PackExportOutputPlaceholder", "empty = suggested path", "留空 = 自动给一个路径"),
("PackExportPrecheck", "Dry run", "预检"),
("PackExportConfirm", "Write the pack", "确认写盘"),
("PackExportAbort", "Back", "返回"),
("PackExportMultimcNote", "MultiMC/Prism can only be imported, not exported (by design).", "MultiMC·Prism 只能导入、不能导出(有意省略)。"),
("PackExportDone", "Wrote {path} · {bytes}", "已写出 {path} · {bytes}"),
("PackExportFailed", "Export failed: {reason}", "导出失败:{reason}"),
("PackExportPending", "Scanning the instance…", "正在扫描实例…"),
("PackPreviewHead", "Will inline {inline} files · cannot export: {skipped}", "将内联 {inline} 个文件 · 无法导出 {skipped} 项"),
("PackPreviewRemote", "{count} files can be referenced remotely", "{count} 个文件可走远程引用"),
("PackPreviewInlineBytes", "{bytes} copied into overrides/", "{bytes} 会原样拷进 overrides/"),
("PackPreviewInlineTitle", "Copied into overrides/ (first {shown}):", "原样拷进 overrides/(只列前 {shown} 个):"),
("PackPreviewSkippedTitle", "Not in the pack: {count}", "没进包:{count} 项"),
("PackPreviewNotes", "Known losses", "已知损失"),
("PackPreviewTarget", "Will be written to {path}", "将写到 {path}"),
("PackPreviewMore", "…and {count} more files (not shown here)", "…还有 {count} 个文件(这里不展开)"),
("PackPreviewComplete", "Nothing is left out", "没有遗漏任何文件"),
("PackPreviewIncomplete", "Some files will not be in the pack", "有文件不会进包"),
("PackReasonModrinthSha1", "Modrinth match (SHA-1)", "Modrinth 命中(SHA-1)"),
("PackReasonCurseFingerprint", "CurseForge match (fingerprint)", "CurseForge 命中(文件指纹)"),
("PackReasonNoRemoteMatch", "unknown to both catalogs, inlined", "两个目录都不认识,已内联"),
("PackReasonCurseOnly", "only CurseForge knows it, this format cannot use it, inlined", "只有 CF 认识,目标格式用不上,已内联"),
("PackReasonModrinthOnly", "only Modrinth knows it, the CF manifest cannot use it, inlined", "只有 Modrinth 认识,CF 清单用不上,已内联"),
("PackReasonMissingSha512", "the catalogue entry has no sha512 hash, inlined", "命中缺 sha512,已内联"),
("PackReasonMissingCurseIds", "the catalogue entry has no CurseForge ids, inlined", "命中缺 projectID/fileID,已内联"),
("PackReasonDisabled", "disabled file, this format cannot express it (.disabled kept)", "禁用文件,目标格式表达不了(保留 .disabled)"),
("PackReasonSymlink", "symbolic link, skipped", "符号链接,已跳过"),
("PackReasonUnsafePath", "path fails the pack path rules, skipped", "路径过不了包内规整,已跳过"),
("PackReasonUnreadable", "cannot read it, skipped", "读不出来,已跳过"),
("PackStatusFailedNotStarted", "Import failed — nothing was written", "导入失败 —— 还没写任何文件"),
("PackStatusFailedIncomplete", "Import failed — rollback did not finish", "导入失败 —— 回滚没做完"),
("PackFatalTitleNotStarted", "Preflight failed — nothing was written", "预检未通过 —— 一个字节都没写"),
("PackFatalTitleIncomplete", "Rollback did not finish — the target folder may differ", "回滚没做完 —— 目标目录可能与导入前不同"),
("AccountTitle", "Accounts", "账户"),
("AccountSubtitle", "Microsoft, third-party skin servers and local profiles", "微软正版、第三方皮肤站与本地档案"),
("AccountListTitle", "All accounts", "全部账户"),
("AccountAddedOn", "Added {date}", "添加于 {date}"),
("AccountActiveChip", "Active", "当前"),
("AccountSwitch", "Use for launch", "切换为启动账户"),
("AccountRelogin", "Sign in again", "重新登录"),
("AccountRemove", "Remove", "删除"),
("AccountKindMicrosoft", "Microsoft", "微软正版"),
("AccountKindAuthlib", "Third-party skin server", "第三方皮肤站"),
("AccountKindOffline", "Local profile", "本地档案"),
("AccountServer", "Server {server}", "服务器 {server}"),
("AccountEmptyTitle", "No accounts yet", "还没有账户"),
("AccountEmptyBody", "Add a Microsoft account, a third-party skin server account, or a local profile.", "添加微软正版账户、第三方皮肤站账户,或建一个本地档案。"),
("AccountEmptyAction", "Add an account", "添加账户"),
("AccountTrackMicrosoftTitle", "Microsoft account", "微软正版"),
("AccountTrackMicrosoftBody", "Sign in with a device code or in your browser. Online servers require this kind of account.", "用设备码或浏览器登录。联机服务器必须用这种账户。"),
("AccountTrackAuthlibTitle", "Third-party skin server", "第三方皮肤站"),
("AccountTrackAuthlibBody", "An authlib-injector compatible server (LittleSkin and friends) brings its own skins and capes.", "兼容 authlib-injector 的皮肤站(如 LittleSkin)自带皮肤与披风。"),
("AccountTrackOfflineTitle", "Local profile", "本地档案"),
("AccountTrackOfflineBody", "Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"),
("AccountTrackOpen", "Sign in", "登录"),
("AccountTrackCreate", "Create profile", "创建档案"),
("AccountDialogClose", "Close", "关闭"),
("AccountOfflineNameLabel", "Profile name", "档案名"),
("AccountOfflineNameHint", "3–16 letters, digits or underscore", "3–16 位字母、数字或下划线"),
("AccountOfflineSubmit", "Create profile", "创建档案"),
("AccountOfflineWarning", "Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"),
("AccountDeviceCodeLabel", "Enter this code", "输入这个用户码"),
("AccountDeviceCodeCopy", "Copy code", "复制用户码"),
("AccountDeviceLinkCopy", "Copy link", "复制链接"),
("AccountDeviceCountdown", "{time} left", "剩 {time}"),
("AccountDeviceExpired", "This code has expired. Start again for a fresh one.", "这个用户码已过期,重新开始换一个新的。"),
("AccountDeviceRetry", "Try again", "重试"),
("AccountDeviceCancel", "Cancel", "取消"),
("AccountDeviceBrowser", "Sign in with browser", "用浏览器登录"),
("AccountDeviceUseCode", "Use a device code instead", "改用设备码"),
("AccountServerLabel", "Server address", "服务器地址"),
("AccountServerHint", "API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"),
("AccountServerProbe", "Check server", "校验服务器"),
("AccountLoginNameLabel", "Email or username", "邮箱或用户名"),
("AccountPasswordLabel", "Password", "密码"),
("AccountPasswordHint", "Used once for this sign-in. It is never stored.", "只在这次登录里用一次,不会保存。"),
("AccountAuthlibSubmit", "Sign in", "登录"),
("AccountAuthlibIdleHint", "The server is checked automatically when you sign in; Check server only does it up front.", "登录时会自动校验服务器;「校验服务器」只是提前跑一遍。"),
("AccountSkinTitle", "Skin", "皮肤"),
("AccountSkinNone", "No skin available", "没有可用皮肤"),
("AccountSkinHint", "Skins come from the account's own server; nothing is generated locally.", "皮肤来自账户自己的服务器,本地不生成任何假皮肤。"),
("AccountRailSignedOut", "Not signed in", "未登录"),
("AccountDeviceHint", "Open the link in your browser and enter the code above.", "在浏览器里打开链接,输入上面的用户码。"),
("AccountDeviceBrowserHint", "Finish the sign-in in the browser window we just opened; the link below also works.", "在我们刚打开的浏览器窗口里完成登录;下面的链接也能手动打开。"),
("AccountServerChecking", "Checking the server…", "正在校验服务器…"),
("AccountServerOk", "Server {server} is ready", "服务器 {server} 可用"),
("AccountLoginFailed", "Sign-in failed", "登录失败"),
("AccountCurrentTitle", "Current account", "当前账户"),
("AccountFailureCode", "Code: {code}", "代码:{code}"),
("AccountFailureDenied", "The server refused this sign-in (access denied).", "服务器拒绝了这次登录(拒绝访问)。"),
("AccountFailureWhitelist", "Microsoft has not approved this launcher's app registration yet. An administrator has to accept the app, then try again.", "微软还没批准本启动器的应用注册:需要管理员同意该应用后再试。"),
("AccountFailureUnreachable", "Could not reach the server. Check the network connection or the address, then try again.", "连不上服务器:检查网络或地址后重试。"),
("AccountFailureNotAuthlib", "This address does not look like an authlib-injector server (the API root usually ends with /api/yggdrasil/).", "这个地址不像 authlib-injector 服务器(API 根通常以 /api/yggdrasil/ 结尾)。"),
("AccountFailureBadCredentials", "The server rejected the username or password.", "服务器不认这个用户名或密码。"),
("AccountFailureNoProfile", "The account has no Minecraft profile on this server yet. Create the character on the server first.", "这个账号在该服务器上还没有角色:先在皮肤站里创建角色。"),
("AccountFailureStore", "Could not save the account to disk.", "账户没能写到磁盘上。"),
("AccountFailureOther", "The sign-in failed. The full reason is in the launcher log.", "登录失败,完整原因在启动器日志里。"),
("AccountLoginNotConfigured", "Microsoft sign-in is not configured", "微软登录未配置"),
("AccountFailureMsaNotConfigured", "This build has no Azure public client id, so Microsoft sign-in cannot start (the sign-in flow itself is implemented). Set the environment variable ULL_MSA_CLIENT_ID=<id> before starting the launcher, or build with ULL_MSA_CLIENT_ID=<id>. Then restart the launcher: a running process does not pick up a new environment variable.", "本启动器没有配置 Azure 公共客户端 id,微软登录无法开始(登录功能已实现,只差这一项配置)。启动前设置环境变量 ULL_MSA_CLIENT_ID=<id>,或构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build。改完请重启启动器——已在运行的进程读不到新设的环境变量。"),
("AccountDeviceRequesting", "Contacting Microsoft…", "正在联系微软…"),
("AccountLoginSucceeded", "Signed in as {name}", "已登录 {name}"),
("AccountNoticeAdded", "Added {name}", "已添加 {name}"),
("AccountNoticeRemoved", "Removed {name}", "已删除 {name}"),
("AccountNoticeSwitched", "{name} will be used for the next launch", "{name} 将作为下次启动的账户"),
("AccountNoticeOfflineNameInvalid", "Profile names must be 3–16 letters, digits or underscores.", "档案名必须是 3–16 位字母、数字或下划线。"),
("AccountNoticeAccountExists", "An account with that name already exists.", "同名账户已经存在。"),
("AccountNoticeStoreFailed", "Could not save the account store. Check the disk and try again.", "账户库写不进去:检查磁盘后重试。"),
("AccountNoticeNotFound", "That account no longer exists.", "这个账户已经不在了。"),
("AccountNoticeOther", "The account action failed. The full reason is in the launcher log.", "账户操作失败,完整原因在启动器日志里。"),
("AccountNoticeDismiss", "Dismiss", "关闭"),
("AccountNoticeTitleFailed", "Account action failed", "账户操作失败"),
("AccountNoticeTitleDone", "Account updated", "账户已更新"),
("AccountHintAuthlibReauth", "Enter the password again for {server}.", "请输入 {server} 的密码。"),
("AccountHintOfflineNoRelogin", "Local profiles have no server account to sign in again — create a new profile instead.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"),
("SettingsGroupUpdate", "Updates", "更新"),
("SettingsUpdateCurrent", "Current version", "当前版本"),
("SettingsUpdateChannel", "Channel", "更新渠道"),
("SettingsUpdateChannelStable", "Stable", "稳定版"),
("SettingsUpdateAutoCheck", "Check for updates on startup", "启动时检查更新"),
("SettingsUpdateCheckNow", "Check now", "立即检查"),
("SettingsUpdateChecking", "Checking…", "正在检查…"),
("SettingsUpdateNever", "Not checked yet", "还没检查过"),
("SettingsUpdateUpToDate", "You are on the latest version ({version})", "已是最新({version})"),
("SettingsUpdateAvailable", "Version {version} is available ({size})", "有新版本 {version}({size})"),
("SettingsUpdateMissingArtifact", "Version {version} is out, but there is no build for this platform yet.", "{version} 已发布,但还没有本平台的安装包。"),
("SettingsUpdateFailed", "Check failed: {reason}", "检查失败:{reason}"),
("SettingsUpdateApply", "Download and install", "下载并安装"),
("SettingsUpdateApplying", "Downloading and verifying…", "正在下载并校验…"),
("SettingsUpdateReady", "Version {version} is ready — restart to use it.", "版本 {version} 已就位,重启后生效。"),
("SettingsUpdateNotes", "Release notes: {notes}", "更新说明:{notes}"),
("SettingsUpdateForced", "This update is required", "这是一次强制更新"),
("SettingsUpdateLastChecked", "Last check", "上次检查"),
("SettingsUpdateHint", "Downloads are verified with SHA256 before they touch the launcher. The running copy stays as it is until you restart.", "更新包在落到启动器之前会做 SHA256 校验;正在运行的这一份在你重启之前不会变。"),
("UpdateBannerTitleAvailable", "Version {version} is available", "发现新版本 {version}"),
("UpdateBannerBodyAvailable", "Download size {size}", "下载体积 {size}"),
("UpdateBannerTitleReady", "Update ready", "更新已就位"),
("UpdateBannerBodyReady", "Version {version} will run after a restart.", "重启后运行版本 {version}。"),
("UpdateBannerTitleFailed", "Update failed", "更新失败"),
("UpdateBannerApplying", "Downloading the update…", "正在下载更新…"),
("UpdateBannerInstall", "Download and install", "下载并安装"),
("UpdateBannerDismiss", "Skip this version", "忽略这个版本"),
("UpdateBannerRestartNow", "Restart now", "立即重启"),
("UpdateBannerRetry", "Try again", "重试"),
];
+1 -1
View File
@@ -73,7 +73,7 @@ macro_rules! instances_entries {
InstancesIsolationShared => ("Shared", "共用目录"), InstancesIsolationShared => ("Shared", "共用目录"),
// ── 运行状态与启动阶段(M120;数字与分隔符不随语言变形) // ── 运行状态与启动阶段(M120;数字与分隔符不随语言变形)
InstancesRunning => ("Running · pid {pid}", "运行中 · pid {pid}"), InstancesRunning => ("Running · pid {pid}", "运行中 · pid {pid}"),
InstancesFailed => ("Launch failed · {reason}", "启动失败 · {reason}"), InstancesFailedStage => ("Launch failed · {stage}", "启动失败 · {stage}"),
InstancesStageDownload => ("Downloading", "下载中"), InstancesStageDownload => ("Downloading", "下载中"),
InstancesStageVerify => ("Verifying", "校验中"), InstancesStageVerify => ("Verifying", "校验中"),
InstancesStageLaunch => ("Starting", "启动中"), InstancesStageLaunch => ("Starting", "启动中"),
+11
View File
@@ -14,6 +14,17 @@ macro_rules! launch_entries {
"疑似静默失败:退出码 0,但游戏没真正跑起来" "疑似静默失败:退出码 0,但游戏没真正跑起来"
), ),
LaunchFailedWithCode => ("Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"), LaunchFailedWithCode => ("Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"),
// M161:启动失败的**阶段**词表。presenter 只给稳定 slug
// (`LaunchFailureStage`),界面在这里选词——此前这些前缀是 presenter 里拼的中文
// (「读版本 JSON 失败」「隔离目录派生失败」…),英文界面上原样露出来。
LaunchStageVersionManifest => ("Reading the version file", "读版本文件"),
LaunchStageInheritance => ("Resolving the inheritance chain", "解析继承链"),
LaunchStageInstanceConfig => ("Reading the instance settings", "读实例设置"),
LaunchStageIsolation => ("Preparing the isolated game folder", "准备隔离游戏目录"),
LaunchStageIdentity => ("Preparing the account", "准备账户"),
LaunchStageSpawn => ("Starting the game process", "启动游戏进程"),
LaunchStageFailed => ("Launch failed · {stage}: {detail}", "启动失败 · {stage}:{detail}"),
LaunchToastEnded => ("{outcome} · {seconds}s", "{outcome} · {seconds} 秒"),
// M117:微软轨的 client id 没配是**配置缺失**,不是「本期未支持」。这句话是那条 // M117:微软轨的 client id 没配是**配置缺失**,不是「本期未支持」。这句话是那条
// 启动失败文案的**双语口径**(真源在 `core::auth::microsoft` 的 // 启动失败文案的**双语口径**(真源在 `core::auth::microsoft` 的
// `MISSING_CLIENT_ID_MESSAGE`,中文逐字一致;英文这条给 En 界面用)。 // `MISSING_CLIENT_ID_MESSAGE`,中文逐字一致;英文这条给 En 界面用)。
+23 -4
View File
@@ -26,6 +26,7 @@ use crate::dto::{
InstanceSummary, InstanceSummary,
JavaPageView, JavaPageView,
LaunchArgsPatch, LaunchArgsPatch,
LaunchFailureView,
LoaderKind, LoaderKind,
LogLine, LogLine,
LogOutcome, LogOutcome,
@@ -54,6 +55,11 @@ pub struct LiveLogBatch {
} }
/// 一次启动收尾(磁贴状态回写 + 日志页会话列表刷新)。 /// 一次启动收尾(磁贴状态回写 + 日志页会话列表刷新)。
///
/// **不带人读散文**(M161):早先这里有一个 `summary: String`,内容是 presenter 拼的中文
/// (`正常退出` / `崩溃(exit 1)` / `启动失败:找不到 Java`)。于是英文界面上弹出的是中文 Toast,
/// 磁贴那行还叠成「Launch failed · 启动失败:…」。现在只给**结构**,文案由 app 层按 key 现求
/// (与 `InstallCancelled` 同一条纪律:presenter 不依赖 `unlucky-i18n`)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchFinished { pub struct LaunchFinished {
/// 实例 id /// 实例 id
@@ -62,8 +68,13 @@ pub struct LaunchFinished {
pub session: SessionId, pub session: SessionId,
/// 结局 /// 结局
pub outcome: LogOutcome, pub outcome: LogOutcome,
/// 一句人读结论(`正常退出` / `崩溃(exit 1)` / `启动失败:…`) /// 进程退出码(`None` = 进程没起来 / 平台没给)
pub summary: String, pub exit_code: Option<i32>,
/// 从 spawn 到收尾的毫秒数(没起来时是 0)
pub uptime_ms: u64,
/// 失败发生在哪一步(`None` = 这一场没有装配/启动阶段的失败;
/// 「跑起来又崩了」看 `outcome`)
pub failure: Option<LaunchFailureView>,
} }
/// core 侧事件(不可变快照发布 + 事件流,01c §5 的三条通道之二)。 /// core 侧事件(不可变快照发布 + 事件流,01c §5 的三条通道之二)。
@@ -248,9 +259,17 @@ pub enum AppCommand {
CheckUpdate, CheckUpdate,
/// 把当前候选下载下来、校验、替换本程序(结果经 [`CoreEvent::Update`] 回来) /// 把当前候选下载下来、校验、替换本程序(结果经 [`CoreEvent::Update`] 回来)
ApplyUpdate, ApplyUpdate,
/// 忽略这个版本(本次会话不再弹提示条;候选仍留在快照里,设置页可手动装) /// 忽略**这一个候选版本**(记的是版本号,不是「别再问我」)。
///
/// 语义三条(`UpdateView::skipped` 的契约,`dto::update` 的模块文档与用例守着):
/// 同一次运行里同一个版本不再弹提示条;**更高的版本照弹**(0.2.3 被忽略不影响 0.2.4);
/// 设置页的「立即检查」会清掉它。候选本身仍留在快照里,设置页可手动装。
DismissUpdate, DismissUpdate,
/// 开关「启动时静默检查」(写进更新域自己的偏好文件) /// 开关「启动时静默检查」。
///
/// 落点是主配置 `config/launcher.json` 的 `update.auto_check`(M161 从更新域自己的
/// `cache/update/prefs.json` 迁过来的 schema 键)——`ull config get update.auto_check`
/// 读到的是同一个值。
SetUpdateAutoCheck(bool), SetUpdateAutoCheck(bool),
} }
+10 -2
View File
@@ -2,6 +2,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub use super::launch_failure::{LaunchFailureStage, LaunchFailureView};
use std::path::PathBuf; use std::path::PathBuf;
use unlucky_core::game::LoaderKind as CoreLoaderKind; use unlucky_core::game::LoaderKind as CoreLoaderKind;
@@ -208,9 +210,15 @@ pub enum InstanceRuntime {
pid: u32, pid: u32,
}, },
/// 上次启动失败(磁贴显示「重试」) /// 上次启动失败(磁贴显示「重试」)
///
/// M161 之前这里只有一个 `reason: String`,而且里面已经写着「启动失败:…」——
/// 界面再包一层 `InstancesFailed`(`Launch failed · {reason}`)就成了「英文前缀 + 中文句子」,
/// 中文界面则是「启动失败 · 启动失败:…」。现在拆成「阶段(可本地化)+ 原文(不翻译)」。
Failed { Failed {
/// 失败原因(用户可见文案) /// 失败阶段
reason: String, stage: LaunchFailureStage,
/// 原始错误文本(不翻译)
detail: String,
}, },
} }
@@ -0,0 +1,74 @@
//! 启动失败的**结构**(M161:presenter 只给阶段 + 原文,文案由界面按 i18n key 现求)。
//!
//! 从 `dto/instances.rs` 拆出:那边贴着 400 行硬线,而这两个类型是「启动」这条线上的
//! 独立一块(磁贴状态行、收尾事件、Toast 三处都读它)。
use serde::{Deserialize, Serialize};
/// 启动失败发生在**哪一步**(M161:界面文案按它选 i18n key,presenter 不写散文)。
///
/// 分档只在「用户该去做什么」不同的地方切,不按代码结构切:
///
/// | 档 | 意思 | 用户下一步 |
/// |---|---|---|
/// | [`Self::VersionManifest`] | `versions/<id>.json` 读不出来 / 解析不了 | 去版本管理重装这个版本 |
/// | [`Self::Inheritance`] | 继承链断了(父版本缺失) | 同上(父版本缺了) |
/// | [`Self::InstanceConfig`] | 实例设置文件读不出来 | 去实例页看一眼那个实例 |
/// | [`Self::Isolation`] | 隔离目录派生失败(路径/权限) | 换隔离档位或修目录权限 |
/// | [`Self::Identity`] | 账户装配失败(找不到账户 / 凭据过期) | 去账户页重新登录 |
/// | [`Self::Spawn`] | 命令行装配完了但进程起不来 | 看 Java 与日志 |
///
/// 「跑起来了但崩了」不在这张表里:那是**结局**,由 `LogOutcome` 表达(三个变体各有 i18n key)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LaunchFailureStage {
/// 版本 JSON 读不出来 / 解析不了
VersionManifest,
/// 继承链断裂
Inheritance,
/// 实例设置文件读不出来
InstanceConfig,
/// 隔离目录派生失败
Isolation,
/// 账户装配失败
Identity,
/// 进程起不来
Spawn,
}
impl LaunchFailureStage {
/// 稳定 slug(日志 / 事件流 / 测试用;**不要改名**)。
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::VersionManifest => "version_manifest",
Self::Inheritance => "inheritance",
Self::InstanceConfig => "instance_config",
Self::Isolation => "isolation",
Self::Identity => "identity",
Self::Spawn => "spawn",
}
}
}
/// 一次启动失败:**哪一步** + 那一步的原文。
///
/// `detail` 是当场的原始错误文本(core / io / 账户层给的),**故意不翻译**:里面有路径、
/// 版本 id、HTTP 状态这类机器细节,翻成人话就丢了取证价值。要本地化的是「哪一步」
/// ([`LaunchFailureStage`]),界面按它选词。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchFailureView {
/// 失败阶段
pub stage: LaunchFailureStage,
/// 原始错误文本(不翻译)
pub detail: String,
}
impl LaunchFailureView {
/// 建一条。
pub fn new(stage: LaunchFailureStage, detail: impl Into<String>) -> Self {
Self {
stage,
detail: detail.into(),
}
}
}
+7 -6
View File
@@ -23,6 +23,7 @@ mod instances;
mod java; mod java;
// M157: 实例启动参数(启动参数区的读取视图 + 写回补丁) // M157: 实例启动参数(启动参数区的读取视图 + 写回补丁)
mod launch_args; mod launch_args;
mod launch_failure;
mod logs; mod logs;
mod notices; mod notices;
// M113: import/export —— 整合包导入/导出的 DTO(M111/M112 管线的 IPC 面) // M113: import/export —— 整合包导入/导出的 DTO(M111/M112 管线的 IPC 面)
@@ -45,8 +46,8 @@ pub use content::{
pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch}; pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch};
pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage}; pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage};
pub use instances::{ pub use instances::{
CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo, CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchFailureStage,
LoaderKind, Progress, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, Progress,
}; };
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView}; pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
pub use launch_args::{LaunchArgsPatch, LaunchArgsView}; pub use launch_args::{LaunchArgsPatch, LaunchArgsView};
@@ -57,10 +58,10 @@ pub use pack::{
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView, ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView,
ConflictReasonView, ConflictSourceView, ExportClassView, ExportDoneView, ExportFileView, ConflictReasonView, ConflictSourceView, ExportClassView, ExportDoneView, ExportFileView,
ExportPreviewView, ExportReasonView, ExportRequestView, ExportState, FileActionView, ExportPreviewView, ExportReasonView, ExportRequestView, ExportState, FileActionView,
ImportConflictView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView, ImportConflictView, ImportErrorView, ImportFailureStageView, ImportProgressView,
ImportScanView, ImportStageView, ImportState, ImportStatusView, ImportedFileView, ImportReportView, ImportRequestView, ImportScanView, ImportStageView, ImportState,
MissingFileView, MissingReasonView, PackCommand, PackEvent, PackFormatView, PackState, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView, PackCommand, PackEvent,
ProbeView, PackFormatView, PackState, ProbeView,
}; };
pub use settings::{SettingControl, SettingGroupView, SettingRowView}; pub use settings::{SettingControl, SettingGroupView, SettingRowView};
// M159: 自更新 // M159: 自更新
+5 -4
View File
@@ -13,7 +13,8 @@
//! 1. **三态**([`ImportStatusView`]):`Partial` 不是 `Success`。把「缺了 3 个文件」说成成功, //! 1. **三态**([`ImportStatusView`]):`Partial` 不是 `Success`。把「缺了 3 个文件」说成成功,
//! 用户会在启动后才发现缺依赖; //! 用户会在启动后才发现缺依赖;
//! 2. **容忍 / 致命的分野**:[`MissingFileView`] 是「搞不到某个文件」(实例可用), //! 2. **容忍 / 致命的分野**:[`MissingFileView`] 是「搞不到某个文件」(实例可用),
//! [`ImportErrorView`] 是「已整包回滚」(实例零残留)。UI 必须让这两类**视觉可区分**; //! [`ImportErrorView`] 是致命失败。UI 必须让这两类**视觉可区分**——并且致命那一类还要按
//! [`ImportReportView::failure_stage`] 再分三档说(预检没过 / 已回滚 / 回滚没做完,M161);
//! 3. **导出的三分类**([`ExportClassView`]):`Inline` 是「证明不了可被远程替代,原字节进 //! 3. **导出的三分类**([`ExportClassView`]):`Inline` 是「证明不了可被远程替代,原字节进
//! `overrides/`」,`Skipped` 是「压根没进包」。`skipped > 0` 意味着包**不完整**。 //! `overrides/`」,`Skipped` 是「压根没进包」。`skipped > 0` 意味着包**不完整**。
//! //!
@@ -56,7 +57,7 @@ pub use export::{
}; };
pub use format::PackFormatView; pub use format::PackFormatView;
pub use import::{ pub use import::{
FileActionView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView, FileActionView, ImportErrorView, ImportFailureStageView, ImportProgressView, ImportReportView,
ImportStageView, ImportState, ImportStatusView, ImportedFileView, MissingFileView, ImportRequestView, ImportStageView, ImportState, ImportStatusView, ImportedFileView,
MissingReasonView, ProbeView, MissingFileView, MissingReasonView, ProbeView,
}; };
+49 -2
View File
@@ -62,6 +62,43 @@ impl ImportStageView {
} }
} }
/// 致命失败的**阶段**(M161:`rolled_back` 一个人说不了两件事)。
///
/// core 的 `ImportReport::rolled_back` 语义是「目标目录已确认与导入前一致」——它同时覆盖
/// 「回滚完整完成」和「**从未写过**目标目录」两种情形(见 `core::addon::import::pipeline` 的
/// `record_rollback` / `abandoned`)。UI 拿一个 bool 判不出该说哪一句,于是预检失败也会被
/// 说成「已回滚」,而实际上一个字节都没写。这个枚举把两种情形重新分开,并补上第三种
/// (回滚有失败项,目标目录可能与导入前不同)。
///
/// 只在 `status == Failed` 时有值:成功/部分成功的报告没有「失败阶段」可言。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ImportFailureStageView {
/// 预检/装配阶段就失败了:**目标目录一个字节都没写**(用户该去修包/修配置再试)。
NotStarted,
/// 已经写过目标目录,回滚**完整**完成(零残留)。
RolledBack,
/// 写过了,但回滚有失败项:目标目录可能与导入前不同(`warnings` 里逐条列了)。
RollbackIncomplete,
}
impl ImportFailureStageView {
/// 稳定 slug(日志 / 测试用;**不要改名**)。
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::NotStarted => "not_started",
Self::RolledBack => "rolled_back",
Self::RollbackIncomplete => "rollback_incomplete",
}
}
/// 本次失败到底动没动目标目录。
#[must_use]
pub const fn touched_target(self) -> bool {
!matches!(self, Self::NotStarted)
}
}
/// 导入三态(`Partial` **不是** `Success`)。 /// 导入三态(`Partial` **不是** `Success`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ImportStatusView { pub enum ImportStatusView {
@@ -69,7 +106,11 @@ pub enum ImportStatusView {
Success, Success,
/// 有缺文件或有冲突(内容不完整,但实例可用)。 /// 有缺文件或有冲突(内容不完整,但实例可用)。
Partial, Partial,
/// 致命失败,**已整包回滚**。 /// 致命失败。
///
/// **「动没动过目标目录」不在这里**(M161):同一个 `Failed` 既可能是预检没过(一个字节都没写),
/// 也可能是写完又回滚干净,还可能是回滚没做完——三种由
/// [`ImportReportView::failure_stage`] 分开说,界面按它选标题与图标。
Failed, Failed,
} }
@@ -243,7 +284,7 @@ pub struct MissingFileView {
pub detail: String, pub detail: String,
} }
/// 一次**已整包回滚**的致命失败。 /// 一次致命失败(是不是「已整包回滚」看 [`ImportReportView::failure_stage`])。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImportErrorView { pub struct ImportErrorView {
/// 稳定码(`pack_traversal` / `import_integrity` …,进日志与 `--json`)。 /// 稳定码(`pack_traversal` / `import_integrity` …,进日志与 `--json`)。
@@ -292,7 +333,13 @@ pub struct ImportReportView {
/// 本次写入的字节数。 /// 本次写入的字节数。
pub bytes_written: u64, pub bytes_written: u64,
/// 回滚是否完整(`false` = 目标目录可能与导入前不一致,逐条在 `warnings` 里)。 /// 回滚是否完整(`false` = 目标目录可能与导入前不一致,逐条在 `warnings` 里)。
///
/// **注意**:`true` 有两种来源——「回滚完整完成」与「从未写过目标目录」(core 的
/// `abandoned`)。要区分它们看 [`ImportReportView::failure_stage`];界面上的措辞
/// **必须**按那个字段选(M161)。
pub rolled_back: bool, pub rolled_back: bool,
/// 失败发生在哪一步(`None` = 这次不是致命失败)。
pub failure_stage: Option<ImportFailureStageView>,
} }
impl ImportReportView { impl ImportReportView {
@@ -11,6 +11,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use unlucky_core::addon::export::{ExportReport, FileClass, FileReason}; use unlucky_core::addon::export::{ExportReport, FileClass, FileReason};
use unlucky_core::addon::import::report::ImportStatus;
use unlucky_core::addon::import::{ use unlucky_core::addon::import::{
ConflictChoice, ConflictDecisions, ConflictFile, ConflictReason, FileAction, FileSource, ConflictChoice, ConflictDecisions, ConflictFile, ConflictReason, FileAction, FileSource,
ImportError, ImportProgress, ImportReport, ImportScan, LoaderTarget, MissingReason, ImportError, ImportProgress, ImportReport, ImportScan, LoaderTarget, MissingReason,
@@ -19,9 +20,9 @@ use unlucky_core::addon::import::{
use crate::dto::{ use crate::dto::{
ConflictChoiceView, ConflictDecisionsView, ConflictFileView, ConflictReasonView, ConflictChoiceView, ConflictDecisionsView, ConflictFileView, ConflictReasonView,
ConflictSourceView, ExportClassView, ExportDoneView, ExportReasonView, FileActionView, ConflictSourceView, ExportClassView, ExportDoneView, ExportReasonView, FileActionView,
ImportConflictView, ImportErrorView, ImportProgressView, ImportReportView, ImportScanView, ImportConflictView, ImportErrorView, ImportFailureStageView, ImportProgressView,
ImportStageView, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView, ImportReportView, ImportScanView, ImportStageView, ImportStatusView, ImportedFileView,
PackFormatView, MissingFileView, MissingReasonView, PackFormatView,
}; };
/// 一次导入**预检** → DTO(M160:冲突清单)。 /// 一次导入**预检** → DTO(M160:冲突清单)。
@@ -150,9 +151,29 @@ pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportV
.map(|registered| registered.name.clone()), .map(|registered| registered.name.clone()),
bytes_written: report.bytes_written, bytes_written: report.bytes_written,
rolled_back: report.rolled_back, rolled_back: report.rolled_back,
failure_stage: failure_stage(report),
} }
} }
/// 致命失败发生在哪一步(M161)。
///
/// 判据只用 core 的两个机器位,**不看 warnings 的措辞**:
/// - `rollback: Some(outcome)` ⇒ 真的跑过回滚(写过了),完整与否看 `is_complete()`;
/// - `rollback: None` ⇒ 没跑过回滚。core 的约定是「失败发生在碰目标目录之前」(`abandoned`),
/// 此时 `rolled_back == true`;若 `rolled_back` 也是 `false`(既没回滚也没宣称干净),
/// 那就如实报「可能不一致」,不替它圆场。
fn failure_stage(report: &ImportReport) -> Option<ImportFailureStageView> {
if report.status != ImportStatus::Failed {
return None;
}
Some(match &report.rollback {
Some(outcome) if outcome.is_complete() => ImportFailureStageView::RolledBack,
Some(_) => ImportFailureStageView::RollbackIncomplete,
None if report.rolled_back => ImportFailureStageView::NotStarted,
None => ImportFailureStageView::RollbackIncomplete,
})
}
/// 一次导出结果 → DTO。 /// 一次导出结果 → DTO。
pub(super) fn done_view(report: &ExportReport) -> ExportDoneView { pub(super) fn done_view(report: &ExportReport) -> ExportDoneView {
ExportDoneView { ExportDoneView {
@@ -296,6 +317,8 @@ fn failed_report_with(target: PathBuf, pack: &Path, error: ImportErrorView) -> I
registered: None, registered: None,
bytes_written: 0, bytes_written: 0,
rolled_back: true, rolled_back: true,
// 预检/装配阶段就失败了:目标目录一个字节都没写(不是「写完又回滚」)。
failure_stage: Some(ImportFailureStageView::NotStarted),
} }
} }
+15 -5
View File
@@ -2,7 +2,10 @@
//! //!
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。 //! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。
use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LogOutcome, PackEvent}; use crate::dto::{
AppEvent, CoreEvent, InstanceRuntime, LaunchFailureStage, LaunchFailureView, LogOutcome,
PackEvent,
};
use crate::toast::ToastKind; use crate::toast::ToastKind;
use super::{addon, logs, CoreHost, HostTask, LiveLaunch}; use super::{addon, logs, CoreHost, HostTask, LiveLaunch};
@@ -160,12 +163,19 @@ impl CoreHost {
self.failed.remove(&finished.id); self.failed.remove(&finished.id);
InstanceRuntime::Idle InstanceRuntime::Idle
} else { } else {
// 记住失败原因:随后的 refresh_instances 会重建快照, // 记住失败(阶段 + 原文):随后的 refresh_instances 会重建快照,
// 不记的话磁贴的「失败/重试」外观会被 Idle 盖掉。 // 不记的话磁贴的「失败/重试」外观会被 Idle 盖掉。
self.failed let failure = finished.failure.clone().unwrap_or_else(|| {
.insert(finished.id.clone(), finished.summary.clone()); // 跑起来再崩:没有「装配阶段」可言,用结局本身当阶段外的说明。
LaunchFailureView::new(
LaunchFailureStage::Spawn,
format!("exit {:?}", finished.exit_code),
)
});
self.failed.insert(finished.id.clone(), failure.clone());
InstanceRuntime::Failed { InstanceRuntime::Failed {
reason: finished.summary.clone(), stage: failure.stage,
detail: failure.detail,
} }
}; };
self.send(CoreEvent::InstanceRuntimeChanged { self.send(CoreEvent::InstanceRuntimeChanged {
+28 -15
View File
@@ -11,7 +11,7 @@ use unlucky_core::download::blocking::block_on;
use unlucky_core::game::ProfileId; use unlucky_core::game::ProfileId;
use unlucky_core::launch::request::AuthPlaceholders; use unlucky_core::launch::request::AuthPlaceholders;
use crate::dto::LogOutcome; use crate::dto::{LaunchFailureStage, LaunchFailureView, LogOutcome};
use super::env::HostEnv; use super::env::HostEnv;
@@ -22,13 +22,16 @@ use super::env::HostEnv;
/// # Errors /// # Errors
/// ///
/// 账户不存在/凭据类型不符/令牌过期且刷新失败时返回用户可读文案。 /// 账户不存在/凭据类型不符/令牌过期且刷新失败时返回用户可读文案。
pub fn resolve(env: &HostEnv, account: Option<&str>) -> Result<(AuthPlaceholders, String), String> { pub fn resolve(
env: &HostEnv,
account: Option<&str>,
) -> Result<(AuthPlaceholders, String), LaunchFailureView> {
let store = AccountStore::open(env.layout.auth_data()); let store = AccountStore::open(env.layout.auth_data());
let chosen = match account { let chosen = match account {
Some(key) => store Some(key) => store
.find(key) .find(key)
.cloned() .cloned()
.ok_or_else(|| format!("找不到账户 `{key}`"))?, .ok_or_else(|| identity_failure(format!("找不到账户 `{key}`")))?,
None => match store.accounts() { None => match store.accounts() {
[] => { [] => {
let uuid = ProfileId::generate().as_str().replace('-', ""); let uuid = ProfileId::generate().as_str().replace('-', "");
@@ -44,27 +47,32 @@ pub fn resolve(env: &HostEnv, account: Option<&str>) -> Result<(AuthPlaceholders
)); ));
} }
[only] => only.clone(), [only] => only.clone(),
many => return Err(format!("有 {} 个账户,请在账户页选一个再启动", many.len())), many => {
return Err(identity_failure(format!(
"有 {} 个账户,请在账户页选一个再启动",
many.len()
)))
}
}, },
}; };
let line = format!("账户 {}({})", chosen.display_name(), chosen.kind.label()); let line = format!("账户 {}({})", chosen.display_name(), chosen.kind.label());
match chosen.kind { match chosen.kind {
AccountKind::Offline => { AccountKind::Offline => {
let placeholders = let placeholders = offline_login(&chosen)
offline_login(&chosen).map_err(|error| format!("离线账户解析失败:{error}"))?; .map_err(|error| identity_failure(format!("离线账户解析失败:{error}")))?;
Ok((placeholders, line)) Ok((placeholders, line))
} }
AccountKind::Microsoft => { AccountKind::Microsoft => {
let Some(Credentials::Microsoft(session)) = store.credentials(&chosen.id) else { let Some(Credentials::Microsoft(session)) = store.credentials(&chosen.id) else {
return Err(format!( return Err(identity_failure(format!(
"账户 {} 的微软凭据缺失或类型不符,请重新登录", "账户 {} 的微软凭据缺失或类型不符,请重新登录",
chosen.display_name() chosen.display_name()
)); )));
}; };
let auth = let auth = MicrosoftAuth::from_env()
MicrosoftAuth::from_env().map_err(|error| format!("微软登录不可用:{error}"))?; .map_err(|error| identity_failure(format!("微软登录不可用:{error}")))?;
let (fresh, _) = block_on(auth.ensure_fresh(session)) let (fresh, _) = block_on(auth.ensure_fresh(session))
.map_err(|error| format!("微软令牌刷新失败:{error}"))?; .map_err(|error| identity_failure(format!("微软令牌刷新失败:{error}")))?;
if &fresh != session { if &fresh != session {
// refresh_token 已轮换:立刻回写,旧值作废。 // refresh_token 已轮换:立刻回写,旧值作废。
let mut store = AccountStore::open(env.layout.auth_data()); let mut store = AccountStore::open(env.layout.auth_data());
@@ -73,24 +81,29 @@ pub fn resolve(env: &HostEnv, account: Option<&str>) -> Result<(AuthPlaceholders
} }
let placeholders = fresh let placeholders = fresh
.to_placeholders() .to_placeholders()
.map_err(|error| format!("微软身份转换失败:{error}"))?; .map_err(|error| identity_failure(format!("微软身份转换失败:{error}")))?;
Ok((placeholders, line)) Ok((placeholders, line))
} }
AccountKind::AuthlibInjector => { AccountKind::AuthlibInjector => {
let Some(Credentials::AuthlibInjector(session)) = store.credentials(&chosen.id) else { let Some(Credentials::AuthlibInjector(session)) = store.credentials(&chosen.id) else {
return Err(format!( return Err(identity_failure(format!(
"账户 {} 的第三方凭据缺失或类型不符", "账户 {} 的第三方凭据缺失或类型不符",
chosen.display_name() chosen.display_name()
)); )));
}; };
let placeholders = session let placeholders = session
.to_placeholders() .to_placeholders()
.map_err(|error| format!("第三方身份转换失败:{error}"))?; .map_err(|error| identity_failure(format!("第三方身份转换失败:{error}")))?;
Ok((placeholders, line)) Ok((placeholders, line))
} }
} }
} }
/// 账户/身份这一步的失败(阶段固定 `Identity`;`detail` 保留原始文案与本机错误)。
fn identity_failure(detail: String) -> LaunchFailureView {
LaunchFailureView::new(LaunchFailureStage::Identity, detail)
}
/// `SessionHandle` → 日志页的三种结局。 /// `SessionHandle` → 日志页的三种结局。
pub fn outcome_of(handle: &unlucky_core::launch::SessionHandle) -> LogOutcome { pub fn outcome_of(handle: &unlucky_core::launch::SessionHandle) -> LogOutcome {
if handle.exit.silent_failure.is_some() { if handle.exit.silent_failure.is_some() {
-23
View File
@@ -21,7 +21,6 @@ use crate::dto::{
LiveLogBatch, LiveLogBatch,
LoaderKind, LoaderKind,
LogLine, LogLine,
LogOutcome,
LogSession, LogSession,
NoticeView, NoticeView,
PackEvent, PackEvent,
@@ -155,18 +154,6 @@ pub fn spawn(name: &str, job: impl FnOnce() + Send + 'static) -> std::thread::Jo
.unwrap_or_else(|error| panic!("起不来后台线程 {name}: {error}")) .unwrap_or_else(|error| panic!("起不来后台线程 {name}: {error}"))
} }
/// 人读一行「启动结束」。
pub fn launch_summary(outcome: LogOutcome, exit_code: Option<i32>) -> String {
match outcome {
LogOutcome::Ok => "正常退出".to_string(),
LogOutcome::Crashed => match exit_code {
Some(code) => format!("崩溃(exit {code})"),
None => "崩溃".to_string(),
},
LogOutcome::SilentFail => "静默失败(exit 0 但游戏没起来)".to_string(),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -187,14 +174,4 @@ mod tests {
let task = rx.recv().expect("收到"); let task = rx.recv().expect("收到");
assert!(matches!(task, HostTask::Toast(toast) if toast.text == "hi")); assert!(matches!(task, HostTask::Toast(toast) if toast.text == "hi"));
} }
#[test]
fn launch_summary_names_the_outcome() {
assert_eq!(launch_summary(LogOutcome::Ok, Some(0)), "正常退出");
assert_eq!(
launch_summary(LogOutcome::Crashed, Some(1)),
"崩溃(exit 1)"
);
assert!(launch_summary(LogOutcome::SilentFail, Some(0)).contains("静默"));
}
} }
+55 -102
View File
@@ -17,16 +17,22 @@ use unlucky_core::game::{self, VersionJson};
use unlucky_core::launch::adapter::{ use unlucky_core::launch::adapter::{
self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput, self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput,
}; };
use unlucky_core::launch::request::{JavaSelection, LaunchRequest, MemoryOptions}; use unlucky_core::launch::request::{LaunchRequest, MemoryOptions};
use unlucky_core::launch::time::{format_rfc3339_millis, now}; use unlucky_core::launch::time::{format_rfc3339_millis, now};
use unlucky_core::launch::tree_kill; use unlucky_core::launch::tree_kill;
use unlucky_core::launch::{Launcher, LauncherConfig}; use unlucky_core::launch::{Launcher, LauncherConfig};
use unlucky_core::platform::paths::ProcessEnv;
use crate::dto::{InstanceId, InstanceRuntime, LaunchFinished, LaunchStage, LogOutcome, Progress}; use crate::dto::{
InstanceId, InstanceRuntime, LaunchFailureStage, LaunchFailureView, LaunchFinished,
LaunchStage, LogOutcome, Progress,
};
use super::env::HostEnv; use super::env::HostEnv;
use super::job::{launch_summary, HostTask, JobSender};
mod java;
use super::job::{HostTask, JobSender};
pub use java::pick_java;
/// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。 /// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。
pub const LAUNCHER_NAME: &str = "Unlucky Launcher"; pub const LAUNCHER_NAME: &str = "Unlucky Launcher";
@@ -44,104 +50,38 @@ pub struct LaunchTarget {
pub account: Option<String>, pub account: Option<String>,
} }
/// 选中并用起来的 Java(`summary` 给 UI 一行提示)。
pub struct JavaPick {
/// 交给 adapter 的选择
pub selection: JavaSelection,
/// 人读一行
pub summary: String,
}
/// 选一个能用的 Java:显式路径(实例 → 全局 `java.selected_path`)> 托管 > JAVA_HOME > PATH。
///
/// 显式路径**先探测**(`java -version`):探测失败不报错,按版本要求的基准主版本兜底——
/// 与自动挑的退路同一口径(启动路径不该因为探测不到就拒绝启动)。
pub fn pick_java(env: &HostEnv, version_id: &str, explicit: Option<&std::path::Path>) -> JavaPick {
let store = env.store();
let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id);
if let Some(path) = explicit {
let runtime = unlucky_core::game::java::probe::probe(
path,
unlucky_core::game::java::probe::JavaOrigin::Instance,
);
let major = runtime.major.unwrap_or(required.minimum);
return JavaPick {
selection: JavaSelection::new(runtime.binary.clone(), major, runtime.bits),
summary: format!(
"实例指定 Java {}({})",
runtime.version.clone().unwrap_or_else(|| major.to_string()),
path.display()
),
};
}
let install_root = store
.doc()
.effective_str("java.installRoot")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(PathBuf::from);
let roots = unlucky_core::game::java::location::scan_roots(
env.mode(),
&env.layout,
install_root.as_deref(),
env.platform,
&ProcessEnv,
);
let candidates = unlucky_core::game::java::candidates_in_roots(&roots);
let picked = unlucky_core::game::java::pick(&candidates, &required).cloned();
match picked {
Some(runtime) => {
let major = runtime
.major
.unwrap_or(unlucky_core::game::java::DEFAULT_MAJOR);
let summary = format!(
"Java {}({}{})",
runtime.version.clone().unwrap_or_else(|| major.to_string()),
runtime.origin.category(),
runtime
.vendor
.as_ref()
.map(|vendor| format!(" · {vendor}"))
.unwrap_or_default()
);
JavaPick {
selection: JavaSelection::new(runtime.binary, major, runtime.bits),
summary,
}
}
None => {
let binary = PathBuf::from(unlucky_core::game::java::java_binary_name());
let major = required.minimum;
JavaPick {
selection: JavaSelection::new(binary, major, 64),
summary: format!("PATH 上的 Java(未探测到版本,按 Java {major} 启动)"),
}
}
}
}
/// 装配 [`LaunchRequest`](与 CLI `snapshot::prepare` 同序)。 /// 装配 [`LaunchRequest`](与 CLI `snapshot::prepare` 同序)。
/// ///
/// # Errors /// # Errors
/// ///
/// 版本 JSON 缺失/非法、继承链断裂、隔离派生失败时返回用户可读文案。 /// 任一步失败都返回 [`LaunchFailureView`]:**阶段**(版本 JSON / 继承链 / 实例设置 / 隔离目录 /
/// 账户)+ 那一步的原文。文案不在这一层拼(presenter 不依赖 `unlucky-i18n`)。
pub fn build_request( pub fn build_request(
env: &HostEnv, env: &HostEnv,
target: &LaunchTarget, target: &LaunchTarget,
) -> Result<(LaunchRequest, Vec<String>), String> { ) -> Result<(LaunchRequest, Vec<String>), LaunchFailureView> {
let version_id = target.version_id.clone(); let version_id = target.version_id.clone();
let manifest_path = env.game.version_json(&version_id); let manifest_path = env.game.version_json(&version_id);
let text = std::fs::read_to_string(&manifest_path) let text = std::fs::read_to_string(&manifest_path).map_err(|error| {
.map_err(|error| format!("读版本 JSON 失败 {}:{error}", manifest_path.display()))?; LaunchFailureView::new(
let manifest = LaunchFailureStage::VersionManifest,
VersionJson::from_json_str(&text).map_err(|error| format!("版本 JSON 非法:{error}"))?; format!("{}:{error}", manifest_path.display()),
)
})?;
let manifest = VersionJson::from_json_str(&text).map_err(|error| {
LaunchFailureView::new(
LaunchFailureStage::VersionManifest,
format!("{}:{error}", manifest_path.display()),
)
})?;
let source = |id: &str| -> Option<VersionJson> { let source = |id: &str| -> Option<VersionJson> {
std::fs::read_to_string(env.game.version_json(id)) std::fs::read_to_string(env.game.version_json(id))
.ok() .ok()
.and_then(|text| VersionJson::from_json_str(&text).ok()) .and_then(|text| VersionJson::from_json_str(&text).ok())
}; };
let resolved = let resolved = game::resolve(&source, &manifest).map_err(|error| {
game::resolve(&source, &manifest).map_err(|error| format!("解析继承链失败:{error}"))?; LaunchFailureView::new(LaunchFailureStage::Inheritance, error.to_string())
})?;
let environment = Environment::host(); let environment = Environment::host();
let expanded = resolved let expanded = resolved
@@ -252,12 +192,15 @@ pub fn build_request(
} }
/// 派生运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。 /// 派生运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result<PathBuf, String> { fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result<PathBuf, LaunchFailureView> {
let global = super::game::global_mode(env); let global = super::game::global_mode(env);
let config = read_instance_config(&env.layout, &target.id) let config = read_instance_config(&env.layout, &target.id).map_err(|error| {
.map_err(|error| format!("读实例设置失败:{error}"))?; LaunchFailureView::new(LaunchFailureStage::InstanceConfig, error.to_string())
})?;
let derivation = derive_game_dir(&env.game, &target.version_id, global, config.as_ref(), None) let derivation = derive_game_dir(&env.game, &target.version_id, global, config.as_ref(), None)
.map_err(|error| format!("隔离目录派生失败:{error}"))?; .map_err(|error| {
LaunchFailureView::new(LaunchFailureStage::Isolation, error.to_string())
})?;
if let Some(mut config) = config { if let Some(mut config) = config {
let at = format_rfc3339_millis(now()); let at = format_rfc3339_millis(now());
if observe_mode(&mut config, &env.game, &at, derivation.isolation).unwrap_or(false) { if observe_mode(&mut config, &env.game, &at, derivation.isolation).unwrap_or(false) {
@@ -282,12 +225,14 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
}); });
let (request, warnings) = match build_request(&env, &target) { let (request, warnings) = match build_request(&env, &target) {
Ok(ok) => ok, Ok(ok) => ok,
Err(reason) => { Err(failure) => {
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished { let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id, id,
session: String::new(), session: String::new(),
outcome: LogOutcome::Crashed, outcome: LogOutcome::Crashed,
summary: format!("启动失败:{reason}"), exit_code: None,
uptime_ms: 0,
failure: Some(failure),
}))); })));
return; return;
} }
@@ -309,7 +254,12 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
id, id,
session: String::new(), session: String::new(),
outcome: LogOutcome::Crashed, outcome: LogOutcome::Crashed,
summary: format!("启动失败:{error}"), exit_code: None,
uptime_ms: 0,
failure: Some(LaunchFailureView::new(
LaunchFailureStage::Spawn,
error.to_string(),
)),
}))); })));
return; return;
} }
@@ -332,12 +282,10 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
id, id,
session: session_id, session: session_id,
outcome, outcome,
summary: format!( exit_code: Some(handle.exit.code),
"{}({},{:.1}s)", uptime_ms: handle.uptime_ms,
launch_summary(outcome, Some(handle.exit.code)), // 跑起来再崩不算「装配失败」:结局由 `outcome` 表达,这里留 `None`。
handle.exit.exit_type.as_str(), failure: None,
handle.uptime_ms as f64 / 1000.0
),
}))); })));
} }
Err(error) => { Err(error) => {
@@ -345,7 +293,12 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
id, id,
session: session_id, session: session_id,
outcome: LogOutcome::SilentFail, outcome: LogOutcome::SilentFail,
summary: format!("启动收尾失败:{error}"), exit_code: None,
uptime_ms: 0,
failure: Some(LaunchFailureView::new(
LaunchFailureStage::Spawn,
error.to_string(),
)),
}))); })));
} }
} }
+88
View File
@@ -0,0 +1,88 @@
//! 「用哪个 Java」:显式路径(实例 → 全局)> 托管 > JAVA_HOME > PATH(M161 从 `host/launch.rs` 拆出)。
//!
//! 拆出的理由与其它 400 行拆分一致:`host/launch.rs` 到线了,而「挑 Java」本来就是独立一段。
//! 口径没变(见原文件与 `core::game::java` 的文档):显式路径**先探测**,探测失败按版本要求的
//! 基准主版本兜底——启动路径不该因为探测不到就拒绝启动。
use std::path::PathBuf;
use unlucky_core::launch::request::JavaSelection;
use unlucky_core::platform::paths::ProcessEnv;
use super::super::env::HostEnv;
/// 选中并用起来的 Java(`summary` 给 UI 一行提示)。
pub struct JavaPick {
/// 交给 adapter 的选择
pub selection: JavaSelection,
/// 人读一行
pub summary: String,
}
/// 选一个能用的 Java:显式路径(实例 → 全局 `java.selected_path`)> 托管 > JAVA_HOME > PATH。
///
/// 显式路径**先探测**(`java -version`):探测失败不报错,按版本要求的基准主版本兜底——
/// 与自动挑的退路同一口径(启动路径不该因为探测不到就拒绝启动)。
pub fn pick_java(env: &HostEnv, version_id: &str, explicit: Option<&std::path::Path>) -> JavaPick {
let store = env.store();
let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id);
if let Some(path) = explicit {
let runtime = unlucky_core::game::java::probe::probe(
path,
unlucky_core::game::java::probe::JavaOrigin::Instance,
);
let major = runtime.major.unwrap_or(required.minimum);
return JavaPick {
selection: JavaSelection::new(runtime.binary.clone(), major, runtime.bits),
summary: format!(
"实例指定 Java {}({})",
runtime.version.clone().unwrap_or_else(|| major.to_string()),
path.display()
),
};
}
let install_root = store
.doc()
.effective_str("java.installRoot")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(PathBuf::from);
let roots = unlucky_core::game::java::location::scan_roots(
env.mode(),
&env.layout,
install_root.as_deref(),
env.platform,
&ProcessEnv,
);
let candidates = unlucky_core::game::java::candidates_in_roots(&roots);
let picked = unlucky_core::game::java::pick(&candidates, &required).cloned();
match picked {
Some(runtime) => {
let major = runtime
.major
.unwrap_or(unlucky_core::game::java::DEFAULT_MAJOR);
let summary = format!(
"Java {}({}{})",
runtime.version.clone().unwrap_or_else(|| major.to_string()),
runtime.origin.category(),
runtime
.vendor
.as_ref()
.map(|vendor| format!(" · {vendor}"))
.unwrap_or_default()
);
JavaPick {
selection: JavaSelection::new(runtime.binary, major, runtime.bits),
summary,
}
}
None => {
let binary = PathBuf::from(unlucky_core::game::java::java_binary_name());
let major = required.minimum;
JavaPick {
selection: JavaSelection::new(binary, major, 64),
summary: format!("PATH 上的 Java(未探测到版本,按 Java {major} 启动)"),
}
}
}
}
@@ -145,7 +145,9 @@ fn launch_started_registers_and_launch_end_clears() {
id: id.clone(), id: id.clone(),
session: "20261031T120000-abcd12".to_string(), session: "20261031T120000-abcd12".to_string(),
outcome: LogOutcome::Ok, outcome: LogOutcome::Ok,
summary: "正常退出".to_string(), exit_code: Some(0),
uptime_ms: 12_500,
failure: None,
}))); })));
assert!(host.launches.is_empty(), "收尾后启动表要清空"); assert!(host.launches.is_empty(), "收尾后启动表要清空");
assert!(!host.failed.contains_key(&id)); assert!(!host.failed.contains_key(&id));
@@ -197,7 +199,9 @@ fn refresh_keeps_pending_as_busy_and_failed_as_failed() {
id: id.clone(), id: id.clone(),
session: "20261031T120000-dead01".to_string(), session: "20261031T120000-dead01".to_string(),
outcome: LogOutcome::Crashed, outcome: LogOutcome::Crashed,
summary: "崩溃(exit 1)".to_string(), exit_code: Some(1),
uptime_ms: 3_000,
failure: None,
}))); })));
assert!(!host.pending.contains(&id), "LaunchEnd 必须清 pending"); assert!(!host.pending.contains(&id), "LaunchEnd 必须清 pending");
assert!(!host.in_flight(&id), "收尾后该实例要能被重新启动"); assert!(!host.in_flight(&id), "收尾后该实例要能被重新启动");
@@ -212,7 +216,8 @@ fn refresh_keeps_pending_as_busy_and_failed_as_failed() {
assert_eq!( assert_eq!(
failed.runtime, failed.runtime,
InstanceRuntime::Failed { InstanceRuntime::Failed {
reason: "崩溃(exit 1)".to_string() stage: crate::dto::LaunchFailureStage::Spawn,
detail: "exit Some(1)".to_string()
}, },
"失败原因要经得住一次刷新(否则磁贴的「重试」外观会被 Idle 盖掉)" "失败原因要经得住一次刷新(否则磁贴的「重试」外观会被 Idle 盖掉)"
); );
@@ -229,7 +234,12 @@ fn launch_end_before_launch_started_still_releases_the_instance() {
id: id.clone(), id: id.clone(),
session: String::new(), session: String::new(),
outcome: LogOutcome::Crashed, outcome: LogOutcome::Crashed,
summary: "启动失败:找不到 Java".to_string(), exit_code: None,
uptime_ms: 0,
failure: Some(crate::dto::LaunchFailureView::new(
crate::dto::LaunchFailureStage::Identity,
"找不到账户 `steve`",
)),
}))); })));
assert!( assert!(
@@ -248,7 +258,8 @@ fn launch_end_before_launch_started_still_releases_the_instance() {
assert_eq!( assert_eq!(
failed.runtime, failed.runtime,
InstanceRuntime::Failed { InstanceRuntime::Failed {
reason: "启动失败:找不到 Java".to_string() stage: crate::dto::LaunchFailureStage::Identity,
detail: "找不到账户 `steve`".to_string()
}, },
"早失败也要在磁贴上留下「失败/重试」,不能被 Idle 盖掉" "早失败也要在磁贴上留下「失败/重试」,不能被 Idle 盖掉"
); );
+6 -4
View File
@@ -41,7 +41,7 @@ use std::time::Duration;
use crate::dto::{ use crate::dto::{
AccountView, AppCommand, AppEvent, Backend, CoreEvent, DiagnosisReport, InstallProgressView, AccountView, AppCommand, AppEvent, Backend, CoreEvent, DiagnosisReport, InstallProgressView,
InstanceRuntime, JavaChainStep, NoticeView, SkinImage, InstanceRuntime, JavaChainStep, LaunchFailureView, NoticeView, SkinImage,
}; };
use crate::mock::MockBackend; use crate::mock::MockBackend;
use crate::toast::{Toast, ToastKind}; use crate::toast::{Toast, ToastKind};
@@ -92,7 +92,8 @@ pub struct CoreHost {
/// 已受理但后台线程还没回报 `LaunchStarted` 的实例(防「启动中重复点启动」起第二个进程) /// 已受理但后台线程还没回报 `LaunchStarted` 的实例(防「启动中重复点启动」起第二个进程)
pending: BTreeSet<String>, pending: BTreeSet<String>,
/// 上次启动失败的实例与原因(刷新快照时保住磁贴的「失败/重试」外观) /// 上次启动失败的实例与原因(刷新快照时保住磁贴的「失败/重试」外观)
failed: BTreeMap<String, String>, /// 上次启动失败(阶段 + 原文;磁贴的「失败/重试」外观靠它重建)
failed: BTreeMap<String, LaunchFailureView>,
/// 实时日志尾随器 /// 实时日志尾随器
tail: logs::Tail, tail: logs::Tail,
/// AI 对话的临时后端(agent GUI host 落地前的占位) /// AI 对话的临时后端(agent GUI host 落地前的占位)
@@ -228,9 +229,10 @@ impl CoreHost {
stage: crate::dto::LaunchStage::Launch, stage: crate::dto::LaunchStage::Launch,
progress: crate::dto::Progress { done: 0, total: 1 }, progress: crate::dto::Progress { done: 0, total: 1 },
}; };
} else if let Some(reason) = self.failed.get(&instance.id) { } else if let Some(failure) = self.failed.get(&instance.id) {
instance.runtime = InstanceRuntime::Failed { instance.runtime = InstanceRuntime::Failed {
reason: reason.clone(), stage: failure.stage,
detail: failure.detail.clone(),
}; };
} }
} }
+41 -51
View File
@@ -5,10 +5,10 @@
//! ```text //! ```text
//! core::update::Job ← 检查/应用的做法(版本比较、渠道准入、双检、原子替换全在 core) //! core::update::Job ← 检查/应用的做法(版本比较、渠道准入、双检、原子替换全在 core)
//! ▲ //! ▲
//! │ 本模块只做三件事 //! │ 本模块只做四件事
//! Factory ← 用哪个源、暂存放哪、替换谁(生产:GitHub + 本进程 exe) //! Factory ← 用哪个源、暂存放哪、替换谁(生产:GitHub + 本进程 exe)
//! outcome_of ← core 的结论 → DTO(GUI 与 CLI 共用同一条判据) //! outcome_of ← core 的结论 → DTO(GUI 与 CLI 共用同一条判据)
//! Prefs ← 更新域自己的开关(启动时静默检查) //! Paths / Migration ← 两个开关的落点,以及旧 `cache/update/prefs.json` 的迁移(M161)
//! ``` //! ```
//! //!
//! 「GUI 与 CLI 行为一致」由结构保证:两边都只构造 `core::update::Job` 并调用 `check` / `apply`, //! 「GUI 与 CLI 行为一致」由结构保证:两边都只构造 `core::update::Job` 并调用 `check` / `apply`,
@@ -16,83 +16,73 @@
//! //!
//! # 静默检查的边界 //! # 静默检查的边界
//! //!
//! 启动时的检查能被关掉([`Prefs::auto_check`]):关掉之后 host 的装配路径**根本不派发** //! 启动时的检查能被关掉(`update.auto_check`):关掉之后 host 的装配路径**根本不派发**
//! (判据在 `CoreHost::refresh_all` 的那一行 `if self.update.should_check_on_start()`)。 //! (判据在 `CoreHost::refresh_all` 的那一行 `if self.update.should_check_on_start()`)。
//! //!
//! # 偏好文件为什么不在 `launcher.json` 里 //! # 两个开关住在主配置里(M161)
//! //!
//! `launcher.json` 的每个键都归 `core::setting::keys` 的 schema 管,而本 mission 的 scope 不含 //! `update.auto_check` / `update.channel` 是 `crates/core/src/setting/keys.rs` 的 schema 键,
//! `crates/core/src/setting/**`。所以这一条先落在更新域自己的文件 //! 落在 `<数据根>/config/launcher.json`——与「所有配置走 `core::setting`」的约定一致,
//! `<数据根>/cache/update/prefs.json`(与 `cache/region.json` 同族:域内状态,坏了就回默认)。 //! 于是 `ull config get update.auto_check` 读到的是**同一个值**。
//! **加 `update.auto_check` schema 键之后应当迁过去**(已作为 finding 上报)。 //!
//! M159 曾把这两项临时放进更新域自己的文件 `<数据根>/cache/update/prefs.json`(当时
//! `crates/core/src/setting/**` 不在那条 mission 的 scope),代价是「GUI 里关掉、CLI 仍报 true」。
//! 存量值的迁移在 [`unlucky_core::setting::update_prefs`]:幂等(源指纹闸门)、不丢 `false`、
//! 新值胜出、旧文件改名为 `prefs.json.migrated` 后不再有任何读路径。`Runtime::with_factory`
//! 在建会话时跑一次并记住结局([`Runtime::migration`],`null` = 没有遗留文件)。
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use serde::{Deserialize, Serialize}; use crate::dto::{UpdateCandidateView, UpdateFailureView, UpdateOutcome};
use unlucky_core::download::DownloadEngine; use unlucky_core::download::DownloadEngine;
use unlucky_core::launch::time::{format_rfc3339_millis, now}; use unlucky_core::launch::time::{format_rfc3339_millis, now};
use unlucky_core::setting::update_prefs;
use unlucky_core::update::{ use unlucky_core::update::{
default_source, staging_dir, Applied, Channel, CheckOutcome, DirSource, Job, Target, default_source, staging_dir, Applied, CheckOutcome, DirSource, Job, Target, UpdateError,
UpdateError, UpdateSource, Version, UpdateSource, Version,
}; };
use crate::dto::{UpdateCandidateView, UpdateFailureView, UpdateOutcome};
use super::engine; use super::engine;
use super::env::HostEnv; use super::env::HostEnv;
use super::logs::display_time; use super::logs::display_time;
/// 渠道(D50:先只发 stable)。
pub const CHANNEL: Channel = Channel::Stable;
/// 本进程版本。整个 workspace 同版本(根 `Cargo.toml` 的 `[workspace.package]`)。 /// 本进程版本。整个 workspace 同版本(根 `Cargo.toml` 的 `[workspace.package]`)。
#[must_use] #[must_use]
pub fn current_version() -> &'static str { pub fn current_version() -> &'static str {
env!("CARGO_PKG_VERSION") env!("CARGO_PKG_VERSION")
} }
/// 更新域偏好(现在只有一项)。 /// 更新域的三个落点(都由数据根派生,core 不猜布局)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone)]
pub struct Prefs { pub struct Paths {
/// 启动时静默检查。缺省开:检查本身不打扰用户,发现新版本才有提示条。 /// `<数据根>/config/launcher.json`(两个开关的真相源)
#[serde(default = "default_auto_check")] pub launcher: PathBuf,
pub auto_check: bool, /// `<数据根>/cache/update/prefs.json`(M159 的旧文件;迁移后改名 `.migrated`)
pub legacy: PathBuf,
/// `<数据根>/config/update-prefs-migration.json`(迁移收据)
pub receipt: PathBuf,
} }
fn default_auto_check() -> bool { impl Paths {
true /// 按数据根装配。
}
impl Default for Prefs {
fn default() -> Self {
Self { auto_check: true }
}
}
impl Prefs {
/// 读偏好:文件不存在 / 坏 JSON 一律回默认(偏好坏了不该让启动器起不来)。
#[must_use] #[must_use]
pub fn load(path: &Path) -> Self { pub fn for_env(env: &HostEnv) -> Self {
std::fs::read_to_string(path) Self {
.ok() launcher: env.launcher_config(),
.and_then(|text| serde_json::from_str(&text).ok()) legacy: env.layout.cache().join("update").join("prefs.json"),
.unwrap_or_default() receipt: env.layout.config().join(update_prefs::RECEIPT_FILE),
}
} }
/// 写偏好。 /// 迁移要的三个路径。
/// #[must_use]
/// # Errors pub fn targets(&self) -> update_prefs::Targets<'_> {
/// update_prefs::Targets {
/// 目录建不出来或写盘失败时返回人读文案。 launcher: &self.launcher,
pub fn save(&self, path: &Path) -> Result<(), String> { legacy: &self.legacy,
if let Some(parent) = path.parent() { receipt: &self.receipt,
std::fs::create_dir_all(parent)
.map_err(|error| format!("建更新偏好目录失败:{error}"))?;
} }
let text = serde_json::to_string_pretty(self)
.map_err(|error| format!("序列化更新偏好失败:{error}"))?;
std::fs::write(path, text).map_err(|error| format!("写更新偏好失败:{error}"))
} }
} }
@@ -20,7 +20,7 @@ fn sha256_mismatch_refuses_the_apply_and_leaves_no_temp_file() {
std::fs::create_dir_all(dest.parent().expect("父目录")).expect("bin"); std::fs::create_dir_all(dest.parent().expect("父目录")).expect("bin");
std::fs::write(&dest, b"old-binary-v1").expect("旧版本"); std::fs::write(&dest, b"old-binary-v1").expect("旧版本");
let report = Report::applied(job(&tmp).apply(&version("0.2.2"), CHANNEL)); let report = Report::applied(job(&tmp).apply(&version("0.2.2"), Channel::Stable));
match report { match report {
Report::Applied(ApplyReport::Failed(failure)) => { Report::Applied(ApplyReport::Failed(failure)) => {
assert_eq!(failure.kind, "digest_mismatch", "失败分类要能区分摘要不符"); assert_eq!(failure.kind, "digest_mismatch", "失败分类要能区分摘要不符");
@@ -67,7 +67,7 @@ fn a_successful_apply_leaves_the_new_file_and_a_backup() {
std::fs::write(&dest, b"old-binary-v1").expect("旧版本"); std::fs::write(&dest, b"old-binary-v1").expect("旧版本");
let applied = job(&tmp) let applied = job(&tmp)
.apply(&version("0.2.2"), CHANNEL) .apply(&version("0.2.2"), Channel::Stable)
.expect("应用成功"); .expect("应用成功");
let Applied::Installed { receipt, version } = applied else { let Applied::Installed { receipt, version } = applied else {
panic!("应当装上"); panic!("应当装上");
@@ -81,21 +81,32 @@ fn dismissing_without_a_candidate_does_nothing() {
} }
/// 关掉「启动时检查」之后:状态上是 no-op、启动路径上根本不派发。 /// 关掉「启动时检查」之后:状态上是 no-op、启动路径上根本不派发。
///
/// 开关的落点是**主配置**(`update.auto_check`),这里顺带把 M161 的迁移走一遍:
/// 旧 `cache/update/prefs.json` 里的 `false` 必须是新会话看到的 `false`(不是默认的 `true`)。
#[test] #[test]
fn auto_check_off_means_no_request_on_start() { fn auto_check_off_means_no_request_on_start() {
let tmp = TempDir::new("autocheck"); let tmp = TempDir::new("autocheck");
let env = HostEnv::at_root(tmp.join("root")); let env = HostEnv::at_root(tmp.join("root"));
let prefs_path = env.layout.cache().join("update").join("prefs.json"); let prefs_path = env.layout.cache().join("update").join("prefs.json");
Prefs { auto_check: false } std::fs::create_dir_all(prefs_path.parent().expect("父目录")).expect("建 cache");
.save(&prefs_path) std::fs::write(&prefs_path, r#"{"auto_check": false}"#).expect("写旧偏好");
.expect("写偏好");
let runtime = Runtime::with_factory(&env, factory(&tmp)); let runtime = Runtime::with_factory(&env, factory(&tmp));
assert!(!runtime.view().auto_check, "开关要读进快照"); assert!(!runtime.view().auto_check, "开关要读进快照");
assert!(!runtime.auto_check(), "关掉的值一路迁进主配置");
assert!( assert!(
!runtime.should_check_on_start(), !runtime.should_check_on_start(),
"关掉开关后启动不许派发检查" "关掉开关后启动不许派发检查"
); );
// 迁移确实发生了:旧文件退役 + 收据落盘(收据可查)。
assert!(!prefs_path.exists(), "旧文件必须退役");
let receipt = runtime
.migration()
.receipt()
.expect("收据路径")
.to_path_buf();
assert!(receipt.exists(), "收据要落盘:{}", receipt.display());
// 结构性判据:`refresh_all` 里那次静默检查必须挂在 `should_check_on_start` 上, // 结构性判据:`refresh_all` 里那次静默检查必须挂在 `should_check_on_start` 上,
// 否则单测绿着而启动仍然联网(这条读源码,理由同 `instances/tests.rs` 的源码断言)。 // 否则单测绿着而启动仍然联网(这条读源码,理由同 `instances/tests.rs` 的源码断言)。
@@ -106,18 +117,30 @@ fn auto_check_off_means_no_request_on_start() {
); );
} }
/// 偏好能落盘、能读回(换一份 Runtime 仍记得上次的选择)。 /// 开关能写进主配置、能读回(换一份 Runtime 仍记得上次的选择)。
#[test] #[test]
fn the_auto_check_preference_survives_a_restart() { fn the_auto_check_preference_survives_a_restart() {
let tmp = TempDir::new("prefs"); let tmp = TempDir::new("prefs");
let env = HostEnv::at_root(tmp.join("root")); let env = HostEnv::at_root(tmp.join("root"));
let mut runtime = Runtime::with_factory(&env, factory(&tmp)); let mut runtime = Runtime::with_factory(&env, factory(&tmp));
assert!(runtime.prefs().auto_check, "缺省开"); assert!(runtime.auto_check(), "缺省开");
runtime.set_auto_check(false).expect("落盘"); runtime.set_auto_check(false).expect("落盘");
let restarted = Runtime::with_factory(&env, factory(&tmp)); let restarted = Runtime::with_factory(&env, factory(&tmp));
assert!(!restarted.prefs().auto_check, "重启后仍记得关掉"); assert!(!restarted.auto_check(), "重启后仍记得关掉");
assert!(!restarted.view().auto_check); assert!(!restarted.view().auto_check);
// 落点是主配置的 schema 键(CLI 读到的就是这一份),不是更新域自己的文件。
let text = std::fs::read_to_string(env.launcher_config()).expect("读主配置");
assert!(text.contains("\"auto_check\": false"), "{text}");
assert!(
!env.layout
.cache()
.join("update")
.join("prefs.json")
.exists(),
"新写入不许再落到旧文件"
);
} }
/// GUI 与 CLI 共用**同一个** `Job::apply`(mission:不许两套逻辑)。 /// GUI 与 CLI 共用**同一个** `Job::apply`(mission:不许两套逻辑)。
@@ -145,7 +168,33 @@ fn the_factory_names_its_source_and_target() {
let factory = factory(&tmp); let factory = factory(&tmp);
assert_eq!(factory.dir(), Some(tmp.join("dist").as_path())); assert_eq!(factory.dir(), Some(tmp.join("dist").as_path()));
assert_eq!(factory.dest(), tmp.join("bin/unlucky-launcher.exe")); assert_eq!(factory.dest(), tmp.join("bin/unlucky-launcher.exe"));
assert_eq!(CHANNEL.as_str(), "stable", "D50:先只发 stable"); }
/// 渠道来自主配置的 `update.channel`(M161:不再是编译期常量)。
///
/// 抓得住:把渠道写回硬编码常量而键里是别的值;也抓得住「认不出的渠道 panic 而不是回出厂值」。
#[test]
fn the_channel_is_read_from_the_schema_key() {
let tmp = TempDir::new("channel-key");
let env = HostEnv::at_root(tmp.join("root"));
let runtime = Runtime::with_factory(&env, factory(&tmp));
assert_eq!(
runtime.channel().as_str(),
unlucky_core::setting::update_prefs::DEFAULT_CHANNEL,
"缺键 ⇒ 出厂渠道"
);
assert_eq!(runtime.view().channel, "stable", "快照里的渠道同源");
// 手改成一个 schema 不允许的值(越过 `config set` 的校验)⇒ 回出厂渠道,不许 panic。
std::fs::create_dir_all(env.layout.config()).expect("建 config");
std::fs::write(
env.launcher_config(),
r#"{"$schema":"https://schemas.unlucky-launcher.dev/ull/launcher/1.6.0","schemaVersion":1,
"update":{"channel":"hand-edited"}}"#,
)
.expect("写配置");
let runtime = Runtime::with_factory(&env, factory(&tmp));
assert_eq!(runtime.channel().as_str(), "stable", "认不出的渠道回出厂值");
} }
/// CLI 与 GUI 用的是**同一个** core Job:源码锚点,防止有人另起一条流程。 /// CLI 与 GUI 用的是**同一个** core Job:源码锚点,防止有人另起一条流程。
+84 -24
View File
@@ -1,32 +1,41 @@
//! 更新状态的**会话层**:一份 `UpdateView`、更新域偏好、以及把 `CoreHost` 接上后台线程。 //! 更新状态的**会话层**:一份 `UpdateView`、主配置里的两个开关、以及把 `CoreHost` 接上后台线程。
//! //!
//! 与 [`super::update`] 的分工:那边是「一次检查/应用的纯做法」(选源、调 core、映射 DTO), //! 与 [`super::update`] 的分工:那边是「一次检查/应用的纯做法」(选源、调 core、映射 DTO),
//! 这边是「谁来记着它、谁来派发」。拆开是因为两边都各有一批判据要写清(静默检查的开关、 //! 这边是「谁来记着它、谁来派发」。拆开是因为两边都各有一批判据要写清(静默检查的开关、
//! 拒绝后不再打扰、应用失败不许丢掉候选),塞在一个文件里会越过 400 行硬线。 //! 拒绝后不再打扰、应用失败不许丢掉候选),塞在一个文件里会越过 400 行硬线。
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use unlucky_core::update::Version; use serde_json::json;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use unlucky_core::setting::update_prefs::{self, Migration};
use unlucky_core::update::{Channel, Version};
use crate::dto::{CoreEvent, ReadyUpdate, UpdateOutcome, UpdateView}; use crate::dto::{CoreEvent, ReadyUpdate, UpdateOutcome, UpdateView};
use super::env::HostEnv; use super::env::HostEnv;
use super::update::{current_version, outcome_of, ApplyReport, Factory, Prefs, Report, CHANNEL}; use super::update::{current_version, outcome_of, ApplyReport, Factory, Paths, Report};
use super::CoreHost; use super::CoreHost;
/// 会话级更新状态(提示条 / 设置页共用一份)。 /// 会话级更新状态(提示条 / 设置页共用一份)。
#[derive(Debug)] #[derive(Debug)]
pub struct Runtime { pub struct Runtime {
view: UpdateView, view: UpdateView,
prefs: Prefs, /// `update.auto_check` 的生效值(缓存:`should_check_on_start` 在 `refresh_all` 里被调)。
auto_check: bool,
factory: Factory, factory: Factory,
prefs_path: PathBuf, /// `config/launcher.json`(两个开关的真相源;写它就走 `core::setting`)。
launcher: PathBuf,
/// 建会话时那次遗留文件迁移的结局(`Nothing` = 没有旧文件)。
migration: Migration,
/// 有一条检查/应用正在后台跑(防重复派发;进程内独占)。 /// 有一条检查/应用正在后台跑(防重复派发;进程内独占)。
inflight: bool, inflight: bool,
} }
impl Runtime { impl Runtime {
/// 按数据根装配。 /// 按数据根装配(顺带把 M159 的 `cache/update/prefs.json` 迁进主配置)。
#[must_use] #[must_use]
pub fn for_env(env: &HostEnv) -> Self { pub fn for_env(env: &HostEnv) -> Self {
Self::with_factory(env, Factory::for_env(env)) Self::with_factory(env, Factory::for_env(env))
@@ -35,13 +44,18 @@ impl Runtime {
/// 用给定装配(单测:临时目录 + 本地目录源)。 /// 用给定装配(单测:临时目录 + 本地目录源)。
#[must_use] #[must_use]
pub fn with_factory(env: &HostEnv, factory: Factory) -> Self { pub fn with_factory(env: &HostEnv, factory: Factory) -> Self {
let prefs_path = env.layout.cache().join("update").join("prefs.json"); let paths = Paths::for_env(env);
let prefs = Prefs::load(&prefs_path); // 迁移在建会话时跑一次:**幂等**(源指纹闸门),没有旧文件时是纯 stat 一次。
let migration = update_prefs::migrate(paths.targets(), now_ms());
let store = SettingStore::load(&paths.launcher, launcher_schema());
let auto_check = update_prefs::auto_check(store.doc());
let channel = update_prefs::channel(store.doc()).to_string();
Self { Self {
view: UpdateView::new(current_version(), CHANNEL.as_str(), prefs.auto_check), view: UpdateView::new(current_version(), &channel, auto_check),
prefs, auto_check,
factory, factory,
prefs_path, launcher: paths.launcher,
migration,
inflight: false, inflight: false,
} }
} }
@@ -58,10 +72,16 @@ impl Runtime {
self.view.clone() self.view.clone()
} }
/// 偏好。 /// 「启动时静默检查」的生效值(来自 `launcher.json` 的 `update.auto_check`)。
#[must_use] #[must_use]
pub const fn prefs(&self) -> Prefs { pub const fn auto_check(&self) -> bool {
self.prefs self.auto_check
}
/// 建会话时那次迁移的结局(`Nothing` = 没有遗留文件;收据路径见 [`Migration::receipt`])。
#[must_use]
pub const fn migration(&self) -> &Migration {
&self.migration
} }
/// 后台有没有一条在跑。 /// 后台有没有一条在跑。
@@ -79,18 +99,37 @@ impl Runtime {
/// 启动时要不要静默检查(开关关掉 ⇒ 启动**一次网络都不发**)。 /// 启动时要不要静默检查(开关关掉 ⇒ 启动**一次网络都不发**)。
#[must_use] #[must_use]
pub fn should_check_on_start(&self) -> bool { pub fn should_check_on_start(&self) -> bool {
self.prefs.auto_check && !self.inflight self.auto_check && !self.inflight
} }
/// 开关「启动时静默检查」并落盘。 /// 检查/应用用的渠道:**每次都从主配置读**(用户在 CLI 里改了 `update.channel` 之后,
/// 下一次检查就按新渠道走,不用重启)。
///
/// 认不出的值(手改文件 / 未来的渠道)回出厂渠道:schema 已经把写入侧卡在允许值上,
/// 读侧只兜底、不报错。
#[must_use]
pub fn channel(&self) -> Channel {
let store = SettingStore::load(&self.launcher, launcher_schema());
Channel::parse(update_prefs::channel(store.doc())).unwrap_or(Channel::Stable)
}
/// 开关「启动时静默检查」并写进主配置(`update.auto_check`)。
/// ///
/// # Errors /// # Errors
/// ///
/// 落盘失败返回人读文案(本会话的取值仍然生效——不因为写不进去就把开关弹回去)。 /// 落盘失败返回人读文案(本会话的取值仍然生效——不因为写不进去就把开关弹回去)。
pub fn set_auto_check(&mut self, on: bool) -> Result<(), String> { pub fn set_auto_check(&mut self, on: bool) -> Result<(), String> {
self.prefs.auto_check = on; self.auto_check = on;
self.view.auto_check = on; self.view.auto_check = on;
self.prefs.save(&self.prefs_path) let mut store = SettingStore::load(&self.launcher, launcher_schema());
store
.set(update_prefs::KEY_AUTO_CHECK, json!(on))
.map_err(|error| format!("写 update.auto_check 被拒:{error}"))?;
match store.save() {
Ok(saved) if saved.wrote() || !store.is_dirty() => Ok(()),
Ok(_) => Err("update.auto_check 没有落盘(配置只读或文件不可信)".to_string()),
Err(error) => Err(format!("写 {} 失败:{error}", self.launcher.display())),
}
} }
/// 开始一次检查:置「正在查」并返回要交给后台线程的 [`unlucky_core::update::Job`] /// 开始一次检查:置「正在查」并返回要交给后台线程的 [`unlucky_core::update::Job`]
@@ -105,6 +144,7 @@ impl Runtime {
self.view.outcome = UpdateOutcome::Checking; self.view.outcome = UpdateOutcome::Checking;
self.view.silent = !manual; self.view.silent = !manual;
self.view.applying = false; self.view.applying = false;
self.view.channel = self.channel().as_str().to_string();
if manual { if manual {
// 手动检查 = 用户要看真结果,把「忽略这个版本」清掉。 // 手动检查 = 用户要看真结果,把「忽略这个版本」清掉。
self.view.skipped = None; self.view.skipped = None;
@@ -190,9 +230,10 @@ impl CoreHost {
let Some(job) = self.update.begin_check(manual) else { let Some(job) = self.update.begin_check(manual) else {
return; return;
}; };
let channel = self.update.channel();
// 先把「正在查」发出去:设置页的按钮据此转圈,而不是看起来没反应。 // 先把「正在查」发出去:设置页的按钮据此转圈,而不是看起来没反应。
self.publish_update(); self.publish_update();
spawn_check(job, current, self.tx.clone()); spawn_check(job, current, channel, self.tx.clone());
} }
/// 应用更新(下载 → 长度/SHA256 双检 → 替换;全部在后台线程)。 /// 应用更新(下载 → 长度/SHA256 双检 → 替换;全部在后台线程)。
@@ -203,27 +244,46 @@ impl CoreHost {
let Some(job) = self.update.begin_apply() else { let Some(job) = self.update.begin_apply() else {
return; return;
}; };
let channel = self.update.channel();
self.publish_update(); self.publish_update();
spawn_apply(job, current, self.tx.clone()); spawn_apply(job, current, channel, self.tx.clone());
} }
} }
/// 起一条检查线程(结果经 [`super::HostTask::Update`] 回来)。 /// 起一条检查线程(结果经 [`super::HostTask::Update`] 回来)。
pub fn spawn_check(job: unlucky_core::update::Job, current: Version, tx: super::JobSender) { pub fn spawn_check(
job: unlucky_core::update::Job,
current: Version,
channel: Channel,
tx: super::JobSender,
) {
super::job::spawn("update-check", move || { super::job::spawn("update-check", move || {
let report = Report::Checked(outcome_of(job.check(&current, CHANNEL))); let report = Report::Checked(outcome_of(job.check(&current, channel)));
let _ = tx.send(super::HostTask::Update(Box::new(report))); let _ = tx.send(super::HostTask::Update(Box::new(report)));
}); });
} }
/// 起一条应用线程(下载 + 双检 + 替换都在里面)。 /// 起一条应用线程(下载 + 双检 + 替换都在里面)。
pub fn spawn_apply(job: unlucky_core::update::Job, current: Version, tx: super::JobSender) { pub fn spawn_apply(
job: unlucky_core::update::Job,
current: Version,
channel: Channel,
tx: super::JobSender,
) {
super::job::spawn("update-apply", move || { super::job::spawn("update-apply", move || {
let report = Report::applied(job.apply(&current, CHANNEL)); let report = Report::applied(job.apply(&current, channel));
let _ = tx.send(super::HostTask::Update(Box::new(report))); let _ = tx.send(super::HostTask::Update(Box::new(report)));
}); });
} }
/// 当前时刻(毫秒 since UNIX_EPOCH;收据用)。
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX))
.unwrap_or(0)
}
#[cfg(test)] #[cfg(test)]
#[path = "update_tests.rs"] #[path = "update_tests.rs"]
mod tests; mod tests;
+6 -6
View File
@@ -19,12 +19,12 @@ use std::sync::atomic::{AtomicU64, Ordering};
use unlucky_core::download::verify::digest_bytes; use unlucky_core::download::verify::digest_bytes;
use unlucky_core::download::HashAlgo; use unlucky_core::download::HashAlgo;
use unlucky_core::update::{Applied, Target, Version}; use unlucky_core::update::{Applied, Channel, Target, Version};
use crate::dto::{UpdateBanner, UpdateOutcome}; use crate::dto::{UpdateBanner, UpdateOutcome};
use super::super::env::HostEnv; use super::super::env::HostEnv;
use super::super::update::{outcome_of, ApplyReport, Factory, Prefs, Report, CHANNEL}; use super::super::update::{outcome_of, ApplyReport, Factory, Report};
use super::Runtime; use super::Runtime;
/// 一次性临时目录。 /// 一次性临时目录。
@@ -134,7 +134,7 @@ fn version_matrix_decides_who_gets_prompted() {
let tmp = TempDir::new("matrix"); let tmp = TempDir::new("matrix");
let payload = b"new"; let payload = b"new";
dir_source(&tmp, &manifest_for(listed, payload, ""), payload); dir_source(&tmp, &manifest_for(listed, payload, ""), payload);
let outcome = outcome_of(job(&tmp).check(&version(current), CHANNEL)); let outcome = outcome_of(job(&tmp).check(&version(current), Channel::Stable));
assert_eq!( assert_eq!(
outcome.candidate().is_some(), outcome.candidate().is_some(),
expected, expected,
@@ -180,7 +180,7 @@ fn a_broken_manifest_degrades_to_failure_not_up_to_date() {
] { ] {
let tmp = TempDir::new("badmanifest"); let tmp = TempDir::new("badmanifest");
tmp.write("dist/stable.json", manifest.as_bytes()); tmp.write("dist/stable.json", manifest.as_bytes());
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), CHANNEL)); let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), Channel::Stable));
assert!( assert!(
matches!(outcome, UpdateOutcome::Failed(_)), matches!(outcome, UpdateOutcome::Failed(_)),
"{label}:坏清单必须降级成「检查失败」,实际 {outcome:?}" "{label}:坏清单必须降级成「检查失败」,实际 {outcome:?}"
@@ -206,7 +206,7 @@ fn an_empty_manifest_means_nothing_published_yet() {
] { ] {
let tmp = TempDir::new("empty-manifest"); let tmp = TempDir::new("empty-manifest");
tmp.write("dist/stable.json", manifest.as_bytes()); tmp.write("dist/stable.json", manifest.as_bytes());
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), CHANNEL)); let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), Channel::Stable));
assert!( assert!(
matches!(&outcome, UpdateOutcome::UpToDate { latest: None }), matches!(&outcome, UpdateOutcome::UpToDate { latest: None }),
"空清单 = 还没发过版,实际 {outcome:?}" "空清单 = 还没发过版,实际 {outcome:?}"
@@ -228,7 +228,7 @@ fn a_higher_version_without_our_platform_is_not_up_to_date() {
) )
.as_bytes(), .as_bytes(),
); );
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), CHANNEL)); let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), Channel::Stable));
assert!( assert!(
matches!(&outcome, UpdateOutcome::MissingArtifact { version } if version == "0.9.0"), matches!(&outcome, UpdateOutcome::MissingArtifact { version } if version == "0.9.0"),
"缺本平台产物要单独报出来,实际 {outcome:?}" "缺本平台产物要单独报出来,实际 {outcome:?}"
+2 -1
View File
@@ -69,7 +69,8 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
}, },
last_played: Some("2026-10-27T12:40:00Z".to_string()), last_played: Some("2026-10-27T12:40:00Z".to_string()),
runtime: InstanceRuntime::Failed { runtime: InstanceRuntime::Failed {
reason: "exit0 静默失败".to_string(), stage: crate::dto::LaunchFailureStage::Spawn,
detail: "exit 0 但游戏没起来(静默失败)".to_string(),
}, },
icon: None, icon: None,
isolation: "isolated".to_string(), isolation: "isolated".to_string(),
+14 -7
View File
@@ -18,6 +18,7 @@ use crate::dto::{
InstanceRuntime, InstanceRuntime,
InstanceSummary, InstanceSummary,
JavaPageView, JavaPageView,
LaunchFinished,
LoaderKind, LoaderKind,
LogLine, LogLine,
LogSession, LogSession,
@@ -32,7 +33,7 @@ use crate::prefs::UiPrefs;
// 内容库的视图态与其 DTO 放一起(`dto/content.rs`),路径 `state::ContentState` 不变。 // 内容库的视图态与其 DTO 放一起(`dto/content.rs`),路径 `state::ContentState` 不变。
pub use crate::dto::ContentState; pub use crate::dto::ContentState;
use crate::router::{AiPanelMode, Route, Router}; use crate::router::{AiPanelMode, Route, Router};
use crate::toast::{ToastCenter, ToastKind}; use crate::toast::ToastCenter;
use crate::wizard::WizardState; use crate::wizard::WizardState;
// 账户/登录状态(倒计时、上屏前二次脱敏)拆在 `state/accounts.rs`,守 400 行硬线。 // 账户/登录状态(倒计时、上屏前二次脱敏)拆在 `state/accounts.rs`,守 400 行硬线。
@@ -100,6 +101,11 @@ pub struct AppState {
/// 只是信号,不带文案:app 层比对前后值决定「要不要弹一条 i18n Toast」, /// 只是信号,不带文案:app 层比对前后值决定「要不要弹一条 i18n Toast」,
/// 于是取消提示和页面用同一种语言(复审 P2-2)。 /// 于是取消提示和页面用同一种语言(复审 P2-2)。
pub install_cancelled: u64, pub install_cancelled: u64,
/// 启动收尾次数(M161,每次 `LaunchFinished` +1):与 `install_cancelled` 同一条纪律——
/// presenter 只发信号,文案由 app 层按 i18n key 现求。
pub launch_ends: u64,
/// 最近一次启动收尾的结构(结局 + 退出码 + 失败阶段;文案在 app 层求)。
pub last_launch_end: Option<Box<LaunchFinished>>,
/// 加载器版本清单(下载页「安装选项」的下拉;M105) /// 加载器版本清单(下载页「安装选项」的下拉;M105)
/// ///
/// 两段键:外层是加载器的**稳定 id**(`LoaderKind::as_str`,`LoaderKind` 没派生 `Hash`, /// 两段键:外层是加载器的**稳定 id**(`LoaderKind::as_str`,`LoaderKind` 没派生 `Hash`,
@@ -160,6 +166,8 @@ impl AppState {
java: JavaPageView::new(), java: JavaPageView::new(),
install: None, install: None,
install_cancelled: 0, install_cancelled: 0,
launch_ends: 0,
last_launch_end: None,
loader_versions: std::collections::HashMap::new(), loader_versions: std::collections::HashMap::new(),
settings_groups: Vec::new(), settings_groups: Vec::new(),
settings_tab: "download".to_string(), settings_tab: "download".to_string(),
@@ -246,12 +254,11 @@ impl AppState {
CoreEvent::LaunchFinished(finished) => { CoreEvent::LaunchFinished(finished) => {
self.live_session = None; self.live_session = None;
self.selected_session = Some(finished.session.clone()); self.selected_session = Some(finished.session.clone());
let kind = if finished.outcome == crate::dto::LogOutcome::Ok { // **不在这里拼文案**(M161):presenter 不依赖 `unlucky-i18n`,写死的中文会在
ToastKind::Success // 英文界面上弹出来。这里只记「又收尾了一场」,Toast 由 app 层按 key 现求
} else { // (与 `install_cancelled` 同一条纪律,见 `app/state/refresh.rs`)。
ToastKind::Error self.launch_ends = self.launch_ends.saturating_add(1);
}; self.last_launch_end = Some(Box::new(finished));
self.toasts.push(kind, finished.summary);
} }
CoreEvent::DiagnosisReady(report) => self.diagnosis = Some(report), CoreEvent::DiagnosisReady(report) => self.diagnosis = Some(report),
CoreEvent::AccountsChanged(accounts) => self.accounts = accounts, CoreEvent::AccountsChanged(accounts) => self.accounts = accounts,