Merge branch 'feat/m161-uipolish-debts-sweep-update-keys-bu'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
0241f793b2
74 files changed
+3415
-739
No files matched your search
@@ -168,6 +168,9 @@ pub struct App {
|
||||
/// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」,
|
||||
/// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。
|
||||
pub(super) install_cancelled_seen: u64,
|
||||
/// 已经提示过的启动收尾次数(M161):`AppState::launch_ends` 是「收尾过几场」,
|
||||
/// 这里是「其中几场已经弹过 Toast」。两者不等 ⇒ 弹一次(与 `install_cancelled_seen` 同构)。
|
||||
pub(super) launch_ends_seen: u64,
|
||||
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
|
||||
///
|
||||
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
|
||||
|
||||
@@ -89,6 +89,7 @@ impl App {
|
||||
let mut view = pages::Model::new(
|
||||
pages::Screen::Instances,
|
||||
width,
|
||||
height,
|
||||
Some(pages::Screen::Instances.nav()),
|
||||
);
|
||||
view.lang = lang.lang();
|
||||
@@ -126,6 +127,7 @@ impl App {
|
||||
download_draft: pages::InstallDraft::default(),
|
||||
launch_form: pages::LaunchForm::default(),
|
||||
install_cancelled_seen: 0,
|
||||
launch_ends_seen: 0,
|
||||
content: pages::content::PageState::default(),
|
||||
account_form: pages::account::Form::new(),
|
||||
account: pages::account::Page::build(
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
use crate::app::view;
|
||||
use crate::pages;
|
||||
|
||||
use unlucky_presenter::router::Route;
|
||||
@@ -36,6 +37,23 @@ impl App {
|
||||
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` 的分类参数):
|
||||
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
|
||||
let category = self.view.download.category;
|
||||
@@ -48,6 +66,7 @@ impl App {
|
||||
self.view = super::super::view::model(
|
||||
&self.state,
|
||||
width,
|
||||
self.height,
|
||||
self.options.force_state,
|
||||
category,
|
||||
&draft,
|
||||
|
||||
@@ -20,8 +20,18 @@ impl App {
|
||||
/// 一条更新动作。
|
||||
pub(super) fn on_update_action(&mut self, action: UpdateAction) -> Task<Message> {
|
||||
match action {
|
||||
UpdateAction::CheckNow => self.dispatch(AppCommand::CheckUpdate),
|
||||
UpdateAction::Apply => self.dispatch(AppCommand::ApplyUpdate),
|
||||
UpdateAction::CheckNow => {
|
||||
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::SetAutoCheck(on) => self.dispatch(AppCommand::SetUpdateAutoCheck(on)),
|
||||
UpdateAction::RestartNow => return self.restart_now(),
|
||||
@@ -29,6 +39,25 @@ impl App {
|
||||
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`)**不重启**:那条路每跑一次就该落一张图然后退出,重启会覆盖同一
|
||||
@@ -142,6 +171,47 @@ mod tests {
|
||||
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]
|
||||
fn the_screenshot_channel_never_restarts() {
|
||||
|
||||
@@ -21,12 +21,12 @@ use super::state::{log_first_frame, App};
|
||||
use super::APP_TITLE;
|
||||
|
||||
mod account_page;
|
||||
mod instances;
|
||||
pub(super) mod instances;
|
||||
mod logs;
|
||||
mod rail;
|
||||
mod update;
|
||||
|
||||
use instances::{detail_of, runtime_status, supporting_line};
|
||||
use instances::{detail_of, runtime_status_row, supporting_line};
|
||||
use logs::logs_model;
|
||||
use update::update_of;
|
||||
|
||||
@@ -140,6 +140,7 @@ impl App {
|
||||
pub(super) fn model(
|
||||
state: &AppState,
|
||||
width: f32,
|
||||
height: f32,
|
||||
forced: Option<crate::cli::ForcedState>,
|
||||
category: pages::Category,
|
||||
draft: &pages::InstallDraft,
|
||||
@@ -158,7 +159,7 @@ pub(super) fn model(
|
||||
id: cell.instance.id.clone(),
|
||||
name: cell.instance.name.clone(),
|
||||
supporting: supporting_line(cell.instance, catalog),
|
||||
status: runtime_status(&cell.instance.runtime, catalog),
|
||||
status: runtime_status_row(&cell.instance.runtime, catalog),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -174,6 +175,7 @@ pub(super) fn model(
|
||||
pages::Model {
|
||||
screen,
|
||||
window_width: width,
|
||||
window_height: height,
|
||||
active: route.nav(),
|
||||
instances: pages::Instances { rows, detail },
|
||||
download: pages::Download::from_snapshot(state, category, draft, lang.catalog()),
|
||||
|
||||
@@ -18,8 +18,11 @@
|
||||
//! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、
|
||||
//! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind};
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
use unlucky_presenter::dto::{
|
||||
InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFinished,
|
||||
LaunchStage, LoaderKind, LogOutcome,
|
||||
};
|
||||
|
||||
use crate::pages::{Detail, LaunchFacts, Meta};
|
||||
|
||||
@@ -59,7 +62,7 @@ fn isolation(raw: &str, catalog: Catalog) -> String {
|
||||
}
|
||||
|
||||
/// 运行状态(列表行的 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 {
|
||||
InstanceRuntime::Idle => None,
|
||||
// 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言
|
||||
@@ -71,13 +74,103 @@ pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Opt
|
||||
InstanceRuntime::Running { pid } => {
|
||||
Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())]))
|
||||
}
|
||||
// `reason` 是 core 给的诊断原文(**数据**,不翻译);只有前半句是界面文案
|
||||
InstanceRuntime::Failed { reason } => {
|
||||
Some(catalog.render(Key::InstancesFailed, &[("reason", reason)]))
|
||||
// 失败有**两种形状**,各说各的话(复审 P1-1):
|
||||
// - `Stage`:进程**没起来** → 「哪一步 + 那一步的原文」(原文不翻译,是取证信息);
|
||||
// - `Crashed` / `SilentFailure`:**起来过** → 说结局(崩溃 / 疑似静默失败 + 退出码),
|
||||
// 绝不说成「启动游戏进程失败」。
|
||||
//
|
||||
// 这一份是**详情页**用的全文(详情列会自动换行);列表行那份是 [`runtime_status_row`]
|
||||
// (单行、不许折行,见 D-M79-5)。
|
||||
InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, true)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条失败状态行。
|
||||
///
|
||||
/// `with_detail = false` 是**列表行**那一档:`Stage` 只留「哪一步」,不带原文
|
||||
/// (原文可能是一长串路径,单行 overline 会把它裁成半截字);结局类的两种形状本来就没有原文。
|
||||
fn failure_line(cause: &LaunchFailureCause, catalog: Catalog, with_detail: bool) -> String {
|
||||
match cause {
|
||||
LaunchFailureCause::Stage { stage, detail } if with_detail => catalog.render(
|
||||
Key::LaunchStageFailed,
|
||||
&[
|
||||
("stage", failure_stage_label(*stage, catalog)),
|
||||
("detail", detail.as_str()),
|
||||
],
|
||||
),
|
||||
LaunchFailureCause::Stage { stage, .. } => catalog.render(
|
||||
Key::InstancesFailed,
|
||||
&[("reason", failure_stage_label(*stage, catalog))],
|
||||
),
|
||||
LaunchFailureCause::Crashed { exit_code } => {
|
||||
let reason = match exit_code {
|
||||
Some(code) => catalog.render(Key::InstancesCrashed, &[("code", &code.to_string())]),
|
||||
// 平台没给退出码:只说「崩溃」,不编一个 0 出来。
|
||||
None => catalog.text(Key::LaunchOutcomeCrash).to_string(),
|
||||
};
|
||||
catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())])
|
||||
}
|
||||
LaunchFailureCause::SilentFailure { .. } => {
|
||||
// 静默失败的**关键信息是「exit 0 却没起来」**,不是退出码本身(复审 P1-1 第 ③ 条)。
|
||||
let reason = catalog.text(Key::InstancesSilentFailed).to_string();
|
||||
catalog.render(Key::InstancesFailed, &[("reason", reason.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 { cause } => Some(failure_line(cause, catalog, false)),
|
||||
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()`(那是核心层的中文诊断串,英文界面会漏中文)。
|
||||
fn stage_label(stage: LaunchStage, catalog: Catalog) -> &'static str {
|
||||
catalog.text(match stage {
|
||||
|
||||
@@ -74,7 +74,9 @@ fn crashed_only() -> AppState {
|
||||
id: "dev".to_string(),
|
||||
session: CRASH.to_string(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
summary: "崩溃(exit 1)".to_string(),
|
||||
exit_code: Some(1),
|
||||
uptime_ms: 3_000,
|
||||
failure: None,
|
||||
}));
|
||||
|
||||
state
|
||||
@@ -166,7 +168,9 @@ fn an_imported_crash_file_still_shows_its_findings() {
|
||||
id: "dev".to_string(),
|
||||
session: CRASH.to_string(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
summary: "崩溃(exit 1)".to_string(),
|
||||
exit_code: Some(1),
|
||||
uptime_ms: 3_000,
|
||||
failure: None,
|
||||
}));
|
||||
state.apply_core(CoreEvent::DiagnosisReady(report(
|
||||
"file:crash-2026-11-02_19.40.15-client.txt",
|
||||
|
||||
@@ -7,6 +7,9 @@ use unlucky_presenter::dto::{
|
||||
};
|
||||
use unlucky_presenter::mock_data::mock_instances;
|
||||
|
||||
#[path = "tests/launch.rs"]
|
||||
mod launch;
|
||||
|
||||
fn instance(runtime: InstanceRuntime) -> InstanceSummary {
|
||||
InstanceSummary {
|
||||
id: "survival".to_string(),
|
||||
@@ -39,6 +42,7 @@ fn at_lang(state: &AppState, forced: Option<crate::cli::ForcedState>, lang: Lang
|
||||
model(
|
||||
state,
|
||||
1180.0,
|
||||
760.0,
|
||||
forced,
|
||||
pages::Category::All,
|
||||
&draft,
|
||||
@@ -122,21 +126,6 @@ fn a_busy_instance_offers_stop_instead_of_play() {
|
||||
assert!(detail.can_stop, "启动中要能 Stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_instances_can_be_retried() {
|
||||
let failed = instance(InstanceRuntime::Failed {
|
||||
reason: "exit 1".to_string(),
|
||||
});
|
||||
assert_eq!(
|
||||
runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
|
||||
Some("Launch failed · exit 1")
|
||||
);
|
||||
assert!(
|
||||
detail_of(&failed, Lang::En.catalog()).can_launch,
|
||||
"失败后要能重试"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_sliced_not_guessed() {
|
||||
let en = Lang::En.catalog();
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! 启动失败/收尾的**文案**用例(从 `app/view/tests.rs` 拆出:那边 400 行到线)。
|
||||
//!
|
||||
//! 这里的断言都钉**具体句子**(不是「字符串非空」):磁贴与 Toast 必须说同一件事,
|
||||
//! 「没起来」与「起来又崩」不许共用一句(M161 复审 P1-1)。
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 失败后磁贴要留「重试」,且行/详情文案按「哪一步 + 原文」给。
|
||||
#[test]
|
||||
fn failed_instances_can_be_retried() {
|
||||
// 进程**没起来**:说「哪一步 + 原文」。
|
||||
let failed = instance(InstanceRuntime::Failed {
|
||||
cause: unlucky_presenter::dto::LaunchFailureCause::Stage {
|
||||
stage: unlucky_presenter::dto::LaunchFailureStage::Spawn,
|
||||
detail: "os error 5".to_string(),
|
||||
},
|
||||
});
|
||||
// 列表行是**单行**:只放「哪一步」(原文太长会被 `Wrapping::None` 裁成半截字)。
|
||||
assert_eq!(
|
||||
super::instances::runtime_status_row(&failed.runtime, Lang::En.catalog()).as_deref(),
|
||||
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: os error 5")
|
||||
);
|
||||
assert!(
|
||||
detail_of(&failed, Lang::En.catalog()).can_launch,
|
||||
"失败后要能重试"
|
||||
);
|
||||
}
|
||||
|
||||
/// **「没起来」与「起来又崩」不许说成同一句**(复审 P1-1 的文案面)。
|
||||
///
|
||||
/// 抓得住:崩溃/静默失败被塞回 `LaunchFailureStage::Spawn`(磁贴会写「启动游戏进程失败」,
|
||||
/// 而进程明明起来了);退出码漏成 `Some(1)` 这种 Debug 文本;静默失败丢掉
|
||||
/// 「exit 0 却没跑起来」这条信息。断言的是**具体句子**,不是「字符串非空」。
|
||||
#[test]
|
||||
fn a_crash_after_start_is_not_worded_as_a_start_failure() {
|
||||
use unlucky_presenter::dto::{LaunchFailureCause, LaunchFailureStage};
|
||||
|
||||
let line = |cause: LaunchFailureCause, lang: Lang| {
|
||||
let runtime = InstanceRuntime::Failed { cause };
|
||||
let row = super::instances::runtime_status_row(&runtime, lang.catalog()).expect("行文案");
|
||||
let detail = super::instances::runtime_status(&runtime, lang.catalog()).expect("详情文案");
|
||||
(row, detail)
|
||||
};
|
||||
|
||||
// ① 没起来:说阶段。
|
||||
let (row, detail) = line(
|
||||
LaunchFailureCause::Stage {
|
||||
stage: LaunchFailureStage::Spawn,
|
||||
detail: "os error 5".to_string(),
|
||||
},
|
||||
Lang::En,
|
||||
);
|
||||
assert_eq!(row, "Launch failed · Starting the game process");
|
||||
assert_eq!(
|
||||
detail,
|
||||
"Launch failed · Starting the game process: os error 5"
|
||||
);
|
||||
|
||||
// ② 起来又崩:说结局 + 数字退出码(不许出现 `Some(1)`)。
|
||||
let (row, detail) = line(LaunchFailureCause::Crashed { exit_code: Some(1) }, Lang::En);
|
||||
assert_eq!(row, "Launch failed · the game crashed (exit 1)");
|
||||
assert_eq!(detail, row, "结局类没有「原文」可加,行与详情同一句");
|
||||
assert!(!detail.contains("Some("), "退出码不许漏 Debug:{detail}");
|
||||
assert!(
|
||||
!detail.contains("Starting the game process"),
|
||||
"进程起来了,不许说「启动游戏进程」:{detail}"
|
||||
);
|
||||
|
||||
// ③ 静默失败:exit 0 却没跑起来 —— 这条信息不能丢。
|
||||
let (row, zh) = line(
|
||||
LaunchFailureCause::SilentFailure { exit_code: Some(0) },
|
||||
Lang::ZhCn,
|
||||
);
|
||||
assert_eq!(row, "启动失败 · 退出码 0,没起来");
|
||||
assert_eq!(zh, row);
|
||||
assert!(row.contains("没起来"), "{row}");
|
||||
|
||||
// ④ 平台没给退出码:只说崩溃,不编一个 0 出来。
|
||||
let (row, _) = line(LaunchFailureCause::Crashed { exit_code: None }, Lang::En);
|
||||
assert_eq!(row, "Launch failed · The game crashed");
|
||||
}
|
||||
|
||||
///
|
||||
/// 此前 `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)
|
||||
}
|
||||
@@ -81,8 +81,9 @@ pub fn view<'a>(
|
||||
metrics: FrameMetrics,
|
||||
model: &'a Content,
|
||||
lang: Lang,
|
||||
window_height: f32,
|
||||
) -> Element<'a, PageMessage> {
|
||||
view::view(ui, metrics, model, lang)
|
||||
view::view(ui, metrics, model, lang, window_height)
|
||||
}
|
||||
|
||||
/// 演示数据(`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` 的门槛:标签栏 + 间距 + 列表下限 + 间距 + 详情。
|
||||
pub fn wide_min() -> f32 {
|
||||
RAIL_LABEL + GAP + layout::LINE_SAFE_WIDTH_PX + GAP + DETAIL
|
||||
@@ -192,3 +239,101 @@ mod tests {
|
||||
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)落在**矮**档,验收到 760 不换版面——**正文可用高按真实外壳算**。
|
||||
///
|
||||
/// 复审 P2-2:早先这里是 `560 − 56 − 88 − 32` 反推的(三个数全错:真实是 titlebar 48 /
|
||||
/// 两行页头 84 / Expanded 页边距 2×24 = chrome 180),结论碰巧对、但抓不住 metrics 漂移。
|
||||
/// 现在直接调 [`crate::pages::frame::body_height`],chrome 从 token 现取。
|
||||
#[test]
|
||||
fn the_minimum_window_size_uses_the_scrolling_body() {
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::pages::frame;
|
||||
use crate::pages::layout::{frame_metrics, PaneKit};
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
let ui = Ui::with_platform(
|
||||
SchemeFlavor::Light,
|
||||
Platform::Windows,
|
||||
Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
},
|
||||
);
|
||||
// 880 宽 = 窗口最小尺寸(`app::MIN_SIZE.width`),属 Expanded。
|
||||
let metrics = frame_metrics(PaneKit::Single, 880.0);
|
||||
let chrome =
|
||||
ui.metrics().titlebar_height + ui.metrics().header_height(true) + 2.0 * metrics.gutter;
|
||||
|
||||
// 真值:48 + 84 + 48 = 180。这条会随 token 改而红——那时请重看一眼两档版面。
|
||||
assert_eq!(chrome, 180.0, "外壳 chrome 与 design token 不一致");
|
||||
|
||||
// 最小窗口 560 高:正文只剩 380 < 460 ⇒ 整列滚动(本条债的真实场景)。
|
||||
let short = frame::body_height(&ui, metrics, 560.0, true);
|
||||
assert_eq!(short, 560.0 - chrome);
|
||||
assert_eq!(
|
||||
BodyMode::of(short),
|
||||
BodyMode::ScrollWhole,
|
||||
"正文只剩 {short}"
|
||||
);
|
||||
|
||||
// 验收尺寸 760 高:580 ≥ 460 ⇒ 列表自己滚(不换版面)。
|
||||
let tall = frame::body_height(&ui, metrics, 760.0, true);
|
||||
assert_eq!(BodyMode::of(tall), BodyMode::Split);
|
||||
|
||||
// 把门槛和**真实 chrome** 绑死:窗口高 = chrome + MIN 时必须还是 Split,
|
||||
// 少 1px 就翻成整列滚动。chrome 涨到 100/页边距 40 这类漂移会让这条立刻红
|
||||
// (而不是像旧用例那样拿反推数字自说自话)。
|
||||
assert_eq!(
|
||||
BodyMode::of(frame::body_height(
|
||||
&ui,
|
||||
metrics,
|
||||
chrome + MIN_BODY_HEIGHT,
|
||||
true
|
||||
)),
|
||||
BodyMode::Split
|
||||
);
|
||||
assert_eq!(
|
||||
BodyMode::of(frame::body_height(
|
||||
&ui,
|
||||
metrics,
|
||||
chrome + MIN_BODY_HEIGHT - 1.0,
|
||||
true
|
||||
)),
|
||||
BodyMode::ScrollWhole
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,8 @@ use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView,
|
||||
ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportErrorView,
|
||||
ImportProgressView, ImportReportView, ImportStageView, ImportState, ImportStatusView,
|
||||
InstanceId, LoaderKind, MissingFileView, MissingReasonView, PackFormatView,
|
||||
ImportFailureStageView, ImportProgressView, ImportReportView, ImportStageView, ImportState,
|
||||
ImportStatusView, InstanceId, LoaderKind, MissingFileView, MissingReasonView, PackFormatView,
|
||||
};
|
||||
|
||||
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));
|
||||
page.expanded = Some("config/sodium-options.json".to_string());
|
||||
}
|
||||
// 致命态:整包回滚 + 容忍域仍在(展示两类失败的视觉分野)
|
||||
"failed" => {
|
||||
// 致命态(M161 分成三档出图:预检没过 / 已回滚 / 回滚没做完)。
|
||||
// `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);
|
||||
fatal.error = Some(ImportErrorView {
|
||||
code: "pack_traversal".to_string(),
|
||||
@@ -99,6 +113,21 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) {
|
||||
});
|
||||
fatal.files.clear();
|
||||
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);
|
||||
}
|
||||
// 取消态:面板收回了,但「后台还在跑」这句话要留在屏上
|
||||
@@ -182,6 +211,7 @@ fn report(status: ImportStatusView, conflicts: bool) -> ImportReportView {
|
||||
registered: Some("Survival".to_string()),
|
||||
bytes_written: 3_400_000,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
};
|
||||
if conflicts {
|
||||
report.conflicts = vec![
|
||||
|
||||
@@ -23,7 +23,10 @@
|
||||
//! ## 容忍 / 致命在模型层就分开
|
||||
//!
|
||||
//! [`report::Report::missing`] 是「搞不到某个文件」(实例可用),[`report::Report::fatal`]
|
||||
//! 是「已整包回滚」。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。
|
||||
//! 是致命失败。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。
|
||||
//!
|
||||
//! 致命那一类内部还要再分三档(M161):预检没过(一个字节都没写)/ 已回滚 / 回滚没做完,
|
||||
//! 见 [`report::FatalKind`]——措辞、图标、底色三处都分开。
|
||||
|
||||
mod export;
|
||||
mod pick;
|
||||
@@ -33,7 +36,7 @@ mod report;
|
||||
pub use export::{format_label, request_of as request_export, Export, Preview};
|
||||
pub use pick::{Pick, PickRow};
|
||||
pub use progress::Running;
|
||||
pub use report::{ConflictRow, Report, Row};
|
||||
pub use report::{ConflictRow, FatalKind, Report, Row};
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView, ImportReportView,
|
||||
ImportStatusView, MissingFileView, MissingReasonView,
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView,
|
||||
ImportFailureStageView, ImportReportView, ImportStatusView, MissingFileView, MissingReasonView,
|
||||
};
|
||||
|
||||
use super::PackPageState;
|
||||
@@ -48,10 +48,40 @@ pub struct ConflictRow {
|
||||
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)]
|
||||
pub struct Fatal {
|
||||
/// 「已回滚 —— 目标目录零残留」
|
||||
/// 三种阶段之一(决定标题/图标/底色)
|
||||
pub kind: FatalKind,
|
||||
/// 标题(预检未通过 / 已回滚 / 回滚不完整)
|
||||
pub title: String,
|
||||
/// 大类名(`路径穿越` / `清单损坏` …)
|
||||
pub label: String,
|
||||
@@ -124,7 +154,7 @@ pub(super) fn report_of(
|
||||
|
||||
Report {
|
||||
status: report.status,
|
||||
title: status_title(report.status, catalog).to_owned(),
|
||||
title: status_title(report, catalog).to_owned(),
|
||||
subtitle: subtitle(report, catalog),
|
||||
written_line: catalog.render(
|
||||
Key::PackReportWritten,
|
||||
@@ -133,7 +163,10 @@ pub(super) fn report_of(
|
||||
("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: report
|
||||
.missing
|
||||
@@ -177,11 +210,22 @@ fn human(report: &ImportReportView) -> String {
|
||||
unlucky_presenter::dto::human_bytes(report.bytes_written)
|
||||
}
|
||||
|
||||
fn status_title(status: ImportStatusView, catalog: Catalog) -> &'static str {
|
||||
match status {
|
||||
/// 标题:三态 + **失败阶段**(预检未通过 ≠ 已回滚,M161)。
|
||||
fn status_title(report: &ImportReportView, catalog: Catalog) -> &'static str {
|
||||
match report.status {
|
||||
ImportStatusView::Success => catalog.text(Key::PackStatusSuccess),
|
||||
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(" · ")
|
||||
}
|
||||
|
||||
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 {
|
||||
title: catalog.text(Key::PackFatalTitle).to_owned(),
|
||||
kind,
|
||||
title: title.to_owned(),
|
||||
label: error.label.clone(),
|
||||
message: error.message.clone(),
|
||||
code: error.code.clone(),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。
|
||||
|
||||
mod pick;
|
||||
mod stage;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -226,6 +227,7 @@ fn report_keeps_tolerable_and_fatal_apart() {
|
||||
registered: None,
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
};
|
||||
let block = report_of(&report, &PackPageState::default(), catalog());
|
||||
assert!(block.fatal.is_some(), "致命块必须单独存在");
|
||||
@@ -262,6 +264,7 @@ fn conflicts_default_to_keeping_the_user_copy() {
|
||||
registered: None,
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
};
|
||||
// 「以包为准」只在导入到**已有实例**时给(复审 r1 P2-2),所以这里选一个实例
|
||||
let page = PackPageState {
|
||||
@@ -359,6 +362,7 @@ fn overwrite_is_offered_only_when_importing_into_an_existing_instance() {
|
||||
registered: None,
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
};
|
||||
|
||||
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::theme::Ui;
|
||||
|
||||
use super::super::model::{ConflictRow, Report};
|
||||
use super::super::model::{ConflictRow, FatalKind, Report};
|
||||
use super::super::PackMessage;
|
||||
use super::intent;
|
||||
use super::parts::{bounded, marker_box, rows_block, tolerable_title};
|
||||
@@ -62,34 +62,46 @@ pub(super) fn report_block<'a>(
|
||||
.width(Length::Fill);
|
||||
|
||||
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(
|
||||
ui,
|
||||
Role::ErrorContainer,
|
||||
column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelLarge,
|
||||
true,
|
||||
fatal.title.as_str(),
|
||||
ui.color(Role::OnErrorContainer),
|
||||
),
|
||||
frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
format!("{} · {}", fatal.label, fatal.code),
|
||||
ui.color(Role::OnErrorContainer),
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
fatal.message.as_str(),
|
||||
ui.color(Role::OnErrorContainer),
|
||||
),
|
||||
role,
|
||||
row![
|
||||
frame::glyph(fatal.kind.icon(), ui.color(on_role), frame::ICON_PX),
|
||||
column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelLarge,
|
||||
true,
|
||||
fatal.title.as_str(),
|
||||
ui.color(on_role),
|
||||
),
|
||||
frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
format!("{} · {}", fatal.label, fatal.code),
|
||||
ui.color(on_role),
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
fatal.message.as_str(),
|
||||
ui.color(on_role),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill),
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Start),
|
||||
));
|
||||
if let Some(hint) = &fatal.hint {
|
||||
block = block.push(
|
||||
|
||||
@@ -98,12 +98,18 @@ pub(super) fn search<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 结果列表(独立滚动;底部「加载更多」钉在列表之外,见 [`more`])。
|
||||
/// 结果列表。
|
||||
///
|
||||
/// 两档(M161,判据同 [`super::layout::BodyMode`]):
|
||||
///
|
||||
/// * `mode.list_scrolls()`:列表**自己滚**(筛选条常驻,高窗口的常态);
|
||||
/// * 否则:行按自然高度铺开,滚动交给外层主列——矮窗口里一行 `ListItem` 不许被压成半行。
|
||||
pub(super) fn list<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Content,
|
||||
rails: Rails,
|
||||
mode: super::layout::BodyMode,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let rows = column(
|
||||
model
|
||||
@@ -117,20 +123,32 @@ pub(super) fn list<'a>(
|
||||
.width(Length::Fill);
|
||||
|
||||
let _ = catalog;
|
||||
container(
|
||||
scrollable(rows)
|
||||
.direction(scrollable::Direction::Vertical(
|
||||
scrollable::Scrollbar::new()
|
||||
.width(frame::SCROLLBAR_WIDTH)
|
||||
.scroller_width(frame::SCROLLBAR_WIDTH)
|
||||
.margin(frame::SCROLLBAR_MARGIN),
|
||||
))
|
||||
.height(Length::Fill)
|
||||
.style(frame::scroll_style(ui)),
|
||||
)
|
||||
.width(Length::Fixed(rails.list))
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
let inner: Element<'a, PageMessage> = if mode.list_scrolls() {
|
||||
container(
|
||||
scrollable(rows)
|
||||
.direction(scrollable::Direction::Vertical(
|
||||
scrollable::Scrollbar::new()
|
||||
.width(frame::SCROLLBAR_WIDTH)
|
||||
.scroller_width(frame::SCROLLBAR_WIDTH)
|
||||
.margin(frame::SCROLLBAR_MARGIN),
|
||||
))
|
||||
.height(Length::Fill)
|
||||
.style(frame::scroll_style(ui)),
|
||||
)
|
||||
.height(Length::Fill)
|
||||
.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> {
|
||||
|
||||
@@ -23,7 +23,11 @@ use super::{intent, sort_label};
|
||||
use crate::pages::frame;
|
||||
use crate::pages::Message as PageMessage;
|
||||
|
||||
/// 来源 + 排序 + 版本 + 加载器四组 chip(一行放不下就换行,`row` 变 `column`)。
|
||||
/// 来源 + 排序 + 版本 + 加载器四组 chip(**每组一行**,组与组之间纵向排)。
|
||||
///
|
||||
/// 组内是一行 `row`([`wrapped_row`]):**不做自动换行**——屏幕太窄时 chip 会被裁掉,
|
||||
/// 而不是折到第二行(`row` 没有 wrap 能力)。这是既定行为,别把这条注释读成「会自动换行」;
|
||||
/// 宽度由列表列下限 424([`super::layout::compact_min`])兜住,正常窗口下不会撞到。
|
||||
pub(crate) fn filters<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
|
||||
@@ -49,12 +49,17 @@ pub(super) fn view<'a>(
|
||||
metrics: FrameMetrics,
|
||||
model: &'a Content,
|
||||
lang: unlucky_i18n::Lang,
|
||||
window_height: f32,
|
||||
) -> Element<'a, PageMessage> {
|
||||
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))
|
||||
.subtitle(catalog.text(Key::ContentSubtitle))
|
||||
.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>(
|
||||
@@ -62,6 +67,7 @@ fn body<'a>(
|
||||
metrics: FrameMetrics,
|
||||
catalog: Catalog,
|
||||
model: &'a Content,
|
||||
mode: content_layout::BodyMode,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let rails = content_layout::resolve(metrics.available);
|
||||
|
||||
@@ -75,14 +81,14 @@ fn body<'a>(
|
||||
let main = column![
|
||||
kind_rail(ui, catalog, model, rails),
|
||||
divider::full(ui),
|
||||
main_column(ui, catalog, model, rails)
|
||||
main_column(ui, catalog, model, rails, mode)
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
columns = row![main].width(Length::Fill).height(Length::Fill);
|
||||
} 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() {
|
||||
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>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Content,
|
||||
rails: Rails,
|
||||
mode: content_layout::BodyMode,
|
||||
) -> Element<'a, PageMessage> {
|
||||
// 主列的横向内边距不在这里加:`rails.list` 已经把可用宽算干了,再加 2×X2 会让整排
|
||||
// 溢出 32px(详情列被切掉一半,复审截图上正是这条)
|
||||
@@ -178,10 +192,15 @@ fn main_column<'a>(
|
||||
} else {
|
||||
// **必须给间距**(复审 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))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
.width(Length::Fill);
|
||||
// 列表高度:`Split` 吃掉剩余高度(自己滚);`ScrollWhole` 按内容铺开(外层滚)。
|
||||
list = if mode.list_scrolls() {
|
||||
list.height(Length::Fill)
|
||||
} else {
|
||||
list.height(Length::Shrink)
|
||||
};
|
||||
// 紧凑档没有独立详情列:详情压在列表下面(整页替换的成本比「多一块」高)
|
||||
if !rails.tier.has_detail() {
|
||||
list = list
|
||||
@@ -191,10 +210,11 @@ fn main_column<'a>(
|
||||
list.push(parts::more(ui, catalog, model)).into()
|
||||
};
|
||||
|
||||
// `ScrollWhole` 档:主列整列一个滚动区(列表按自然高度)。**只有这一处**加滚动条,
|
||||
// 不在列表里再套一个(双滚动是「到处加滚动条糊」的典型症状)。
|
||||
let mut column = column![]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fixed(rails.list))
|
||||
.height(Length::Fill);
|
||||
.width(Length::Fixed(rails.list));
|
||||
|
||||
// M113: import/export —— 只有整合包这一类才有「把本地的包装进来 / 把实例打出去」。
|
||||
// 其它三类(Mod / 资源包 / 光影)的导入就是把文件拖进目录,不属于本条任务。
|
||||
@@ -204,7 +224,20 @@ fn main_column<'a>(
|
||||
.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` 存在)。
|
||||
|
||||
@@ -114,6 +114,19 @@ pub fn page<'a>(
|
||||
.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> {
|
||||
let spec = Component::TopAppBar.spec();
|
||||
// 16.4:标题 TitleLarge(emphasized w500),副标题 LabelMedium + OnSurfaceVariant
|
||||
|
||||
@@ -99,3 +99,49 @@ fn two_pane_body_is_narrower_than_the_available_width() {
|
||||
assert!(wide.column < wide.available);
|
||||
assert!(wide.secondary.is_some());
|
||||
}
|
||||
|
||||
/// **`body_height` 用真的 `Ui::metrics` + `FrameMetrics` 算**(M161 复审 P2-2:这条函数此前
|
||||
/// 一条测试都没有,而④的「最小窗口」用例是拿 56/88/32 反推的——三个数全错)。
|
||||
///
|
||||
/// 抓得住:①`titlebar_height` / `header_height` / `gutter` 任何一个被改(chrome 变了,
|
||||
/// 下面的等式立刻红);②把两行页头当单行算(`has_subtitle` 被忽略);③把页边距只减一遍。
|
||||
#[test]
|
||||
fn body_height_subtracts_the_real_shell_chrome() {
|
||||
let ui = ui();
|
||||
let metrics = frame_metrics(PaneKit::Single, 880.0);
|
||||
let metrics_wide = frame_metrics(PaneKit::Single, 1180.0);
|
||||
|
||||
let chrome = |two_line: bool| {
|
||||
ui.metrics().titlebar_height + ui.metrics().header_height(two_line) + 2.0 * metrics.gutter
|
||||
};
|
||||
|
||||
// 真值来自 token:自绘标题栏 48 + 两行页头 84 + 上下页边距 2×24 = 180。
|
||||
assert_eq!(
|
||||
chrome(true),
|
||||
48.0 + 84.0 + 2.0 * 24.0,
|
||||
"chrome 与 design token 不一致(titlebar_height / page_header_tall_height / Expanded gutter)"
|
||||
);
|
||||
assert_eq!(body_height(&ui, metrics, 560.0, true), 560.0 - chrome(true));
|
||||
// 单行页头少 28(84 − 56 = `page_header_tall_height` − `page_header_height`)。
|
||||
assert_eq!(
|
||||
body_height(&ui, metrics, 560.0, false),
|
||||
560.0 - chrome(false)
|
||||
);
|
||||
assert!(
|
||||
body_height(&ui, metrics, 560.0, true) < body_height(&ui, metrics, 560.0, false),
|
||||
"带副标题的页头更高,正文可用高必须更小"
|
||||
);
|
||||
// 窄窗(Compact < 600)页边距更小 ⇒ 同样窗口高下正文更高。
|
||||
let compact = frame_metrics(PaneKit::Single, 560.0);
|
||||
assert_eq!(compact.class, crate::pages::layout::WidthClass::Compact);
|
||||
assert!(
|
||||
compact.gutter < metrics_wide.gutter,
|
||||
"Compact 档的页边距应小于 Expanded(16 < 24)"
|
||||
);
|
||||
assert!(
|
||||
body_height(&ui, compact, 560.0, true) > body_height(&ui, metrics_wide, 560.0, true),
|
||||
"页边距变小 ⇒ 正文可用高变大"
|
||||
);
|
||||
// 窗口比外壳还矮时不返回负数(不许把版面算成 NaN/负值)。
|
||||
assert_eq!(body_height(&ui, metrics, 100.0, true), 0.0);
|
||||
}
|
||||
@@ -140,7 +140,7 @@ pub fn run(request: Request) -> Result<(), String> {
|
||||
let written = Arc::clone(&written);
|
||||
iced::application(
|
||||
move || {
|
||||
let mut model = demo_model(boot.screen, boot.width);
|
||||
let mut model = demo_model(boot.screen, boot.width, boot.height);
|
||||
// 截图通道把**消费 forced 的那批控件**(实例页)钉成常态,与产品 `--screenshot`
|
||||
// 的 `ForcedState::Rest` 同一条纪律。它管不到设置页 / 外观区(那边不读 forced),
|
||||
// 那两屏的密闭靠窗口级 passthrough——别把这条当成万能的。
|
||||
|
||||
@@ -61,12 +61,20 @@ const PAGE_SOURCES: [(&str, &str); 6] = [
|
||||
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
|
||||
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
|
||||
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
|
||||
const ALLOWED_LITERALS: [&str; 19] = [
|
||||
const ALLOWED_LITERALS: [&str; 24] = [
|
||||
"{date} {time} UTC",
|
||||
"isolated",
|
||||
"shared",
|
||||
"pid",
|
||||
// M161:失败行由 `LaunchStageFailed` / `InstancesFailed` / `InstancesCrashed` 渲染,
|
||||
// 占位符名是 `{stage}` / `{detail}` / `{reason}` / `{code}`(占位符名不是文案);
|
||||
// `{outcome}` / `{seconds}` 是收尾 Toast 的。
|
||||
"stage",
|
||||
"detail",
|
||||
"reason",
|
||||
"code",
|
||||
"outcome",
|
||||
"seconds",
|
||||
"Instances",
|
||||
// M157:图形后端三态的**稳定 id**(与 core 的 `GraphicsBackend::IDS` 同一份词表),
|
||||
// `backend_label` 只认这三档,其余原样上屏。
|
||||
@@ -87,6 +95,7 @@ const ALLOWED_LITERALS: [&str; 19] = [
|
||||
"1.20.4",
|
||||
"C:/Java/temurin-21/bin/java.exe",
|
||||
"ULL_LAUNCH_ARGS_FIXTURE",
|
||||
// M161:示例「被门槛挡下」的取值(夹具)。
|
||||
"gated",
|
||||
];
|
||||
|
||||
@@ -219,11 +228,23 @@ fn the_running_line_keeps_its_number_and_separator() {
|
||||
assert!(text.contains(" · pid "), "分隔符或 pid 变形了:{text}");
|
||||
}
|
||||
|
||||
// 失败那一行同理:core 给的原因原文原样保留,只翻前半句
|
||||
let failed_en = Catalog::of(Lang::En).render(Key::InstancesFailed, &[("reason", "exit 1")]);
|
||||
let failed_zh = Catalog::of(Lang::ZhCn).render(Key::InstancesFailed, &[("reason", "exit 1")]);
|
||||
assert_eq!(failed_en, "Launch failed · exit 1");
|
||||
assert_eq!(failed_zh, "启动失败 · exit 1");
|
||||
// 失败那一行同理(M161:`InstancesFailed` 已被 `LaunchStageFailed` 取代——「哪一步」也要翻,
|
||||
// 只有 `detail` 那段原文原样保留)
|
||||
let args = [("stage", "Starting the game process"), ("detail", "exit 1")];
|
||||
let failed_en = Catalog::of(Lang::En).render(Key::LaunchStageFailed, &args);
|
||||
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`] 里。
|
||||
|
||||
@@ -230,7 +230,11 @@ fn render<'a>(
|
||||
// M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。
|
||||
Screen::Download => download::view(ui, metrics, &model.download, 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` 里,理由见该组文档)。
|
||||
Screen::Settings => settings::view(ui, metrics, states, language, &model.update),
|
||||
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` 转换)。
|
||||
///
|
||||
/// 实例 / 下载 / 日志三页带演示数据,设置页仍是占位视觉。
|
||||
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 {
|
||||
Screen::Instances => model.instances = instances::demo(),
|
||||
Screen::Download => model.download = download::demo(unlucky_i18n::Lang::En.catalog()),
|
||||
|
||||
@@ -107,6 +107,11 @@ pub struct Model {
|
||||
pub screen: Screen,
|
||||
/// 窗口宽度(**含外壳**:布局档位判据就是它)
|
||||
pub window_width: f32,
|
||||
/// 窗口高度(**含外壳**)。
|
||||
///
|
||||
/// 只有「矮窗口要换版面」这一类判据用它(M161:内容库在低高度下不能把列表压扁)——
|
||||
/// 宽度档位仍只看 [`Self::window_width`]。
|
||||
pub window_height: f32,
|
||||
/// 导航轨高亮项(`None` = 没有高亮,如 AI 独立页)
|
||||
pub active: Option<NavSection>,
|
||||
/// 实例页的数据(其它三页还是占位视觉,不读)
|
||||
@@ -134,10 +139,16 @@ pub struct 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 {
|
||||
screen,
|
||||
window_width,
|
||||
window_height,
|
||||
active,
|
||||
instances: Instances::default(),
|
||||
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` 才画。
|
||||
#[test]
|
||||
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!(
|
||||
model.update.available().is_some(),
|
||||
@@ -115,9 +115,84 @@ fn the_demo_model_carries_an_update_snapshot() {
|
||||
);
|
||||
// 其它页不掺演示更新状态(实例页/下载页的基线图不该多出一条提示条)。
|
||||
assert_eq!(
|
||||
crate::pages::demo_model(Screen::Instances, 1180.0).update,
|
||||
crate::pages::demo_model(Screen::Instances, 1180.0, 760.0).update,
|
||||
Update::default()
|
||||
);
|
||||
let catalog: Catalog = Lang::En.catalog();
|
||||
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(), "装的时候也不许再来一条检查");
|
||||
// 状态行在这一档也必须说「正在安装…」(复审 P2 附注:此前只有截图兜着)。
|
||||
assert_eq!(
|
||||
super::super::update::status_text(catalog, &applying),
|
||||
(catalog.text(Key::SettingsUpdateApplying).to_string(), false),
|
||||
"applying 档的状态行必须是「正在安装…」且不是错误色"
|
||||
);
|
||||
// 同一档的两句不许撞(「正在安装」≠「正在检查」)。
|
||||
assert_ne!(
|
||||
catalog.text(Key::SettingsUpdateApplying),
|
||||
catalog.text(Key::SettingsUpdateChecking)
|
||||
);
|
||||
|
||||
// 结论回来(这里是最坏的结论:失败)⇒ 恢回可点。
|
||||
let failed = Update {
|
||||
status: UpdateStatus::Failed {
|
||||
reason: "连不上".to_string(),
|
||||
retryable: true,
|
||||
},
|
||||
..Update::default()
|
||||
};
|
||||
assert!(!failed.busy(), "失败 = 这一轮结束了");
|
||||
assert!(failed.can_check(), "失败之后必须能重试");
|
||||
assert!(!failed.can_apply(), "没有候选时安装键不可点");
|
||||
}
|
||||
@@ -73,7 +73,7 @@ pub(super) fn body<'a>(ui: &'a Ui, update: &'a Update, lang: Lang) -> Element<'a
|
||||
channel_label(catalog, &update.channel),
|
||||
));
|
||||
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(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![
|
||||
chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
catalog.text(Key::SettingsUpdateAutoCheck),
|
||||
auto_check,
|
||||
Message::Update(UpdateAction::SetAutoCheck(!auto_check)),
|
||||
),
|
||||
),
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::SettingsUpdateCheckNow),
|
||||
Message::Update(UpdateAction::CheckNow),
|
||||
update.auto_check,
|
||||
Message::Update(UpdateAction::SetAutoCheck(!update.auto_check)),
|
||||
),
|
||||
),
|
||||
check,
|
||||
]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill)
|
||||
@@ -159,11 +166,7 @@ fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element
|
||||
|
||||
// 下载/校验/替换期间先说这一句:设置页的按钮在这一刻也点不动(见下面 `applying` 分支),
|
||||
// 用户得知道「它正在干活」而不是「点了没反应」。
|
||||
let (text, error) = if update.applying {
|
||||
(catalog.text(Key::SettingsUpdateApplying).to_string(), false)
|
||||
} else {
|
||||
status_line(catalog, update)
|
||||
};
|
||||
let (text, error) = status_text(catalog, update);
|
||||
let color = ui.color(if error {
|
||||
Role::Error
|
||||
} else {
|
||||
@@ -171,17 +174,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));
|
||||
|
||||
if let Some((_version, _size, notes, forced)) = update.available().filter(|_| !update.applying)
|
||||
{
|
||||
let mut actions = row![buttons::filled(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::SettingsUpdateApply),
|
||||
Message::Update(UpdateAction::Apply),
|
||||
),
|
||||
)]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center);
|
||||
if let Some((_version, _size, notes, forced)) = update.available() {
|
||||
// 有候选就**永远画出**这一颗按钮:忙碌时置灰(`can_apply` = false),而不是整颗消失。
|
||||
// 消失会让人以为「装到一半的功能没了」;灰着 + 状态行那句「正在安装…」才说得清。
|
||||
let apply_label = catalog.text(Key::SettingsUpdateApply);
|
||||
let apply = if update.can_apply() {
|
||||
buttons::filled(
|
||||
ui,
|
||||
Button::new(apply_label, Message::Update(UpdateAction::Apply)),
|
||||
)
|
||||
} else {
|
||||
buttons::filled(ui, Button::disabled(apply_label))
|
||||
};
|
||||
let mut actions = row![apply]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center);
|
||||
if forced {
|
||||
actions = actions.push(text_role_owned(
|
||||
ui,
|
||||
@@ -231,10 +238,23 @@ fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element
|
||||
block.into()
|
||||
}
|
||||
|
||||
/// 状态行此刻那句话(**唯一**产地:`applying` 与四态都在这里分派)。
|
||||
///
|
||||
/// 拆成函数是为了可断言:`applying` 这一档此前只有截图兜着(复审 P2 附注),
|
||||
/// `Element` 没有内省接口,而上屏文本只能从**取词的那一个入口**取。
|
||||
pub(super) fn status_text(catalog: Catalog, update: &Update) -> (String, bool) {
|
||||
if update.applying {
|
||||
// 「正在下载/校验/替换」不是错误色(它只是在干活)。
|
||||
return (catalog.text(Key::SettingsUpdateApplying).to_string(), false);
|
||||
}
|
||||
status_line(catalog, update)
|
||||
}
|
||||
|
||||
/// 一条状态行(文案 + 是不是「错」那一类)。
|
||||
///
|
||||
/// 四种结论各写各的句子,**不共用**「已是最新」那句:失败/缺产物时那句就是撒谎。
|
||||
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 {
|
||||
UpdateStatus::Idle => (Key::SettingsUpdateNever, Vec::new(), false),
|
||||
UpdateStatus::Checking => (Key::SettingsUpdateChecking, Vec::new(), false),
|
||||
|
||||
@@ -57,7 +57,7 @@ fn every_screen_builds_at_the_three_comparison_widths() {
|
||||
let form = demo_launch_form();
|
||||
for width in [1180.0, 900.0, 700.0] {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ fn an_empty_snapshot_still_builds() {
|
||||
let ui = ui();
|
||||
let form = demo_launch_form();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -368,7 +368,7 @@ fn every_rail_item_points_at_a_route_that_highlights_it() {
|
||||
/// 演示模型:12 行 + 1 条选中详情(M84 的出图基线就靠这份数据)。
|
||||
#[test]
|
||||
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);
|
||||
let detail = model.instances.detail.as_ref().expect("演示数据有选中项");
|
||||
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] {
|
||||
let model = demo_model(screen, 1180.0);
|
||||
let model = demo_model(screen, 1180.0, 760.0);
|
||||
assert!(
|
||||
model.instances.rows.is_empty(),
|
||||
"{} 不该带实例数据",
|
||||
|
||||
@@ -102,6 +102,8 @@ pub struct Update {
|
||||
pub silent: bool,
|
||||
}
|
||||
|
||||
mod busy;
|
||||
|
||||
impl Update {
|
||||
/// 有没有可装的候选(有的话设置页给「下载并安装」)。
|
||||
#[must_use]
|
||||
@@ -155,8 +157,37 @@ impl Update {
|
||||
/// 出图 / 单测用的演示快照(一个可装的新版本 + 一次成功的检查)。
|
||||
///
|
||||
/// **不是产品数据**:产品路径的更新状态只来自 presenter(`app/view/update.rs` 转换)。
|
||||
///
|
||||
/// 取证开关 `ULL_UPDATE_STATE`(M161,沿用 `ULL_CONTENT_*` 那套范式):不设 = 缺省那一态;
|
||||
/// `checking` / `applying` 用来出「按钮置灰 + 状态行说正在干活」的那两张图,
|
||||
/// `failed` / `up_to_date` 用来出另外两条结论。
|
||||
#[must_use]
|
||||
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 {
|
||||
current_version: "0.2.2".to_string(),
|
||||
channel: "stable".to_string(),
|
||||
@@ -332,60 +363,6 @@ pub fn banner<'a>(
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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 { .. })));
|
||||
}
|
||||
}
|
||||
#[path = "update/tests.rs"]
|
||||
mod tests;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 { .. })));
|
||||
}
|
||||
@@ -32,6 +32,10 @@ use crate::output::Report;
|
||||
/// 敏感值在输出里的占位。
|
||||
const MASK: &str = "<redacted>";
|
||||
|
||||
mod prefs_migration;
|
||||
|
||||
use prefs_migration::migrate_update_prefs;
|
||||
|
||||
/// `ull config get [<key>] [--show-secret]`。
|
||||
///
|
||||
/// # Errors
|
||||
@@ -46,6 +50,9 @@ pub fn get(ctx: &Ctx, key: Option<&str>, show_secret: bool) -> Result<Report, Cl
|
||||
|
||||
/// 单键 get:读该键自己的落点文件(数据根键走便携目录那份)。
|
||||
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 store = SettingStore::load(&path, launcher_schema());
|
||||
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()
|
||||
};
|
||||
let problems = render_problems(&store);
|
||||
let data = json!({
|
||||
let mut data = json!({
|
||||
"key": key,
|
||||
"value": shown,
|
||||
"redacted": hidden,
|
||||
@@ -83,6 +90,12 @@ fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError>
|
||||
}
|
||||
)];
|
||||
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 {
|
||||
human.push(format!("问题:{problem}"));
|
||||
}
|
||||
@@ -92,6 +105,7 @@ fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError>
|
||||
|
||||
/// 无键全量 dump:以数据根那份为底,`core.*` 数据根键换成便携目录那份的生效值。
|
||||
fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
|
||||
let migration = migrate_update_prefs(ctx);
|
||||
let path = ctx.launcher_config();
|
||||
let store = SettingStore::load(&path, launcher_schema());
|
||||
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()
|
||||
));
|
||||
human.extend(text.lines().map(str::to_string));
|
||||
if let Some(note) = &migration {
|
||||
human.push(note.human.clone());
|
||||
}
|
||||
for problem in &problems {
|
||||
human.push(format!("问题:{problem}"));
|
||||
}
|
||||
if masked {
|
||||
human.push("(敏感值已掩码;加 --show-secret 显示明文)".to_string());
|
||||
}
|
||||
Ok(Report::new(
|
||||
json!({
|
||||
"path": path.display().to_string(),
|
||||
"core_path": core_path.as_ref().map(|core| core.display().to_string()),
|
||||
"config": document,
|
||||
"redacted": masked,
|
||||
"problems": problems,
|
||||
"data_root": {
|
||||
"mode": ctx.data.mode.as_str(),
|
||||
"launcher_home": ctx.data.launcher_home.display().to_string(),
|
||||
"game_home": ctx.data.game_home.display().to_string(),
|
||||
},
|
||||
}),
|
||||
human,
|
||||
))
|
||||
let mut data = json!({
|
||||
"path": path.display().to_string(),
|
||||
"core_path": core_path.as_ref().map(|core| core.display().to_string()),
|
||||
"config": document,
|
||||
"redacted": masked,
|
||||
"problems": problems,
|
||||
"data_root": {
|
||||
"mode": ctx.data.mode.as_str(),
|
||||
"launcher_home": ctx.data.launcher_home.display().to_string(),
|
||||
"game_home": ctx.data.game_home.display().to_string(),
|
||||
},
|
||||
});
|
||||
if let Some(note) = migration {
|
||||
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>`。
|
||||
@@ -171,6 +191,8 @@ fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
|
||||
///
|
||||
/// 键不在 schema、值不合法、文件只读/不可读、写盘失败时返回用户错误。
|
||||
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 mut store = SettingStore::load(&path, launcher_schema());
|
||||
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 {
|
||||
SaveOutcome::Saved(saved) => {
|
||||
let data = json!({
|
||||
let mut data = json!({
|
||||
"key": key,
|
||||
"old": old,
|
||||
"new": value,
|
||||
@@ -209,13 +231,25 @@ pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> {
|
||||
),
|
||||
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));
|
||||
Ok(Report::new(data, human))
|
||||
}
|
||||
SaveOutcome::Skipped(SkipReason::NotDirty) => Ok(Report::new(
|
||||
json!({ "key": key, "new": value, "saved": null }),
|
||||
vec![format!("{key} 已经是该值,未写盘")],
|
||||
)),
|
||||
SaveOutcome::Skipped(SkipReason::NotDirty) => {
|
||||
let mut human = 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) => {
|
||||
let error = CliError::user(
|
||||
"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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -254,3 +254,67 @@ fn unknown_schema_prefix_warns_and_still_shows_file_values() {
|
||||
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"));
|
||||
}
|
||||
+144
-14
@@ -194,26 +194,73 @@ fn outcome(
|
||||
if let Some(hint) = error.hint() {
|
||||
human.push(format!("提示:{hint}"));
|
||||
}
|
||||
if report.rolled_back {
|
||||
human.push("已整包回滚:目标目录与导入前一致".to_string());
|
||||
} else if let Some(rollback) = &report.rollback {
|
||||
human.push(format!(
|
||||
"回滚未完整:{} 项没回去(详见 --json 的 rollback.failed)",
|
||||
rollback.failure_count()
|
||||
));
|
||||
}
|
||||
human.push(rollback_line(report));
|
||||
}
|
||||
|
||||
Outcome::new(Report { data, human }).with_exit_code(report.status.exit_code())
|
||||
}
|
||||
|
||||
/// 状态的人读名。**失败要按回滚是否干净分叉**:不完整时不能说"已回滚"。
|
||||
/// 状态的人读名。**失败要按「动没动过目标目录」分叉**(见 [`rollback_line`])。
|
||||
fn status_label(report: &ImportReport) -> &'static str {
|
||||
match report.status {
|
||||
ImportStatus::Success => "成功",
|
||||
ImportStatus::Partial => "部分成功",
|
||||
ImportStatus::Failed if report.rolled_back => "失败(已回滚)",
|
||||
ImportStatus::Failed => "失败(回滚不完整)",
|
||||
match rollback_stage(report) {
|
||||
Some(RollbackStage::NotStarted) => "失败(未写入:目标目录没被动过)",
|
||||
Some(RollbackStage::RolledBack) => "失败(已回滚)",
|
||||
Some(RollbackStage::Incomplete) => "失败(回滚不完整)",
|
||||
None => match report.status {
|
||||
ImportStatus::Success => "成功",
|
||||
_ => "部分成功",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 「这次失败到底动没动过目标目录」(三档)。
|
||||
///
|
||||
/// 与 `presenter` 的 `host::addon::pack::convert::failure_stage` **同义**——CLI 不依赖 presenter
|
||||
/// 层(`unlucky-cli` 只依赖 core + agent),所以这套判据在这里各写一份。已登记 finding:
|
||||
/// 更干净的做法是把三档挪进 `core::addon::import::ImportReport`(那儿有 `rollback` /
|
||||
/// `rolled_back` 两个机器位),两处都改成读它。
|
||||
///
|
||||
/// 为什么要三档而不是一个 `rolled_back` 布尔:core 的 `rolled_back` 在「回滚完整完成」与
|
||||
/// **「从未写过目标目录」**两种情形下都是 `true`。只按它出词,预检失败会被说成「已整包回滚:目标
|
||||
/// 目录与导入前一致」——那句话在**一个字节都没写**时是假的(M161 复审的 P1 同族问题)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RollbackStage {
|
||||
/// 预检/装配阶段就失败:目标目录一个字节都没写
|
||||
NotStarted,
|
||||
/// 写过,回滚**完整**完成
|
||||
RolledBack,
|
||||
/// 写过,回滚有失败项(目标目录可能与导入前不同)
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
/// 三档判据(与 presenter 那份逐条对应)。
|
||||
fn rollback_stage(report: &ImportReport) -> Option<RollbackStage> {
|
||||
if report.status != ImportStatus::Failed {
|
||||
return None;
|
||||
}
|
||||
Some(match &report.rollback {
|
||||
Some(outcome) if outcome.is_complete() => RollbackStage::RolledBack,
|
||||
Some(_) => RollbackStage::Incomplete,
|
||||
None if report.rolled_back => RollbackStage::NotStarted,
|
||||
None => RollbackStage::Incomplete,
|
||||
})
|
||||
}
|
||||
|
||||
/// 失败收尾那一段人读文案(**三档三句**,不许共用)。
|
||||
fn rollback_line(report: &ImportReport) -> String {
|
||||
match rollback_stage(report) {
|
||||
Some(RollbackStage::NotStarted) => {
|
||||
"预检未通过:目标目录一个字节都没写(不需要回滚)".to_string()
|
||||
}
|
||||
Some(RollbackStage::RolledBack) => "已整包回滚:目标目录与导入前一致".to_string(),
|
||||
Some(RollbackStage::Incomplete) => match &report.rollback {
|
||||
Some(rollback) => format!(
|
||||
"回滚未完整:{} 项没回去(详见 --json 的 rollback.failed)",
|
||||
rollback.failure_count()
|
||||
),
|
||||
None => "回滚状态不明:目标目录可能与导入前不同(详见 --json)".to_string(),
|
||||
},
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,3 +291,86 @@ fn build_engine(ctx: &Ctx) -> DownloadEngine {
|
||||
Arc::new(ContentCache::open(&cache_root)),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! `pack import` 失败收尾的三档文案(M161 复审「不计入但该记」第一条:CLI 也曾把
|
||||
//! 预检失败说成「已整包回滚」)。
|
||||
//!
|
||||
//! 抓得住:三档塌成两句(预检 → 已回滚);`is_complete()` 被丢掉;`status != Failed` 也出话。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::addon::import::{ImportError, ImportReport, ImportStatus, RollbackOutcome};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn failed() -> ImportReport {
|
||||
let mut report = ImportReport::empty(PathBuf::from("C:/tmp/.minecraft"));
|
||||
report.fail(ImportError::integrity("sha1 mismatch"));
|
||||
report
|
||||
}
|
||||
|
||||
/// 三档三句,**互不相同**(同族的谎就是这个 mission 要消灭的那类)。
|
||||
#[test]
|
||||
fn the_three_rollback_stages_get_three_different_sentences() {
|
||||
// ① 预检没过:一个字节都没写。
|
||||
let mut preflight = failed();
|
||||
preflight.rolled_back = true;
|
||||
assert_eq!(rollback_stage(&preflight), Some(RollbackStage::NotStarted));
|
||||
let not_started = rollback_line(&preflight);
|
||||
assert!(not_started.contains("没写"), "{not_started}");
|
||||
assert!(
|
||||
!not_started.contains("回滚:目标目录"),
|
||||
"预检失败不许说「已回滚」:{not_started}"
|
||||
);
|
||||
assert_eq!(status_label(&preflight), "失败(未写入:目标目录没被动过)");
|
||||
|
||||
// ② 写过又完整回滚。
|
||||
let mut rolled_back = failed();
|
||||
rolled_back.rolled_back = true;
|
||||
rolled_back.rollback = Some(RollbackOutcome {
|
||||
attempted: 2,
|
||||
failed: Vec::new(),
|
||||
});
|
||||
assert_eq!(
|
||||
rollback_stage(&rolled_back),
|
||||
Some(RollbackStage::RolledBack)
|
||||
);
|
||||
assert!(rollback_line(&rolled_back).contains("已整包回滚"));
|
||||
assert_eq!(status_label(&rolled_back), "失败(已回滚)");
|
||||
|
||||
// ③ 回滚没做完。
|
||||
let mut incomplete = failed();
|
||||
incomplete.rolled_back = false;
|
||||
incomplete.rollback = Some(RollbackOutcome {
|
||||
attempted: 2,
|
||||
failed: vec!["mods/a.jar".to_string()],
|
||||
});
|
||||
assert_eq!(rollback_stage(&incomplete), Some(RollbackStage::Incomplete));
|
||||
assert!(rollback_line(&incomplete).contains("1 项没回去"));
|
||||
assert_eq!(status_label(&incomplete), "失败(回滚不完整)");
|
||||
|
||||
// 三句必须互不相同(把任何两档并成一句,这里就红)。
|
||||
let lines = [
|
||||
rollback_line(&preflight),
|
||||
rollback_line(&rolled_back),
|
||||
rollback_line(&incomplete),
|
||||
];
|
||||
assert_eq!(
|
||||
lines.iter().collect::<std::collections::HashSet<_>>().len(),
|
||||
3,
|
||||
"{lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 不是致命失败 ⇒ 没有回滚那一段话(成功/部分成功不该出现「已回滚」)。
|
||||
#[test]
|
||||
fn a_non_fatal_report_says_nothing_about_rollback() {
|
||||
let mut report = ImportReport::empty(PathBuf::from("C:/tmp"));
|
||||
report.status = ImportStatus::Partial;
|
||||
assert_eq!(rollback_stage(&report), None);
|
||||
assert_eq!(rollback_line(&report), "");
|
||||
assert_eq!(status_label(&report), "部分成功");
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,17 @@ impl SettingSchema {
|
||||
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]
|
||||
pub const fn id(&self) -> SchemaId {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! `config/launcher.json` 的键表(全局设置)。
|
||||
//!
|
||||
//! 键名与默认值对齐 `DevDocs/PlanDoc/04-data-and-config.md` §2-§3 与 `05/06/07/08` 各专题;
|
||||
//! AI 可写 + 敏感分级按 `02-ai-agent-design.md` §3.1 的边界落:**密钥类一律 locked**。
|
||||
//! `config/launcher.json` 的键表(全局设置)。键名与默认值对齐 `04-data-and-config.md` §2-§3;
|
||||
//! AI 可写 + 敏感分级按 `02-ai-agent-design.md` §3.1 落:**密钥类一律 locked**。
|
||||
|
||||
use super::catalog::SettingSchema;
|
||||
use super::schema::{SchemaId, SchemaVersion};
|
||||
@@ -36,11 +34,13 @@ pub const INSTANCE_MODES: &[&str] = &["isolated", "shared"];
|
||||
/// `java.installRoot` 未设置时的哨兵(空串 = 按数据目录模式派生落点)。
|
||||
pub const JAVA_INSTALL_ROOT_UNSET: &str = "";
|
||||
/// `java.vendorPriority` 的允许值(D23:四家发行版,顺序即优先级)。
|
||||
///
|
||||
/// 与 `crate::game::java::vendor::VENDOR_IDS` 必须是同一份集合——setting 层不依赖 game 层
|
||||
/// (分层纪律),一致性由 `tests/setting/java_install_keys.rs` 的契约测试固定。
|
||||
/// 与 `game::java::vendor::VENDOR_IDS` 必须同一份集合(setting 层不依赖 game 层),
|
||||
/// 一致性由 `tests/setting/java_install_keys.rs` 的契约测试固定。
|
||||
pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "termux"];
|
||||
|
||||
/// 自更新两键(M161;拆到自己的文件守 400 行)。
|
||||
pub mod update;
|
||||
|
||||
/// launcher.json 当前 schema 版本。
|
||||
///
|
||||
/// `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` 的差异只体现在文件里出现新键之后。
|
||||
/// `1.3.0` 登记 D17 的「外观(高级)」P1 键(`appearance.bg_image*` / `panel_alpha` / `glass` /
|
||||
/// `accent_color`):同一套纯增量语义——出厂全默认(高级特性全关)即等于老文件缺键。
|
||||
/// `1.4.0` 登记 P4 的外观下层三键(`appearance.window_opacity` / `titlebar_mode` /
|
||||
/// `palette_source`,M91 · D36/D37/D38):仍然只推 minor、不加迁移步骤——三者的出厂值
|
||||
/// (不透明 / auto / 内置色卡)正是「老文件没有这些键」时的等价物。
|
||||
/// `1.5.0` 登记 M116 的窗口圆角策略(`appearance.window_corner`,出厂 `system`):
|
||||
/// 同一套纯增量语义——**新读旧**:老文件缺该键 ⇒ 读默认 `system` ⇒ 跟随系统,与写进文件后行为一致。
|
||||
/// `1.4.0` 登记 P4 的外观下层三键(`window_opacity` / `titlebar_mode` / `palette_source`,M91):
|
||||
/// 仍然只推 minor、不加迁移步骤——三者的出厂值正是「老文件没有这些键」时的等价物。
|
||||
/// `1.5.0` 登记 M116 的窗口圆角策略(`appearance.window_corner`,出厂 `system`):纯增量。
|
||||
/// `1.6.0` 登记 M161 的「自更新」两键(见 [`update`]):纯增量但带一步数据迁移
|
||||
/// (此前住在 `cache/update/prefs.json`,迁移见 [`super::update_prefs`])。
|
||||
///
|
||||
/// **旧读新(方向相反,措辞必须精确)**:1.4.0 的客户端读 1.5.0 的文件时,
|
||||
/// [`super::schema::SchemaCompat::check_url`] 走 `found.minor > expected.minor` →
|
||||
@@ -64,7 +64,7 @@ pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "te
|
||||
/// `$schema` 那一行里,不看版本号无从排查。
|
||||
#[must_use]
|
||||
pub fn launcher_version() -> SchemaVersion {
|
||||
SchemaVersion::new(1, 5, 0)
|
||||
SchemaVersion::new(1, 6, 0)
|
||||
}
|
||||
|
||||
/// `config/launcher.json` 的 schema。
|
||||
@@ -395,4 +395,5 @@ pub fn launcher_schema() -> SettingSchema {
|
||||
AiPolicy::audited(),
|
||||
"启动器日志级别",
|
||||
))
|
||||
.with_all(update::specs())
|
||||
}
|
||||
@@ -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;清单里没有的渠道不许写进文件)",
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,8 @@
|
||||
//! - [`store`]:单文件加载降级 + 原子保存 + 坏文件备份;
|
||||
//! - [`save`] / [`save_worker`]:200ms 去抖攒批与后台保存线程;
|
||||
//! - [`migrate`]:`schemaVersion N → N+1` 迁移链 + 收据闸门。
|
||||
//! - [`update_prefs`]:自更新偏好(旧 `cache/update/prefs.json`)→ `launcher.json` 的一次性迁移
|
||||
//! (M161:幂等、源指纹闸门、旧文件退役、收据可查)。
|
||||
|
||||
pub mod appearance;
|
||||
pub mod catalog;
|
||||
@@ -34,3 +36,4 @@ pub mod save_worker;
|
||||
pub mod schema;
|
||||
pub mod spec;
|
||||
pub mod store;
|
||||
pub mod update_prefs;
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -131,8 +131,8 @@ fn appearance_keys_are_all_ai_writable_and_registered() {
|
||||
fn schema_registration_shape_is_right() {
|
||||
assert_eq!(
|
||||
launcher_version().minor,
|
||||
5,
|
||||
"1.5.0 登记 M116 的窗口圆角策略键"
|
||||
6,
|
||||
"1.6.0 登记 M161 的自更新两键(update.auto_check / update.channel)"
|
||||
);
|
||||
let schema = launcher_schema();
|
||||
assert_eq!(
|
||||
|
||||
@@ -128,9 +128,10 @@ fn overrides_key_is_an_enum_whitelist() {
|
||||
|
||||
#[test]
|
||||
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 落点/优先级键)。
|
||||
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!(launcher_schema().id(), SchemaId::LAUNCHER);
|
||||
// major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。
|
||||
|
||||
@@ -66,7 +66,8 @@ fn install_root_accepts_a_path_and_keeps_empty_as_unset() {
|
||||
|
||||
#[test]
|
||||
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!(
|
||||
launcher_version().major,
|
||||
1,
|
||||
|
||||
@@ -19,3 +19,4 @@ mod redirect;
|
||||
mod schema_compat;
|
||||
mod spec_ai;
|
||||
mod store;
|
||||
mod update_keys;
|
||||
@@ -0,0 +1,337 @@
|
||||
//! 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");
|
||||
}
|
||||
|
||||
/// **源指纹闸门**:旧文件还在原地(改名失败 / 用户从备份捞回来)但内容没变 ⇒ 一次都不再迁。
|
||||
///
|
||||
/// 复审 P2-3:把 `update_prefs.rs` 里那道闸门整段删掉,113 条 setting 用例全绿——因为
|
||||
/// `running_twice_is_idempotent` 的第二遍之所以空操作,是第一遍已经把文件改名成 `.migrated`,
|
||||
/// **根本没走到闸门**。这条用例是唯一真正撞它的:先按旧文件字节写好收据,再让 `prefs.json`
|
||||
/// 原地留着调 `migrate`。
|
||||
///
|
||||
/// 抓得住:①闸门被删(会走「新值胜出」那一支并**重写收据**,`applied_at_ms` 变);②指纹比对
|
||||
/// 写成「有收据就跳过」而不是「指纹相同才跳过」(下面第二条反证);③闸门放行时忘了退役旧文件。
|
||||
#[test]
|
||||
fn the_receipt_gate_skips_a_replayed_source_byte_for_byte() {
|
||||
let temp = TempDir::new("m161-gate-replay");
|
||||
let legacy = temp.write("cache/update/prefs.json", r#"{"auto_check": false}"#);
|
||||
let receipt_path = temp.child(&format!("config/{RECEIPT_FILE}"));
|
||||
|
||||
// 先按真实字节写一张收据(模拟「上一轮迁过、但旧文件又回到了原地」)。
|
||||
let bytes = std::fs::read(&legacy).expect("读旧文件");
|
||||
let receipt = unlucky_core::platform::receipt::MigrationReceipt::legacy_config(
|
||||
legacy.clone(),
|
||||
temp.child("config/launcher.json"),
|
||||
unlucky_core::platform::receipt::fingerprint_bytes(&bytes),
|
||||
1_600_000_000_000,
|
||||
);
|
||||
unlucky_core::platform::receipt::write(&receipt_path, &receipt).expect("写收据");
|
||||
let receipt_before = temp.read(&format!("config/{RECEIPT_FILE}"));
|
||||
|
||||
let outcome = run(&temp);
|
||||
assert_eq!(
|
||||
outcome.as_str(),
|
||||
"nothing",
|
||||
"源指纹相同 ⇒ 必须走闸门(Nothing),实际 {outcome:?}"
|
||||
);
|
||||
// 关键断言:**收据没被重写**(重写 = 走了迁移分支,闸门失效)。
|
||||
assert_eq!(
|
||||
temp.read(&format!("config/{RECEIPT_FILE}")),
|
||||
receipt_before,
|
||||
"收据被重写了 ⇒ 闸门没拦住重放"
|
||||
);
|
||||
// 主配置一个字都没写(闸门那一支只退役旧文件)。
|
||||
assert!(!temp.exists("config/launcher.json"), "闸门放行时不该建配置");
|
||||
// 旧文件仍然退役(它是真相源之外的东西,留着只会下次再撞一次)。
|
||||
assert!(!temp.exists("cache/update/prefs.json"));
|
||||
|
||||
// 反证:源文件**变了**(false → true)⇒ 指纹不同 ⇒ 允许重放,收据被更新。
|
||||
temp.write("cache/update/prefs.json", r#"{"auto_check": true}"#);
|
||||
let outcome = run(&temp);
|
||||
assert_eq!(
|
||||
outcome.as_str(),
|
||||
"migrated",
|
||||
"源内容变了就该重放,实际 {outcome:?}"
|
||||
);
|
||||
assert_ne!(
|
||||
temp.read(&format!("config/{RECEIPT_FILE}")),
|
||||
receipt_before,
|
||||
"重放要写新收据(指纹变了)"
|
||||
);
|
||||
}
|
||||
|
||||
/// **`Refused`:主配置不可写时不动旧文件**(唯一有用户数据风险的分支)。
|
||||
///
|
||||
/// 造法:主配置的 `$schema` 写成**更高的 minor**(1.7.0 > 1.6.0)⇒ `SchemaCompat` 判
|
||||
/// `ReadOnlyPreserveSchema` ⇒ `FileAccess::ReadOnly` ⇒ `can_save()` false。
|
||||
/// 抓得住:把「写不进去」当成「迁完了」(旧文件被改名,用户的值两边都没有了)。
|
||||
#[test]
|
||||
fn a_read_only_launcher_config_never_retires_the_legacy_file() {
|
||||
let temp = TempDir::new("m161-refused");
|
||||
temp.write(
|
||||
"config/launcher.json",
|
||||
&json!({
|
||||
"$schema": "https://schemas.unlucky-launcher.dev/ull/launcher/1.7.0",
|
||||
"schemaVersion": 1
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
temp.write("cache/update/prefs.json", r#"{"auto_check": false}"#);
|
||||
|
||||
let outcome = run(&temp);
|
||||
let Migration::Refused { reason } = &outcome else {
|
||||
panic!("只读主配置必须走 Refused,实际 {outcome:?}");
|
||||
};
|
||||
// 拒绝的理由必须是「先看能不能写」这一条(而不是写到一半才发现 `set` 被拒):
|
||||
// 抓得住把 `can_save()` 守卫删掉这种变异——那条路径的 reason 是「写 update.auto_check 被拒」。
|
||||
assert!(
|
||||
reason.contains("不可写"),
|
||||
"应当在动手前就判定主配置不可写,实际理由:{reason}"
|
||||
);
|
||||
// 旧文件原样留着(下一次还能再试)。
|
||||
assert!(
|
||||
temp.exists("cache/update/prefs.json"),
|
||||
"写不进去时**不许**退役旧文件——用户的值不能两边都没有"
|
||||
);
|
||||
assert!(
|
||||
!temp.exists("cache/update/prefs.json.migrated"),
|
||||
"不许留下「已迁移」的痕迹"
|
||||
);
|
||||
// 不写收据(没迁成就不该有收据)。
|
||||
assert!(!temp.exists(&format!("config/{RECEIPT_FILE}")));
|
||||
// 主配置一个字都没改(还是那份 1.7.0)。
|
||||
let text = temp.read("config/launcher.json");
|
||||
assert!(text.contains("1.7.0"), "{text}");
|
||||
assert!(!text.contains("auto_check"), "只读文件不许被写:{text}");
|
||||
}
|
||||
|
||||
/// 真 `$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()
|
||||
}
|
||||
@@ -292,6 +292,26 @@ macro_rules! content_entries {
|
||||
"路径过不了包内规整,已跳过"
|
||||
),
|
||||
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",
|
||||
"回滚没做完 —— 目标目录可能与导入前不同"
|
||||
),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
||||
("InstancesIsolationShared", "Shared", "共用目录"),
|
||||
("InstancesRunning", "Running · pid {pid}", "运行中 · pid {pid}"),
|
||||
("InstancesFailed", "Launch failed · {reason}", "启动失败 · {reason}"),
|
||||
("InstancesCrashed", "the game crashed (exit {code})", "游戏崩溃(退出码 {code})"),
|
||||
("InstancesSilentFailed", "exit 0, but never started", "退出码 0,没起来"),
|
||||
("InstancesStageDownload", "Downloading", "下载中"),
|
||||
("InstancesStageVerify", "Verifying", "校验中"),
|
||||
("InstancesStageLaunch", "Starting", "启动中"),
|
||||
@@ -59,17 +61,21 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
||||
("InstancesLaunchReset", "Reset to global", "恢复跟随全局"),
|
||||
];
|
||||
|
||||
/// 金表的**全量**行(前半段在本文件、后半段在 [`tail`],顺序即 `Key::ALL`)。
|
||||
/// 金表的**全量**行(三段:本文件 + [`tail`] + [`tail2`],顺序即 `Key::ALL`)。
|
||||
///
|
||||
/// 拆成两段只为守 400 行硬线(M157 加了 19 行启动参数文案后越线);对调用方是**一条**序列,
|
||||
/// `catalog/tests.rs` 的逐字节断言不用关心分段。
|
||||
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` 的条数)。
|
||||
pub(super) fn len() -> usize {
|
||||
GOLDEN.len() + tail::ROWS.len()
|
||||
GOLDEN.len() + tail::ROWS.len() + tail2::ROWS.len()
|
||||
}
|
||||
|
||||
mod tail;
|
||||
mod tail2;
|
||||
@@ -3,13 +3,20 @@
|
||||
//! 为什么拆:`golden.rs` 在 M157 加了 19 行启动参数文案后到 416 行,越过 400 行硬线;
|
||||
//! 拆分口径与仓库其它 `-p1/-p2` 一致——**纯搬移、一行不改**,顺序即 `Key::ALL` 的后半段。
|
||||
//! 拼装与断言在 `golden.rs` 的 [`rows`] 与 `catalog/tests.rs`。
|
||||
|
||||
pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("LoaderVanilla", "Vanilla", "原版"),
|
||||
("LaunchOutcomeNormal", "Finished normally", "正常结束"),
|
||||
("LaunchOutcomeCrash", "The game crashed", "游戏崩溃"),
|
||||
("LaunchOutcomeSilent", "Suspected silent failure: exit code 0, but the game never really started", "疑似静默失败:退出码 0,但游戏没真正跑起来"),
|
||||
("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>。"),
|
||||
("FontCjkNeedsDownload", "No Chinese font found on this system. Download an open-source (OFL) Chinese font?", "系统里没找到中文字体。要下载一套开源(OFL)中文字体吗?"),
|
||||
("FontCjkDownloading", "Downloading Chinese font… {percent}%", "正在下载中文字体… {percent}%"),
|
||||
@@ -112,8 +119,6 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("AgentSubtitleReal", "Conversation", "会话"),
|
||||
("AgentPlaceholderReal", "Message the assistant…", "给助手发消息…"),
|
||||
("AgentEmptyBodyReal", "Ask about instances, downloads or logs.", "可以问实例、下载或日志。"),
|
||||
// ── M106 `I18NFOLLOW`:General 分组真控件(语言选择器 + 其余三行)。**只追加在链尾**,
|
||||
// 上一行到 `LogsEmptyAction` 的既有顺序一字未动(金表要证明的正是这件事)。
|
||||
("SettingsLanguageFollowSystem", "Follow system", "跟随系统"),
|
||||
("SettingsLanguageZhCn", "Chinese (Simplified)", "简体中文"),
|
||||
("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 参数,其余实例沿用上面的默认值。"),
|
||||
("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 的骨架回退。"),
|
||||
// --- M107 版本管理页(追加在链尾:`Key::ALL` 顺序 = 金表顺序) ---
|
||||
("VersionRailTitle", "Version type", "版本类型"),
|
||||
("VersionMajorTitle", "Major version", "主版本"),
|
||||
("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。"),
|
||||
("VersionSearchElsewhere", "{count} versions match, but they are in another type.", "有 {count} 个版本匹配,但它们在别的类型里。"),
|
||||
("VersionSearchElsewhereAction", "Show all types", "看全部类型"),
|
||||
// --- M107 r2(追加在末尾:`Key::ALL` 顺序 = 金表顺序) ---
|
||||
("VersionSearchHitsOne", "1 match", "1 个匹配"),
|
||||
("VersionSearchElsewhereTitle", "Found in another type", "在别的类型里找到了"),
|
||||
("VersionSearchElsewhereOne", "1 version matches, but it is in another type.", "有 1 个版本匹配,但它在别的类型里。"),
|
||||
// --- M109 内容库页(同样追加在链尾:`Key::ALL` 顺序 = 金表顺序) ---
|
||||
("ContentTitle", "Content", "内容库"),
|
||||
("ContentSubtitle", "Modpacks, mods, resource packs and shaders", "整合包、Mod、资源包与光影"),
|
||||
("ContentSectionVersions", "Version management", "版本管理"),
|
||||
@@ -206,206 +208,4 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("PackDismiss", "Dismiss", "关闭"),
|
||||
("PackTargetLabel", "Install into", "装到实例"),
|
||||
("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", "重试"),
|
||||
];
|
||||
@@ -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", "重试"),
|
||||
];
|
||||
@@ -73,7 +73,13 @@ macro_rules! instances_entries {
|
||||
InstancesIsolationShared => ("Shared", "共用目录"),
|
||||
// ── 运行状态与启动阶段(M120;数字与分隔符不随语言变形)
|
||||
InstancesRunning => ("Running · pid {pid}", "运行中 · pid {pid}"),
|
||||
// 失败状态行的**框架**:`{reason}` 是「哪一步」或「结局」的短句
|
||||
// (M161 复审 P1-1:「起来了又崩」与「没起来」必须能说出不同的话)。
|
||||
InstancesFailed => ("Launch failed · {reason}", "启动失败 · {reason}"),
|
||||
InstancesCrashed => ("the game crashed (exit {code})", "游戏崩溃(退出码 {code})"),
|
||||
// 单行列表行也要放得下(`Launch failed · ` + 这一句 ≤ 行宽),
|
||||
// 「exit 0 却没起来」这一条信息必须留(复审 P1-1 第 ③ 条)。
|
||||
InstancesSilentFailed => ("exit 0, but never started", "退出码 0,没起来"),
|
||||
InstancesStageDownload => ("Downloading", "下载中"),
|
||||
InstancesStageVerify => ("Verifying", "校验中"),
|
||||
InstancesStageLaunch => ("Starting", "启动中"),
|
||||
|
||||
@@ -14,6 +14,17 @@ macro_rules! launch_entries {
|
||||
"疑似静默失败:退出码 0,但游戏没真正跑起来"
|
||||
),
|
||||
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 没配是**配置缺失**,不是「本期未支持」。这句话是那条
|
||||
// 启动失败文案的**双语口径**(真源在 `core::auth::microsoft` 的
|
||||
// `MISSING_CLIENT_ID_MESSAGE`,中文逐字一致;英文这条给 En 界面用)。
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::dto::{
|
||||
InstanceSummary,
|
||||
JavaPageView,
|
||||
LaunchArgsPatch,
|
||||
LaunchFailureView,
|
||||
LoaderKind,
|
||||
LogLine,
|
||||
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)]
|
||||
pub struct LaunchFinished {
|
||||
/// 实例 id
|
||||
@@ -62,8 +68,13 @@ pub struct LaunchFinished {
|
||||
pub session: SessionId,
|
||||
/// 结局
|
||||
pub outcome: LogOutcome,
|
||||
/// 一句人读结论(`正常退出` / `崩溃(exit 1)` / `启动失败:…`)
|
||||
pub summary: String,
|
||||
/// 进程退出码(`None` = 进程没起来 / 平台没给)
|
||||
pub exit_code: Option<i32>,
|
||||
/// 从 spawn 到收尾的毫秒数(没起来时是 0)
|
||||
pub uptime_ms: u64,
|
||||
/// 失败发生在哪一步(`None` = 这一场没有装配/启动阶段的失败;
|
||||
/// 「跑起来又崩了」看 `outcome`)
|
||||
pub failure: Option<LaunchFailureView>,
|
||||
}
|
||||
|
||||
/// core 侧事件(不可变快照发布 + 事件流,01c §5 的三条通道之二)。
|
||||
@@ -248,9 +259,17 @@ pub enum AppCommand {
|
||||
CheckUpdate,
|
||||
/// 把当前候选下载下来、校验、替换本程序(结果经 [`CoreEvent::Update`] 回来)
|
||||
ApplyUpdate,
|
||||
/// 忽略这个版本(本次会话不再弹提示条;候选仍留在快照里,设置页可手动装)
|
||||
/// 忽略**这一个候选版本**(记的是版本号,不是「别再问我」)。
|
||||
///
|
||||
/// 语义三条(`UpdateView::skipped` 的契约,`dto::update` 的模块文档与用例守着):
|
||||
/// 同一次运行里同一个版本不再弹提示条;**更高的版本照弹**(0.2.3 被忽略不影响 0.2.4);
|
||||
/// 设置页的「立即检查」会清掉它。候选本身仍留在快照里,设置页可手动装。
|
||||
DismissUpdate,
|
||||
/// 开关「启动时静默检查」(写进更新域自己的偏好文件)
|
||||
/// 开关「启动时静默检查」。
|
||||
///
|
||||
/// 落点是主配置 `config/launcher.json` 的 `update.auto_check`(M161 从更新域自己的
|
||||
/// `cache/update/prefs.json` 迁过来的 schema 键)——`ull config get update.auto_check`
|
||||
/// 读到的是同一个值。
|
||||
SetUpdateAutoCheck(bool),
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use super::launch_failure::{LaunchFailureCause, LaunchFailureStage, LaunchFailureView};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::game::LoaderKind as CoreLoaderKind;
|
||||
@@ -208,9 +210,14 @@ pub enum InstanceRuntime {
|
||||
pid: u32,
|
||||
},
|
||||
/// 上次启动失败(磁贴显示「重试」)
|
||||
///
|
||||
/// M161 之前这里只有一个 `reason: String`,而且里面已经写着「启动失败:…」——
|
||||
/// 界面再包一层就成了「英文前缀 + 中文句子」,中文界面则是「启动失败 · 启动失败:…」。
|
||||
/// 现在给的是结构化原因(见 [`LaunchFailureCause`]):**没起来**与**起来又崩**是两种形状,
|
||||
/// 界面各说各的话(复审 P1-1:早先崩溃被说成「启动游戏进程失败」)。
|
||||
Failed {
|
||||
/// 失败原因(用户可见文案)
|
||||
reason: String,
|
||||
/// 这一场为什么是失败态
|
||||
cause: LaunchFailureCause,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! 启动失败的**结构**(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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 磁贴/详情上「这一场为什么是失败态」的**完整原因**。
|
||||
///
|
||||
/// 两种**互斥**的形状,混成一种就会说假话(M161 复审 P1-1 抓的就是这个):
|
||||
///
|
||||
/// | 形状 | 意思 | 界面该说什么 |
|
||||
/// |---|---|---|
|
||||
/// | [`Self::Stage`] | 进程**没起来**(装配/启动阶段失败) | 「启动失败 · <哪一步>:<原文>」 |
|
||||
/// | [`Self::Crashed`] | 进程起来过,**崩了** | 「启动失败 · 游戏崩溃(退出码 N)」 |
|
||||
/// | [`Self::SilentFailure`] | 进程起来过,但 exit 0 却没真正跑起来 | 「启动失败 · 退出码 0,但游戏没起来」 |
|
||||
///
|
||||
/// 为什么把结局也放在这里:磁贴的状态行与 Toast 必须说同一件事。早先「起来了又崩」被
|
||||
/// 硬塞进 [`LaunchFailureStage::Spawn`](因为 `LaunchFinished.failure` 在这次是 `None`),
|
||||
/// 于是磁贴写「启动游戏进程失败」而 Toast 写「游戏崩溃」——同一时刻两句话打架,而且
|
||||
/// `detail` 还漏出了 `exit Some(1)` 这种 Rust Debug 文本。
|
||||
///
|
||||
/// `detail` 只在 [`Self::Stage`] 里有:那是 core / io 给的原文(路径、版本 id、os error),
|
||||
/// **故意不翻译**。结局类没有什么「原文」可给,退出码是数字,措辞由界面按语言出。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LaunchFailureCause {
|
||||
/// 装配/启动阶段失败:进程**没起来**
|
||||
Stage {
|
||||
/// 失败在上面的哪一步
|
||||
stage: LaunchFailureStage,
|
||||
/// 那一步的原始错误文本(不翻译)
|
||||
detail: String,
|
||||
},
|
||||
/// 进程起来过,结局是崩溃
|
||||
Crashed {
|
||||
/// 退出码(`None` = 平台没给)
|
||||
exit_code: Option<i32>,
|
||||
},
|
||||
/// 进程起来过,但疑似静默失败(exit 0 却没真正跑起来)
|
||||
SilentFailure {
|
||||
/// 退出码(通常是 0)
|
||||
exit_code: Option<i32>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LaunchFailureCause {
|
||||
/// 稳定 slug(日志 / 测试用;**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn slug(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Stage { .. } => "stage",
|
||||
Self::Crashed { .. } => "crashed",
|
||||
Self::SilentFailure { .. } => "silent_failure",
|
||||
}
|
||||
}
|
||||
|
||||
/// 进程到底有没有起来过。
|
||||
#[must_use]
|
||||
pub const fn started(&self) -> bool {
|
||||
!matches!(self, Self::Stage { .. })
|
||||
}
|
||||
|
||||
/// 由一次收尾组装原因。
|
||||
///
|
||||
/// `failure` 有值 = 装配/启动阶段失败(进程没起来);`None` = 跑起来了,此时**只能**
|
||||
/// 看 `outcome`(`Ok` 不该走到这里,兜底按崩溃处理)。
|
||||
#[must_use]
|
||||
pub fn of(
|
||||
outcome: crate::dto::LogOutcome,
|
||||
failure: Option<&LaunchFailureView>,
|
||||
exit_code: Option<i32>,
|
||||
) -> Self {
|
||||
match failure {
|
||||
Some(failure) => Self::Stage {
|
||||
stage: failure.stage,
|
||||
detail: failure.detail.clone(),
|
||||
},
|
||||
None if outcome == crate::dto::LogOutcome::SilentFail => {
|
||||
Self::SilentFailure { exit_code }
|
||||
}
|
||||
None => Self::Crashed { exit_code },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ mod instances;
|
||||
mod java;
|
||||
// M157: 实例启动参数(启动参数区的读取视图 + 写回补丁)
|
||||
mod launch_args;
|
||||
mod launch_failure;
|
||||
mod logs;
|
||||
mod notices;
|
||||
// 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 install::{derive_speed, human_bytes, InstallProgressView, InstallStage};
|
||||
pub use instances::{
|
||||
CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo,
|
||||
LoaderKind, Progress,
|
||||
CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchFailureCause,
|
||||
LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, Progress,
|
||||
};
|
||||
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
|
||||
pub use launch_args::{LaunchArgsPatch, LaunchArgsView};
|
||||
@@ -57,10 +58,10 @@ pub use pack::{
|
||||
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView,
|
||||
ConflictReasonView, ConflictSourceView, ExportClassView, ExportDoneView, ExportFileView,
|
||||
ExportPreviewView, ExportReasonView, ExportRequestView, ExportState, FileActionView,
|
||||
ImportConflictView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView,
|
||||
ImportScanView, ImportStageView, ImportState, ImportStatusView, ImportedFileView,
|
||||
MissingFileView, MissingReasonView, PackCommand, PackEvent, PackFormatView, PackState,
|
||||
ProbeView,
|
||||
ImportConflictView, ImportErrorView, ImportFailureStageView, ImportProgressView,
|
||||
ImportReportView, ImportRequestView, ImportScanView, ImportStageView, ImportState,
|
||||
ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView, PackCommand, PackEvent,
|
||||
PackFormatView, PackState, ProbeView,
|
||||
};
|
||||
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
|
||||
// M159: 自更新
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
//! 1. **三态**([`ImportStatusView`]):`Partial` 不是 `Success`。把「缺了 3 个文件」说成成功,
|
||||
//! 用户会在启动后才发现缺依赖;
|
||||
//! 2. **容忍 / 致命的分野**:[`MissingFileView`] 是「搞不到某个文件」(实例可用),
|
||||
//! [`ImportErrorView`] 是「已整包回滚」(实例零残留)。UI 必须让这两类**视觉可区分**;
|
||||
//! [`ImportErrorView`] 是致命失败。UI 必须让这两类**视觉可区分**——并且致命那一类还要按
|
||||
//! [`ImportReportView::failure_stage`] 再分三档说(预检没过 / 已回滚 / 回滚没做完,M161);
|
||||
//! 3. **导出的三分类**([`ExportClassView`]):`Inline` 是「证明不了可被远程替代,原字节进
|
||||
//! `overrides/`」,`Skipped` 是「压根没进包」。`skipped > 0` 意味着包**不完整**。
|
||||
//!
|
||||
@@ -56,7 +57,7 @@ pub use export::{
|
||||
};
|
||||
pub use format::PackFormatView;
|
||||
pub use import::{
|
||||
FileActionView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView,
|
||||
ImportStageView, ImportState, ImportStatusView, ImportedFileView, MissingFileView,
|
||||
MissingReasonView, ProbeView,
|
||||
FileActionView, ImportErrorView, ImportFailureStageView, ImportProgressView, ImportReportView,
|
||||
ImportRequestView, ImportStageView, ImportState, ImportStatusView, ImportedFileView,
|
||||
MissingFileView, MissingReasonView, ProbeView,
|
||||
};
|
||||
@@ -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`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ImportStatusView {
|
||||
@@ -69,7 +106,11 @@ pub enum ImportStatusView {
|
||||
Success,
|
||||
/// 有缺文件或有冲突(内容不完整,但实例可用)。
|
||||
Partial,
|
||||
/// 致命失败,**已整包回滚**。
|
||||
/// 致命失败。
|
||||
///
|
||||
/// **「动没动过目标目录」不在这里**(M161):同一个 `Failed` 既可能是预检没过(一个字节都没写),
|
||||
/// 也可能是写完又回滚干净,还可能是回滚没做完——三种由
|
||||
/// [`ImportReportView::failure_stage`] 分开说,界面按它选标题与图标。
|
||||
Failed,
|
||||
}
|
||||
|
||||
@@ -243,7 +284,7 @@ pub struct MissingFileView {
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 一次**已整包回滚**的致命失败。
|
||||
/// 一次致命失败(是不是「已整包回滚」看 [`ImportReportView::failure_stage`])。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportErrorView {
|
||||
/// 稳定码(`pack_traversal` / `import_integrity` …,进日志与 `--json`)。
|
||||
@@ -292,7 +333,13 @@ pub struct ImportReportView {
|
||||
/// 本次写入的字节数。
|
||||
pub bytes_written: u64,
|
||||
/// 回滚是否完整(`false` = 目标目录可能与导入前不一致,逐条在 `warnings` 里)。
|
||||
///
|
||||
/// **注意**:`true` 有两种来源——「回滚完整完成」与「从未写过目标目录」(core 的
|
||||
/// `abandoned`)。要区分它们看 [`ImportReportView::failure_stage`];界面上的措辞
|
||||
/// **必须**按那个字段选(M161)。
|
||||
pub rolled_back: bool,
|
||||
/// 失败发生在哪一步(`None` = 这次不是致命失败)。
|
||||
pub failure_stage: Option<ImportFailureStageView>,
|
||||
}
|
||||
|
||||
impl ImportReportView {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::addon::export::{ExportReport, FileClass, FileReason};
|
||||
use unlucky_core::addon::import::report::ImportStatus;
|
||||
use unlucky_core::addon::import::{
|
||||
ConflictChoice, ConflictDecisions, ConflictFile, ConflictReason, FileAction, FileSource,
|
||||
ImportError, ImportProgress, ImportReport, ImportScan, LoaderTarget, MissingReason,
|
||||
@@ -19,9 +20,9 @@ use unlucky_core::addon::import::{
|
||||
use crate::dto::{
|
||||
ConflictChoiceView, ConflictDecisionsView, ConflictFileView, ConflictReasonView,
|
||||
ConflictSourceView, ExportClassView, ExportDoneView, ExportReasonView, FileActionView,
|
||||
ImportConflictView, ImportErrorView, ImportProgressView, ImportReportView, ImportScanView,
|
||||
ImportStageView, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView,
|
||||
PackFormatView,
|
||||
ImportConflictView, ImportErrorView, ImportFailureStageView, ImportProgressView,
|
||||
ImportReportView, ImportScanView, ImportStageView, ImportStatusView, ImportedFileView,
|
||||
MissingFileView, MissingReasonView, PackFormatView,
|
||||
};
|
||||
|
||||
/// 一次导入**预检** → DTO(M160:冲突清单)。
|
||||
@@ -150,9 +151,29 @@ pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportV
|
||||
.map(|registered| registered.name.clone()),
|
||||
bytes_written: report.bytes_written,
|
||||
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。
|
||||
pub(super) fn done_view(report: &ExportReport) -> ExportDoneView {
|
||||
ExportDoneView {
|
||||
@@ -296,6 +317,8 @@ fn failed_report_with(target: PathBuf, pack: &Path, error: ImportErrorView) -> I
|
||||
registered: None,
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
// 预检/装配阶段就失败了:目标目录一个字节都没写(不是「写完又回滚」)。
|
||||
failure_stage: Some(ImportFailureStageView::NotStarted),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,3 +375,7 @@ pub(super) fn slug(text: &str) -> String {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "convert/stage_tests.rs"]
|
||||
mod stage_tests;
|
||||
@@ -0,0 +1,108 @@
|
||||
//! M161 复审 P2-1:`failure_stage` 是⑥三档的**唯一**判定点,此前零覆盖(把它改成恒
|
||||
//! `RolledBack`,presenter 254 条全绿)。这里直接喂 core 的机器位,断言的是**判定**,
|
||||
//! 不是渲染(渲染在 app-iced 的 `packui/model/tests/stage.rs`)。
|
||||
//!
|
||||
//! 抓得住:`None if report.rolled_back` 被写反(预检失败 → 「已回滚」,正是⑥要消灭的谎);
|
||||
//! `is_complete()` 被丢掉(回滚没做完也说成零残留);`status != Failed` 也发阶段。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::addon::import::{ImportError, ImportReport, ImportStatus, RollbackOutcome};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 一份「失败」的空报告(还没决定回滚结果)。
|
||||
fn failed() -> ImportReport {
|
||||
let mut report = ImportReport::empty(PathBuf::from("C:/tmp/.minecraft"));
|
||||
report.fail(ImportError::integrity("sha1 mismatch"));
|
||||
report
|
||||
}
|
||||
|
||||
/// 预检就失败(**从未写过目标目录**)⇒ `NotStarted`。
|
||||
///
|
||||
/// core 的 `abandoned()` 就是这一形:`rollback == None` + `rolled_back == true`。
|
||||
#[test]
|
||||
fn preflight_failure_is_not_started() {
|
||||
let mut report = failed();
|
||||
report.rolled_back = true;
|
||||
assert_eq!(
|
||||
failure_stage(&report),
|
||||
Some(ImportFailureStageView::NotStarted),
|
||||
"没写过目标目录就是「根本没开始」,不许说成「已回滚」"
|
||||
);
|
||||
assert_eq!(failure_stage(&report).unwrap().slug(), "not_started");
|
||||
// 渲染层同一份判据(`failed_report_with` 走的是同一条)。
|
||||
assert_eq!(
|
||||
failed_report_with(
|
||||
PathBuf::from("C:/tmp"),
|
||||
std::path::Path::new("Better MC.mrpack"),
|
||||
ImportErrorView {
|
||||
code: "pack_traversal".to_string(),
|
||||
label: "Path traversal".to_string(),
|
||||
message: "../etc/passwd".to_string(),
|
||||
hint: None,
|
||||
},
|
||||
)
|
||||
.failure_stage,
|
||||
Some(ImportFailureStageView::NotStarted),
|
||||
"宿主侧失败(目标实例不可用)也是「没开始写」,不是「已回滚」"
|
||||
);
|
||||
}
|
||||
|
||||
/// 跑过回滚且完整 ⇒ `RolledBack`。
|
||||
#[test]
|
||||
fn a_complete_rollback_is_rolled_back() {
|
||||
let mut report = failed();
|
||||
report.rolled_back = true;
|
||||
report.rollback = Some(RollbackOutcome {
|
||||
attempted: 3,
|
||||
failed: Vec::new(),
|
||||
});
|
||||
assert_eq!(
|
||||
failure_stage(&report),
|
||||
Some(ImportFailureStageView::RolledBack)
|
||||
);
|
||||
assert_eq!(failure_stage(&report).unwrap().slug(), "rolled_back");
|
||||
}
|
||||
|
||||
/// 跑过回滚但有失败项 ⇒ `RollbackIncomplete`(目标目录可能与导入前不同)。
|
||||
#[test]
|
||||
fn a_partial_rollback_is_incomplete() {
|
||||
let mut report = failed();
|
||||
report.rolled_back = false;
|
||||
report.rollback = Some(RollbackOutcome {
|
||||
attempted: 3,
|
||||
failed: vec!["mods/a.jar: 占用中".to_string()],
|
||||
});
|
||||
assert_eq!(
|
||||
failure_stage(&report),
|
||||
Some(ImportFailureStageView::RollbackIncomplete)
|
||||
);
|
||||
assert_eq!(
|
||||
failure_stage(&report).unwrap().slug(),
|
||||
"rollback_incomplete"
|
||||
);
|
||||
}
|
||||
|
||||
/// 既没回滚也没宣称干净(core 的 `failed()` 单独一支)⇒ 只能如实说「可能不一致」。
|
||||
#[test]
|
||||
fn a_failure_without_any_claim_is_incomplete_not_not_started() {
|
||||
let mut report = failed();
|
||||
report.rolled_back = false;
|
||||
report.rollback = None;
|
||||
assert_eq!(
|
||||
failure_stage(&report),
|
||||
Some(ImportFailureStageView::RollbackIncomplete),
|
||||
"没跑回滚也不宣称干净:不许说「没开始写」"
|
||||
);
|
||||
}
|
||||
|
||||
/// 不是致命失败 ⇒ 没有「失败阶段」可言(成功/部分成功都不该发这一段)。
|
||||
#[test]
|
||||
fn a_non_fatal_report_has_no_stage() {
|
||||
let mut report = ImportReport::empty(PathBuf::from("C:/tmp"));
|
||||
report.rolled_back = false;
|
||||
assert_eq!(failure_stage(&report), None, "Success 不该有失败阶段");
|
||||
report.status = ImportStatus::Partial;
|
||||
assert_eq!(failure_stage(&report), None, "Partial 不该有失败阶段");
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。
|
||||
|
||||
use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LogOutcome, PackEvent};
|
||||
use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LaunchFailureCause, LogOutcome, PackEvent};
|
||||
use crate::toast::ToastKind;
|
||||
|
||||
use super::{addon, logs, CoreHost, HostTask, LiveLaunch};
|
||||
@@ -160,13 +160,19 @@ impl CoreHost {
|
||||
self.failed.remove(&finished.id);
|
||||
InstanceRuntime::Idle
|
||||
} else {
|
||||
// 记住失败原因:随后的 refresh_instances 会重建快照,
|
||||
// 记住失败:随后的 refresh_instances 会重建快照,
|
||||
// 不记的话磁贴的「失败/重试」外观会被 Idle 盖掉。
|
||||
self.failed
|
||||
.insert(finished.id.clone(), finished.summary.clone());
|
||||
InstanceRuntime::Failed {
|
||||
reason: finished.summary.clone(),
|
||||
}
|
||||
//
|
||||
// 原因**按形状**组装(复审 P1-1):`failure` 有值 = 进程没起来(阶段失败);
|
||||
// 没值 = 跑起来了,那就必须看 `outcome`(崩溃 / 疑似静默失败),
|
||||
// 不许再拿「启动游戏进程失败」去顶——那句话在进程明明起来时说不过去。
|
||||
let cause = LaunchFailureCause::of(
|
||||
finished.outcome,
|
||||
finished.failure.as_ref(),
|
||||
finished.exit_code,
|
||||
);
|
||||
self.failed.insert(finished.id.clone(), cause.clone());
|
||||
InstanceRuntime::Failed { cause }
|
||||
};
|
||||
self.send(CoreEvent::InstanceRuntimeChanged {
|
||||
id: finished.id.clone(),
|
||||
|
||||
@@ -11,7 +11,7 @@ use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::game::ProfileId;
|
||||
use unlucky_core::launch::request::AuthPlaceholders;
|
||||
|
||||
use crate::dto::LogOutcome;
|
||||
use crate::dto::{LaunchFailureStage, LaunchFailureView, LogOutcome};
|
||||
|
||||
use super::env::HostEnv;
|
||||
|
||||
@@ -22,13 +22,16 @@ use super::env::HostEnv;
|
||||
/// # 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 chosen = match account {
|
||||
Some(key) => store
|
||||
.find(key)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("找不到账户 `{key}`"))?,
|
||||
.ok_or_else(|| identity_failure(format!("找不到账户 `{key}`")))?,
|
||||
None => match store.accounts() {
|
||||
[] => {
|
||||
let uuid = ProfileId::generate().as_str().replace('-', "");
|
||||
@@ -44,27 +47,32 @@ pub fn resolve(env: &HostEnv, account: Option<&str>) -> Result<(AuthPlaceholders
|
||||
));
|
||||
}
|
||||
[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());
|
||||
match chosen.kind {
|
||||
AccountKind::Offline => {
|
||||
let placeholders =
|
||||
offline_login(&chosen).map_err(|error| format!("离线账户解析失败:{error}"))?;
|
||||
let placeholders = offline_login(&chosen)
|
||||
.map_err(|error| identity_failure(format!("离线账户解析失败:{error}")))?;
|
||||
Ok((placeholders, line))
|
||||
}
|
||||
AccountKind::Microsoft => {
|
||||
let Some(Credentials::Microsoft(session)) = store.credentials(&chosen.id) else {
|
||||
return Err(format!(
|
||||
return Err(identity_failure(format!(
|
||||
"账户 {} 的微软凭据缺失或类型不符,请重新登录",
|
||||
chosen.display_name()
|
||||
));
|
||||
)));
|
||||
};
|
||||
let auth =
|
||||
MicrosoftAuth::from_env().map_err(|error| format!("微软登录不可用:{error}"))?;
|
||||
let auth = MicrosoftAuth::from_env()
|
||||
.map_err(|error| identity_failure(format!("微软登录不可用:{error}")))?;
|
||||
let (fresh, _) = block_on(auth.ensure_fresh(session))
|
||||
.map_err(|error| format!("微软令牌刷新失败:{error}"))?;
|
||||
.map_err(|error| identity_failure(format!("微软令牌刷新失败:{error}")))?;
|
||||
if &fresh != session {
|
||||
// refresh_token 已轮换:立刻回写,旧值作废。
|
||||
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
|
||||
.to_placeholders()
|
||||
.map_err(|error| format!("微软身份转换失败:{error}"))?;
|
||||
.map_err(|error| identity_failure(format!("微软身份转换失败:{error}")))?;
|
||||
Ok((placeholders, line))
|
||||
}
|
||||
AccountKind::AuthlibInjector => {
|
||||
let Some(Credentials::AuthlibInjector(session)) = store.credentials(&chosen.id) else {
|
||||
return Err(format!(
|
||||
return Err(identity_failure(format!(
|
||||
"账户 {} 的第三方凭据缺失或类型不符",
|
||||
chosen.display_name()
|
||||
));
|
||||
)));
|
||||
};
|
||||
let placeholders = session
|
||||
.to_placeholders()
|
||||
.map_err(|error| format!("第三方身份转换失败:{error}"))?;
|
||||
.map_err(|error| identity_failure(format!("第三方身份转换失败:{error}")))?;
|
||||
Ok((placeholders, line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 账户/身份这一步的失败(阶段固定 `Identity`;`detail` 保留原始文案与本机错误)。
|
||||
fn identity_failure(detail: String) -> LaunchFailureView {
|
||||
LaunchFailureView::new(LaunchFailureStage::Identity, detail)
|
||||
}
|
||||
|
||||
/// `SessionHandle` → 日志页的三种结局。
|
||||
pub fn outcome_of(handle: &unlucky_core::launch::SessionHandle) -> LogOutcome {
|
||||
if handle.exit.silent_failure.is_some() {
|
||||
|
||||
@@ -21,7 +21,6 @@ use crate::dto::{
|
||||
LiveLogBatch,
|
||||
LoaderKind,
|
||||
LogLine,
|
||||
LogOutcome,
|
||||
LogSession,
|
||||
NoticeView,
|
||||
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}"))
|
||||
}
|
||||
|
||||
/// 人读一行「启动结束」。
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -187,14 +174,4 @@ mod tests {
|
||||
let task = rx.recv().expect("收到");
|
||||
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("静默"));
|
||||
}
|
||||
}
|
||||
@@ -17,16 +17,22 @@ use unlucky_core::game::{self, VersionJson};
|
||||
use unlucky_core::launch::adapter::{
|
||||
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::tree_kill;
|
||||
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::job::{launch_summary, HostTask, JobSender};
|
||||
|
||||
mod java;
|
||||
|
||||
use super::job::{HostTask, JobSender};
|
||||
pub use java::pick_java;
|
||||
|
||||
/// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。
|
||||
pub const LAUNCHER_NAME: &str = "Unlucky Launcher";
|
||||
@@ -44,104 +50,38 @@ pub struct LaunchTarget {
|
||||
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` 同序)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 版本 JSON 缺失/非法、继承链断裂、隔离派生失败时返回用户可读文案。
|
||||
/// 任一步失败都返回 [`LaunchFailureView`]:**阶段**(版本 JSON / 继承链 / 实例设置 / 隔离目录 /
|
||||
/// 账户)+ 那一步的原文。文案不在这一层拼(presenter 不依赖 `unlucky-i18n`)。
|
||||
pub fn build_request(
|
||||
env: &HostEnv,
|
||||
target: &LaunchTarget,
|
||||
) -> Result<(LaunchRequest, Vec<String>), String> {
|
||||
) -> Result<(LaunchRequest, Vec<String>), LaunchFailureView> {
|
||||
let version_id = target.version_id.clone();
|
||||
let manifest_path = env.game.version_json(&version_id);
|
||||
let text = std::fs::read_to_string(&manifest_path)
|
||||
.map_err(|error| format!("读版本 JSON 失败 {}:{error}", manifest_path.display()))?;
|
||||
let manifest =
|
||||
VersionJson::from_json_str(&text).map_err(|error| format!("版本 JSON 非法:{error}"))?;
|
||||
let text = std::fs::read_to_string(&manifest_path).map_err(|error| {
|
||||
LaunchFailureView::new(
|
||||
LaunchFailureStage::VersionManifest,
|
||||
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> {
|
||||
std::fs::read_to_string(env.game.version_json(id))
|
||||
.ok()
|
||||
.and_then(|text| VersionJson::from_json_str(&text).ok())
|
||||
};
|
||||
let resolved =
|
||||
game::resolve(&source, &manifest).map_err(|error| format!("解析继承链失败:{error}"))?;
|
||||
let resolved = game::resolve(&source, &manifest).map_err(|error| {
|
||||
LaunchFailureView::new(LaunchFailureStage::Inheritance, error.to_string())
|
||||
})?;
|
||||
|
||||
let environment = Environment::host();
|
||||
let expanded = resolved
|
||||
@@ -252,12 +192,15 @@ pub fn build_request(
|
||||
}
|
||||
|
||||
/// 派生运行目录(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 config = read_instance_config(&env.layout, &target.id)
|
||||
.map_err(|error| format!("读实例设置失败:{error}"))?;
|
||||
let config = read_instance_config(&env.layout, &target.id).map_err(|error| {
|
||||
LaunchFailureView::new(LaunchFailureStage::InstanceConfig, error.to_string())
|
||||
})?;
|
||||
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 {
|
||||
let at = format_rfc3339_millis(now());
|
||||
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) {
|
||||
Ok(ok) => ok,
|
||||
Err(reason) => {
|
||||
Err(failure) => {
|
||||
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||
id,
|
||||
session: String::new(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
summary: format!("启动失败:{reason}"),
|
||||
exit_code: None,
|
||||
uptime_ms: 0,
|
||||
failure: Some(failure),
|
||||
})));
|
||||
return;
|
||||
}
|
||||
@@ -309,7 +254,12 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
id,
|
||||
session: String::new(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
summary: format!("启动失败:{error}"),
|
||||
exit_code: None,
|
||||
uptime_ms: 0,
|
||||
failure: Some(LaunchFailureView::new(
|
||||
LaunchFailureStage::Spawn,
|
||||
error.to_string(),
|
||||
)),
|
||||
})));
|
||||
return;
|
||||
}
|
||||
@@ -332,12 +282,10 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
id,
|
||||
session: session_id,
|
||||
outcome,
|
||||
summary: format!(
|
||||
"{}({},{:.1}s)",
|
||||
launch_summary(outcome, Some(handle.exit.code)),
|
||||
handle.exit.exit_type.as_str(),
|
||||
handle.uptime_ms as f64 / 1000.0
|
||||
),
|
||||
exit_code: Some(handle.exit.code),
|
||||
uptime_ms: handle.uptime_ms,
|
||||
// 跑起来再崩不算「装配失败」:结局由 `outcome` 表达,这里留 `None`。
|
||||
failure: None,
|
||||
})));
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -345,7 +293,12 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||||
id,
|
||||
session: session_id,
|
||||
outcome: LogOutcome::SilentFail,
|
||||
summary: format!("启动收尾失败:{error}"),
|
||||
exit_code: None,
|
||||
uptime_ms: 0,
|
||||
failure: Some(LaunchFailureView::new(
|
||||
LaunchFailureStage::Spawn,
|
||||
error.to_string(),
|
||||
)),
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
session: "20261031T120000-abcd12".to_string(),
|
||||
outcome: LogOutcome::Ok,
|
||||
summary: "正常退出".to_string(),
|
||||
exit_code: Some(0),
|
||||
uptime_ms: 12_500,
|
||||
failure: None,
|
||||
})));
|
||||
assert!(host.launches.is_empty(), "收尾后启动表要清空");
|
||||
assert!(!host.failed.contains_key(&id));
|
||||
@@ -197,7 +199,9 @@ fn refresh_keeps_pending_as_busy_and_failed_as_failed() {
|
||||
id: id.clone(),
|
||||
session: "20261031T120000-dead01".to_string(),
|
||||
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.in_flight(&id), "收尾后该实例要能被重新启动");
|
||||
@@ -209,12 +213,15 @@ fn refresh_keeps_pending_as_busy_and_failed_as_failed() {
|
||||
.into_iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.expect("实例存在");
|
||||
// 跑起来又崩:原因是**结局**,不是「进程起不来」(复审 P1-1)。
|
||||
// 抓得住:把崩溃塞回 `LaunchFailureStage::Spawn`(磁贴会说「启动游戏进程失败」,假话),
|
||||
// 或者把退出码漏成 `Some(1)` 这种 Debug 文本(这里只认数字)。
|
||||
assert_eq!(
|
||||
failed.runtime,
|
||||
InstanceRuntime::Failed {
|
||||
reason: "崩溃(exit 1)".to_string()
|
||||
cause: crate::dto::LaunchFailureCause::Crashed { exit_code: Some(1) }
|
||||
},
|
||||
"失败原因要经得住一次刷新(否则磁贴的「重试」外观会被 Idle 盖掉)"
|
||||
"崩溃要如实报成「起来了又崩」,且经得住一次刷新"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -229,7 +236,12 @@ fn launch_end_before_launch_started_still_releases_the_instance() {
|
||||
id: id.clone(),
|
||||
session: String::new(),
|
||||
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!(
|
||||
@@ -248,12 +260,101 @@ fn launch_end_before_launch_started_still_releases_the_instance() {
|
||||
assert_eq!(
|
||||
failed.runtime,
|
||||
InstanceRuntime::Failed {
|
||||
reason: "启动失败:找不到 Java".to_string()
|
||||
cause: crate::dto::LaunchFailureCause::Stage {
|
||||
stage: crate::dto::LaunchFailureStage::Identity,
|
||||
detail: "找不到账户 `steve`".to_string()
|
||||
}
|
||||
},
|
||||
"早失败也要在磁贴上留下「失败/重试」,不能被 Idle 盖掉"
|
||||
"早失败(进程没起来)要留在阶段那一支,不能被 Idle 盖掉"
|
||||
);
|
||||
}
|
||||
|
||||
/// **三种失败形状各归各的**(复审 P1-1 的核心判据)。
|
||||
///
|
||||
/// | 收尾事件 | 期望原因 |
|
||||
/// |---|---|
|
||||
/// | `failure: Some(..)` | `Stage{..}`(进程**没起来**)|
|
||||
/// | `failure: None` + `Crashed` | `Crashed{exit_code}`(起来又崩)|
|
||||
/// | `failure: None` + `SilentFail` | `SilentFailure{exit_code}` |
|
||||
///
|
||||
/// 抓得住:把「起来了又崩」塞进 `LaunchFailureStage::Spawn`(磁贴会说「启动游戏进程失败」,
|
||||
/// 而进程明明起来了);或者把静默失败也塌成崩溃(丢掉「退出码 0 却没跑起来」这条信息)。
|
||||
/// 断言的是**枚举变体**,不是字符串里有没有某个词。
|
||||
#[test]
|
||||
fn a_launch_end_without_a_stage_reports_the_outcome_not_a_start_failure() {
|
||||
use crate::dto::{LaunchFailureCause, LaunchFailureStage, LaunchFailureView};
|
||||
|
||||
/// 一行用例:`(标签, 结局, 阶段失败, 退出码, 期望原因)`。
|
||||
type Case = (
|
||||
&'static str,
|
||||
LogOutcome,
|
||||
Option<LaunchFailureView>,
|
||||
Option<i32>,
|
||||
LaunchFailureCause,
|
||||
);
|
||||
|
||||
let cases: Vec<Case> = vec![
|
||||
(
|
||||
"spawn-failed",
|
||||
LogOutcome::Crashed,
|
||||
Some(LaunchFailureView::new(
|
||||
LaunchFailureStage::Spawn,
|
||||
"os error 5",
|
||||
)),
|
||||
None,
|
||||
LaunchFailureCause::Stage {
|
||||
stage: LaunchFailureStage::Spawn,
|
||||
detail: "os error 5".to_string(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"crashed-after-start",
|
||||
LogOutcome::Crashed,
|
||||
None,
|
||||
Some(1),
|
||||
LaunchFailureCause::Crashed { exit_code: Some(1) },
|
||||
),
|
||||
(
|
||||
"silent-after-start",
|
||||
LogOutcome::SilentFail,
|
||||
None,
|
||||
Some(0),
|
||||
LaunchFailureCause::SilentFailure { exit_code: Some(0) },
|
||||
),
|
||||
];
|
||||
|
||||
for (label, outcome, failure, exit_code, expected) in cases {
|
||||
let (mut host, id, _root) = host_with_instance(label);
|
||||
host.mark_pending(&id);
|
||||
host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||
id: id.clone(),
|
||||
session: format!("20261031T120000-{label}"),
|
||||
outcome,
|
||||
exit_code,
|
||||
uptime_ms: 1_000,
|
||||
failure,
|
||||
})));
|
||||
let failed = instances_from_events({
|
||||
host.refresh_instances();
|
||||
host.drain_events()
|
||||
})
|
||||
.into_iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.expect("实例存在");
|
||||
match (&failed.runtime, &expected) {
|
||||
(InstanceRuntime::Failed { cause }, want) => {
|
||||
assert_eq!(cause, want, "{label}:失败原因说错了");
|
||||
assert_eq!(
|
||||
cause.slug(),
|
||||
want.slug(),
|
||||
"{label}:slug 与变体不一致(日志/事件流按 slug 判)"
|
||||
);
|
||||
}
|
||||
(other, want) => panic!("{label}:期望 Failed{{cause:{want:?}}},实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从事件队列里取最后一次 `InstancesChanged`。
|
||||
fn instances_from_events(events: Vec<AppEvent>) -> Vec<crate::dto::InstanceSummary> {
|
||||
events
|
||||
|
||||
@@ -41,7 +41,7 @@ use std::time::Duration;
|
||||
|
||||
use crate::dto::{
|
||||
AccountView, AppCommand, AppEvent, Backend, CoreEvent, DiagnosisReport, InstallProgressView,
|
||||
InstanceRuntime, JavaChainStep, NoticeView, SkinImage,
|
||||
InstanceRuntime, JavaChainStep, LaunchFailureCause, NoticeView, SkinImage,
|
||||
};
|
||||
use crate::mock::MockBackend;
|
||||
use crate::toast::{Toast, ToastKind};
|
||||
@@ -92,7 +92,8 @@ pub struct CoreHost {
|
||||
/// 已受理但后台线程还没回报 `LaunchStarted` 的实例(防「启动中重复点启动」起第二个进程)
|
||||
pending: BTreeSet<String>,
|
||||
/// 上次启动失败的实例与原因(刷新快照时保住磁贴的「失败/重试」外观)
|
||||
failed: BTreeMap<String, String>,
|
||||
/// 上次启动失败(阶段 + 原文;磁贴的「失败/重试」外观靠它重建)
|
||||
failed: BTreeMap<String, LaunchFailureCause>,
|
||||
/// 实时日志尾随器
|
||||
tail: logs::Tail,
|
||||
/// AI 对话的临时后端(agent GUI host 落地前的占位)
|
||||
@@ -228,9 +229,9 @@ impl CoreHost {
|
||||
stage: crate::dto::LaunchStage::Launch,
|
||||
progress: crate::dto::Progress { done: 0, total: 1 },
|
||||
};
|
||||
} else if let Some(reason) = self.failed.get(&instance.id) {
|
||||
} else if let Some(cause) = self.failed.get(&instance.id) {
|
||||
instance.runtime = InstanceRuntime::Failed {
|
||||
reason: reason.clone(),
|
||||
cause: cause.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
//! ```text
|
||||
//! core::update::Job ← 检查/应用的做法(版本比较、渠道准入、双检、原子替换全在 core)
|
||||
//! ▲
|
||||
//! │ 本模块只做三件事
|
||||
//! │ 本模块只做四件事
|
||||
//! Factory ← 用哪个源、暂存放哪、替换谁(生产:GitHub + 本进程 exe)
|
||||
//! outcome_of ← core 的结论 → DTO(GUI 与 CLI 共用同一条判据)
|
||||
//! Prefs ← 更新域自己的开关(启动时静默检查)
|
||||
//! Paths / Migration ← 两个开关的落点,以及旧 `cache/update/prefs.json` 的迁移(M161)
|
||||
//! ```
|
||||
//!
|
||||
//! 「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()`)。
|
||||
//!
|
||||
//! # 偏好文件为什么不在 `launcher.json` 里
|
||||
//! # 两个开关住在主配置里(M161)
|
||||
//!
|
||||
//! `launcher.json` 的每个键都归 `core::setting::keys` 的 schema 管,而本 mission 的 scope 不含
|
||||
//! `crates/core/src/setting/**`。所以这一条先落在更新域自己的文件
|
||||
//! `<数据根>/cache/update/prefs.json`(与 `cache/region.json` 同族:域内状态,坏了就回默认)。
|
||||
//! **加 `update.auto_check` schema 键之后应当迁过去**(已作为 finding 上报)。
|
||||
//! `update.auto_check` / `update.channel` 是 `crates/core/src/setting/keys.rs` 的 schema 键,
|
||||
//! 落在 `<数据根>/config/launcher.json`——与「所有配置走 `core::setting`」的约定一致,
|
||||
//! 于是 `ull config get update.auto_check` 读到的是**同一个值**。
|
||||
//!
|
||||
//! 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::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::dto::{UpdateCandidateView, UpdateFailureView, UpdateOutcome};
|
||||
use unlucky_core::download::DownloadEngine;
|
||||
use unlucky_core::launch::time::{format_rfc3339_millis, now};
|
||||
use unlucky_core::setting::update_prefs;
|
||||
use unlucky_core::update::{
|
||||
default_source, staging_dir, Applied, Channel, CheckOutcome, DirSource, Job, Target,
|
||||
UpdateError, UpdateSource, Version,
|
||||
default_source, staging_dir, Applied, CheckOutcome, DirSource, Job, Target, UpdateError,
|
||||
UpdateSource, Version,
|
||||
};
|
||||
|
||||
use crate::dto::{UpdateCandidateView, UpdateFailureView, UpdateOutcome};
|
||||
|
||||
use super::engine;
|
||||
use super::env::HostEnv;
|
||||
use super::logs::display_time;
|
||||
|
||||
/// 渠道(D50:先只发 stable)。
|
||||
pub const CHANNEL: Channel = Channel::Stable;
|
||||
|
||||
/// 本进程版本。整个 workspace 同版本(根 `Cargo.toml` 的 `[workspace.package]`)。
|
||||
#[must_use]
|
||||
pub fn current_version() -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
/// 更新域偏好(现在只有一项)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Prefs {
|
||||
/// 启动时静默检查。缺省开:检查本身不打扰用户,发现新版本才有提示条。
|
||||
#[serde(default = "default_auto_check")]
|
||||
pub auto_check: bool,
|
||||
/// 更新域的三个落点(都由数据根派生,core 不猜布局)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Paths {
|
||||
/// `<数据根>/config/launcher.json`(两个开关的真相源)
|
||||
pub launcher: PathBuf,
|
||||
/// `<数据根>/cache/update/prefs.json`(M159 的旧文件;迁移后改名 `.migrated`)
|
||||
pub legacy: PathBuf,
|
||||
/// `<数据根>/config/update-prefs-migration.json`(迁移收据)
|
||||
pub receipt: PathBuf,
|
||||
}
|
||||
|
||||
fn default_auto_check() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
fn default() -> Self {
|
||||
Self { auto_check: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl Prefs {
|
||||
/// 读偏好:文件不存在 / 坏 JSON 一律回默认(偏好坏了不该让启动器起不来)。
|
||||
impl Paths {
|
||||
/// 按数据根装配。
|
||||
#[must_use]
|
||||
pub fn load(path: &Path) -> Self {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|text| serde_json::from_str(&text).ok())
|
||||
.unwrap_or_default()
|
||||
pub fn for_env(env: &HostEnv) -> Self {
|
||||
Self {
|
||||
launcher: env.launcher_config(),
|
||||
legacy: env.layout.cache().join("update").join("prefs.json"),
|
||||
receipt: env.layout.config().join(update_prefs::RECEIPT_FILE),
|
||||
}
|
||||
}
|
||||
|
||||
/// 写偏好。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 目录建不出来或写盘失败时返回人读文案。
|
||||
pub fn save(&self, path: &Path) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("建更新偏好目录失败:{error}"))?;
|
||||
/// 迁移要的三个路径。
|
||||
#[must_use]
|
||||
pub fn targets(&self) -> update_prefs::Targets<'_> {
|
||||
update_prefs::Targets {
|
||||
launcher: &self.launcher,
|
||||
legacy: &self.legacy,
|
||||
receipt: &self.receipt,
|
||||
}
|
||||
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::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 {
|
||||
Report::Applied(ApplyReport::Failed(failure)) => {
|
||||
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("旧版本");
|
||||
|
||||
let applied = job(&tmp)
|
||||
.apply(&version("0.2.2"), CHANNEL)
|
||||
.apply(&version("0.2.2"), Channel::Stable)
|
||||
.expect("应用成功");
|
||||
let Applied::Installed { receipt, version } = applied else {
|
||||
panic!("应当装上");
|
||||
|
||||
@@ -81,21 +81,32 @@ fn dismissing_without_a_candidate_does_nothing() {
|
||||
}
|
||||
|
||||
/// 关掉「启动时检查」之后:状态上是 no-op、启动路径上根本不派发。
|
||||
///
|
||||
/// 开关的落点是**主配置**(`update.auto_check`),这里顺带把 M161 的迁移走一遍:
|
||||
/// 旧 `cache/update/prefs.json` 里的 `false` 必须是新会话看到的 `false`(不是默认的 `true`)。
|
||||
#[test]
|
||||
fn auto_check_off_means_no_request_on_start() {
|
||||
let tmp = TempDir::new("autocheck");
|
||||
let env = HostEnv::at_root(tmp.join("root"));
|
||||
let prefs_path = env.layout.cache().join("update").join("prefs.json");
|
||||
Prefs { auto_check: false }
|
||||
.save(&prefs_path)
|
||||
.expect("写偏好");
|
||||
std::fs::create_dir_all(prefs_path.parent().expect("父目录")).expect("建 cache");
|
||||
std::fs::write(&prefs_path, r#"{"auto_check": false}"#).expect("写旧偏好");
|
||||
|
||||
let runtime = Runtime::with_factory(&env, factory(&tmp));
|
||||
assert!(!runtime.view().auto_check, "开关要读进快照");
|
||||
assert!(!runtime.auto_check(), "关掉的值一路迁进主配置");
|
||||
assert!(
|
||||
!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` 上,
|
||||
// 否则单测绿着而启动仍然联网(这条读源码,理由同 `instances/tests.rs` 的源码断言)。
|
||||
@@ -106,18 +117,30 @@ fn auto_check_off_means_no_request_on_start() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 偏好能落盘、能读回(换一份 Runtime 仍记得上次的选择)。
|
||||
/// 开关能写进主配置、能读回(换一份 Runtime 仍记得上次的选择)。
|
||||
#[test]
|
||||
fn the_auto_check_preference_survives_a_restart() {
|
||||
let tmp = TempDir::new("prefs");
|
||||
let env = HostEnv::at_root(tmp.join("root"));
|
||||
let mut runtime = Runtime::with_factory(&env, factory(&tmp));
|
||||
assert!(runtime.prefs().auto_check, "缺省开");
|
||||
assert!(runtime.auto_check(), "缺省开");
|
||||
|
||||
runtime.set_auto_check(false).expect("落盘");
|
||||
let restarted = Runtime::with_factory(&env, factory(&tmp));
|
||||
assert!(!restarted.prefs().auto_check, "重启后仍记得关掉");
|
||||
assert!(!restarted.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:不许两套逻辑)。
|
||||
@@ -145,7 +168,33 @@ fn the_factory_names_its_source_and_target() {
|
||||
let factory = factory(&tmp);
|
||||
assert_eq!(factory.dir(), Some(tmp.join("dist").as_path()));
|
||||
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:源码锚点,防止有人另起一条流程。
|
||||
|
||||
@@ -1,32 +1,41 @@
|
||||
//! 更新状态的**会话层**:一份 `UpdateView`、更新域偏好、以及把 `CoreHost` 接上后台线程。
|
||||
//! 更新状态的**会话层**:一份 `UpdateView`、主配置里的两个开关、以及把 `CoreHost` 接上后台线程。
|
||||
//!
|
||||
//! 与 [`super::update`] 的分工:那边是「一次检查/应用的纯做法」(选源、调 core、映射 DTO),
|
||||
//! 这边是「谁来记着它、谁来派发」。拆开是因为两边都各有一批判据要写清(静默检查的开关、
|
||||
//! 拒绝后不再打扰、应用失败不许丢掉候选),塞在一个文件里会越过 400 行硬线。
|
||||
|
||||
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 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;
|
||||
|
||||
/// 会话级更新状态(提示条 / 设置页共用一份)。
|
||||
#[derive(Debug)]
|
||||
pub struct Runtime {
|
||||
view: UpdateView,
|
||||
prefs: Prefs,
|
||||
/// `update.auto_check` 的生效值(缓存:`should_check_on_start` 在 `refresh_all` 里被调)。
|
||||
auto_check: bool,
|
||||
factory: Factory,
|
||||
prefs_path: PathBuf,
|
||||
/// `config/launcher.json`(两个开关的真相源;写它就走 `core::setting`)。
|
||||
launcher: PathBuf,
|
||||
/// 建会话时那次遗留文件迁移的结局(`Nothing` = 没有旧文件)。
|
||||
migration: Migration,
|
||||
/// 有一条检查/应用正在后台跑(防重复派发;进程内独占)。
|
||||
inflight: bool,
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
/// 按数据根装配。
|
||||
/// 按数据根装配(顺带把 M159 的 `cache/update/prefs.json` 迁进主配置)。
|
||||
#[must_use]
|
||||
pub fn for_env(env: &HostEnv) -> Self {
|
||||
Self::with_factory(env, Factory::for_env(env))
|
||||
@@ -35,13 +44,18 @@ impl Runtime {
|
||||
/// 用给定装配(单测:临时目录 + 本地目录源)。
|
||||
#[must_use]
|
||||
pub fn with_factory(env: &HostEnv, factory: Factory) -> Self {
|
||||
let prefs_path = env.layout.cache().join("update").join("prefs.json");
|
||||
let prefs = Prefs::load(&prefs_path);
|
||||
let paths = Paths::for_env(env);
|
||||
// 迁移在建会话时跑一次:**幂等**(源指纹闸门),没有旧文件时是纯 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 {
|
||||
view: UpdateView::new(current_version(), CHANNEL.as_str(), prefs.auto_check),
|
||||
prefs,
|
||||
view: UpdateView::new(current_version(), &channel, auto_check),
|
||||
auto_check,
|
||||
factory,
|
||||
prefs_path,
|
||||
launcher: paths.launcher,
|
||||
migration,
|
||||
inflight: false,
|
||||
}
|
||||
}
|
||||
@@ -58,10 +72,16 @@ impl Runtime {
|
||||
self.view.clone()
|
||||
}
|
||||
|
||||
/// 偏好。
|
||||
/// 「启动时静默检查」的生效值(来自 `launcher.json` 的 `update.auto_check`)。
|
||||
#[must_use]
|
||||
pub const fn prefs(&self) -> Prefs {
|
||||
self.prefs
|
||||
pub const fn auto_check(&self) -> bool {
|
||||
self.auto_check
|
||||
}
|
||||
|
||||
/// 建会话时那次迁移的结局(`Nothing` = 没有遗留文件;收据路径见 [`Migration::receipt`])。
|
||||
#[must_use]
|
||||
pub const fn migration(&self) -> &Migration {
|
||||
&self.migration
|
||||
}
|
||||
|
||||
/// 后台有没有一条在跑。
|
||||
@@ -79,18 +99,37 @@ impl Runtime {
|
||||
/// 启动时要不要静默检查(开关关掉 ⇒ 启动**一次网络都不发**)。
|
||||
#[must_use]
|
||||
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
|
||||
///
|
||||
/// 落盘失败返回人读文案(本会话的取值仍然生效——不因为写不进去就把开关弹回去)。
|
||||
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.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`]
|
||||
@@ -105,6 +144,7 @@ impl Runtime {
|
||||
self.view.outcome = UpdateOutcome::Checking;
|
||||
self.view.silent = !manual;
|
||||
self.view.applying = false;
|
||||
self.view.channel = self.channel().as_str().to_string();
|
||||
if manual {
|
||||
// 手动检查 = 用户要看真结果,把「忽略这个版本」清掉。
|
||||
self.view.skipped = None;
|
||||
@@ -190,9 +230,10 @@ impl CoreHost {
|
||||
let Some(job) = self.update.begin_check(manual) else {
|
||||
return;
|
||||
};
|
||||
let channel = self.update.channel();
|
||||
// 先把「正在查」发出去:设置页的按钮据此转圈,而不是看起来没反应。
|
||||
self.publish_update();
|
||||
spawn_check(job, current, self.tx.clone());
|
||||
spawn_check(job, current, channel, self.tx.clone());
|
||||
}
|
||||
|
||||
/// 应用更新(下载 → 长度/SHA256 双检 → 替换;全部在后台线程)。
|
||||
@@ -203,27 +244,46 @@ impl CoreHost {
|
||||
let Some(job) = self.update.begin_apply() else {
|
||||
return;
|
||||
};
|
||||
let channel = self.update.channel();
|
||||
self.publish_update();
|
||||
spawn_apply(job, current, self.tx.clone());
|
||||
spawn_apply(job, current, channel, self.tx.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// 起一条检查线程(结果经 [`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 || {
|
||||
let report = Report::Checked(outcome_of(job.check(¤t, CHANNEL)));
|
||||
let report = Report::Checked(outcome_of(job.check(¤t, channel)));
|
||||
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 || {
|
||||
let report = Report::applied(job.apply(¤t, CHANNEL));
|
||||
let report = Report::applied(job.apply(¤t, channel));
|
||||
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)]
|
||||
#[path = "update_tests.rs"]
|
||||
mod tests;
|
||||
@@ -19,12 +19,12 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use unlucky_core::download::verify::digest_bytes;
|
||||
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 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;
|
||||
|
||||
/// 一次性临时目录。
|
||||
@@ -134,7 +134,7 @@ fn version_matrix_decides_who_gets_prompted() {
|
||||
let tmp = TempDir::new("matrix");
|
||||
let payload = b"new";
|
||||
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!(
|
||||
outcome.candidate().is_some(),
|
||||
expected,
|
||||
@@ -180,7 +180,7 @@ fn a_broken_manifest_degrades_to_failure_not_up_to_date() {
|
||||
] {
|
||||
let tmp = TempDir::new("badmanifest");
|
||||
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!(
|
||||
matches!(outcome, UpdateOutcome::Failed(_)),
|
||||
"{label}:坏清单必须降级成「检查失败」,实际 {outcome:?}"
|
||||
@@ -206,7 +206,7 @@ fn an_empty_manifest_means_nothing_published_yet() {
|
||||
] {
|
||||
let tmp = TempDir::new("empty-manifest");
|
||||
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!(
|
||||
matches!(&outcome, UpdateOutcome::UpToDate { latest: None }),
|
||||
"空清单 = 还没发过版,实际 {outcome:?}"
|
||||
@@ -228,7 +228,7 @@ fn a_higher_version_without_our_platform_is_not_up_to_date() {
|
||||
)
|
||||
.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!(
|
||||
matches!(&outcome, UpdateOutcome::MissingArtifact { version } if version == "0.9.0"),
|
||||
"缺本平台产物要单独报出来,实际 {outcome:?}"
|
||||
|
||||
@@ -68,8 +68,9 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
|
||||
version: "21.1.72".to_string(),
|
||||
},
|
||||
last_played: Some("2026-10-27T12:40:00Z".to_string()),
|
||||
// 静默失败 = **起来过**但 exit 0 没真正跑起来(不是「进程起不来」,见复审 P1-1)。
|
||||
runtime: InstanceRuntime::Failed {
|
||||
reason: "exit0 静默失败".to_string(),
|
||||
cause: crate::dto::LaunchFailureCause::SilentFailure { exit_code: Some(0) },
|
||||
},
|
||||
icon: None,
|
||||
isolation: "isolated".to_string(),
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::dto::{
|
||||
InstanceRuntime,
|
||||
InstanceSummary,
|
||||
JavaPageView,
|
||||
LaunchFinished,
|
||||
LoaderKind,
|
||||
LogLine,
|
||||
LogSession,
|
||||
@@ -32,7 +33,7 @@ use crate::prefs::UiPrefs;
|
||||
// 内容库的视图态与其 DTO 放一起(`dto/content.rs`),路径 `state::ContentState` 不变。
|
||||
pub use crate::dto::ContentState;
|
||||
use crate::router::{AiPanelMode, Route, Router};
|
||||
use crate::toast::{ToastCenter, ToastKind};
|
||||
use crate::toast::ToastCenter;
|
||||
use crate::wizard::WizardState;
|
||||
|
||||
// 账户/登录状态(倒计时、上屏前二次脱敏)拆在 `state/accounts.rs`,守 400 行硬线。
|
||||
@@ -100,6 +101,11 @@ pub struct AppState {
|
||||
/// 只是信号,不带文案:app 层比对前后值决定「要不要弹一条 i18n Toast」,
|
||||
/// 于是取消提示和页面用同一种语言(复审 P2-2)。
|
||||
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)
|
||||
///
|
||||
/// 两段键:外层是加载器的**稳定 id**(`LoaderKind::as_str`,`LoaderKind` 没派生 `Hash`,
|
||||
@@ -160,6 +166,8 @@ impl AppState {
|
||||
java: JavaPageView::new(),
|
||||
install: None,
|
||||
install_cancelled: 0,
|
||||
launch_ends: 0,
|
||||
last_launch_end: None,
|
||||
loader_versions: std::collections::HashMap::new(),
|
||||
settings_groups: Vec::new(),
|
||||
settings_tab: "download".to_string(),
|
||||
@@ -246,12 +254,11 @@ impl AppState {
|
||||
CoreEvent::LaunchFinished(finished) => {
|
||||
self.live_session = None;
|
||||
self.selected_session = Some(finished.session.clone());
|
||||
let kind = if finished.outcome == crate::dto::LogOutcome::Ok {
|
||||
ToastKind::Success
|
||||
} else {
|
||||
ToastKind::Error
|
||||
};
|
||||
self.toasts.push(kind, finished.summary);
|
||||
// **不在这里拼文案**(M161):presenter 不依赖 `unlucky-i18n`,写死的中文会在
|
||||
// 英文界面上弹出来。这里只记「又收尾了一场」,Toast 由 app 层按 key 现求
|
||||
// (与 `install_cancelled` 同一条纪律,见 `app/state/refresh.rs`)。
|
||||
self.launch_ends = self.launch_ends.saturating_add(1);
|
||||
self.last_launch_end = Some(Box::new(finished));
|
||||
}
|
||||
CoreEvent::DiagnosisReady(report) => self.diagnosis = Some(report),
|
||||
CoreEvent::AccountsChanged(accounts) => self.accounts = accounts,
|
||||
|
||||
Reference in new issue
Block a user