fix(m161): 收更新键/按钮/矮窗口/启动错误/导入结果五笔债
① update.auto_check / update.channel 登记进 core setting schema(1.6.0)
- 新增 setting/keys/update.rs(两键 + UPDATE_CHANNELS,只允许 stable);
SettingSchema 加 with_all 供域键表批量登记。
- 新增 setting/update_prefs.rs:cache/update/prefs.json → launcher.json 的一次性迁移。
四条硬约束:源指纹闸门(幂等)、不丢 false、新值胜出、旧文件改名 .migrated 后不再被读;
主配置不可写时不动旧文件。收据 config/update-prefs-migration.json。
- presenter Runtime 建会话时迁一次并把结局留在 migration();prefs 字段换成
auto_check + 每次派发从主配置读 channel(不再硬编码 Channel::Stable)。
保留旧字段 auto_check 的真实来源(原有测试 cases 会断言)。
- CLI config get/set/dump 先跑同一次迁移(幂等),输出里给 migration 段与收据路径。
② presenter/dto/events.rs 的「本次会话不再弹提示条」注释改成真实语义
(记的是版本号:同版本压住、更高版本照弹、手动检查清掉)。
③ 检查/下载进行中按钮禁用:pages/update/busy.rs 的 busy/can_check/can_apply;
设置页两颗按钮改画禁用(不是消失),app 层 on_update_action 再挡一道
(忙碌时 CheckNow/Apply 不派发);新增两条用例(含「忙完恢回可点」)。
④ 内容库低高度:content_layout::MIN_BODY_HEIGHT=460 + BodyMode(按固定件+三整行算出来,
用例守着算式);frame::body_height 从 Ui::metrics 算正文可用高,pages::Model 带 window_height。
≥460 列表自己滚;<460 主列整列一个滚动条、列表按自然高度铺开。
⑤ 启动错误文案本地化:presenter 删掉 launch_summary / 「启动失败:」前缀 / 五条中文阶段前缀,
改发 LaunchFailureView{stage,detail};LaunchFinished 改带 exit_code/uptime_ms/failure,
Toast 由 app 层按 i18n key 现求(与 install_cancelled 同构)。InstancesFailed 拆成
InstancesFailedStage(列表单行)+ LaunchStageFailed(详情/Toast 全文)。
⑥ 导入结果块:ImportReportView 加 failure_stage(NotStarted/RolledBack/RollbackIncomplete,
由 core 的 rollback 机器位推出);标题、图标、底色三处分档,预检失败不再说「已回滚」。
顺带:六处注释/文档漂移(update.rs 的分工表与偏好落点、pack 的「已整包回滚」、
build_request 的 Errors、settings/update 的按钮禁用说明等),
以及为守 400 行把 update/cli-config/core-keys/presenter-dto/presenter-launch/i18n-golden
里贴线或越线的文件按模块拆开(纯搬移,行为不变)。
This commit is contained in:
1 parent
b2d9f3927f
commit
03741ecd2d
69 files changed
+2653
-707
No files matched your search
@@ -168,6 +168,9 @@ pub struct App {
|
|||||||
/// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」,
|
/// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」,
|
||||||
/// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。
|
/// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。
|
||||||
pub(super) install_cancelled_seen: u64,
|
pub(super) install_cancelled_seen: u64,
|
||||||
|
/// 已经提示过的启动收尾次数(M161):`AppState::launch_ends` 是「收尾过几场」,
|
||||||
|
/// 这里是「其中几场已经弹过 Toast」。两者不等 ⇒ 弹一次(与 `install_cancelled_seen` 同构)。
|
||||||
|
pub(super) launch_ends_seen: u64,
|
||||||
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
|
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
|
||||||
///
|
///
|
||||||
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
|
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ impl App {
|
|||||||
let mut view = pages::Model::new(
|
let mut view = pages::Model::new(
|
||||||
pages::Screen::Instances,
|
pages::Screen::Instances,
|
||||||
width,
|
width,
|
||||||
|
height,
|
||||||
Some(pages::Screen::Instances.nav()),
|
Some(pages::Screen::Instances.nav()),
|
||||||
);
|
);
|
||||||
view.lang = lang.lang();
|
view.lang = lang.lang();
|
||||||
@@ -126,6 +127,7 @@ impl App {
|
|||||||
download_draft: pages::InstallDraft::default(),
|
download_draft: pages::InstallDraft::default(),
|
||||||
launch_form: pages::LaunchForm::default(),
|
launch_form: pages::LaunchForm::default(),
|
||||||
install_cancelled_seen: 0,
|
install_cancelled_seen: 0,
|
||||||
|
launch_ends_seen: 0,
|
||||||
content: pages::content::PageState::default(),
|
content: pages::content::PageState::default(),
|
||||||
account_form: pages::account::Form::new(),
|
account_form: pages::account::Form::new(),
|
||||||
account: pages::account::Page::build(
|
account: pages::account::Page::build(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use unlucky_presenter::toast::ToastKind;
|
use unlucky_presenter::toast::ToastKind;
|
||||||
|
|
||||||
|
use crate::app::view;
|
||||||
use crate::pages;
|
use crate::pages;
|
||||||
|
|
||||||
use unlucky_presenter::router::Route;
|
use unlucky_presenter::router::Route;
|
||||||
@@ -36,6 +37,23 @@ impl App {
|
|||||||
self.state.toasts.push(ToastKind::Info, text);
|
self.state.toasts.push(ToastKind::Info, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M161:启动收尾的提示语也在这里弹(presenter 只发结构:结局 + 退出码 + 失败阶段)。
|
||||||
|
// 此前 presenter 直接推一条自己拼好的中文 Toast,英文界面会弹中文。
|
||||||
|
if self.state.launch_ends != self.launch_ends_seen {
|
||||||
|
self.launch_ends_seen = self.state.launch_ends;
|
||||||
|
if let Some(finished) = self.state.last_launch_end.as_deref() {
|
||||||
|
let text = view::instances::launch_toast(finished, self.lang());
|
||||||
|
let kind = if finished.outcome == unlucky_presenter::dto::LogOutcome::Ok
|
||||||
|
&& finished.failure.is_none()
|
||||||
|
{
|
||||||
|
ToastKind::Success
|
||||||
|
} else {
|
||||||
|
ToastKind::Error
|
||||||
|
};
|
||||||
|
self.state.toasts.push(kind, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
|
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
|
||||||
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
|
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
|
||||||
let category = self.view.download.category;
|
let category = self.view.download.category;
|
||||||
@@ -48,6 +66,7 @@ impl App {
|
|||||||
self.view = super::super::view::model(
|
self.view = super::super::view::model(
|
||||||
&self.state,
|
&self.state,
|
||||||
width,
|
width,
|
||||||
|
self.height,
|
||||||
self.options.force_state,
|
self.options.force_state,
|
||||||
category,
|
category,
|
||||||
&draft,
|
&draft,
|
||||||
|
|||||||
@@ -20,8 +20,18 @@ impl App {
|
|||||||
/// 一条更新动作。
|
/// 一条更新动作。
|
||||||
pub(super) fn on_update_action(&mut self, action: UpdateAction) -> Task<Message> {
|
pub(super) fn on_update_action(&mut self, action: UpdateAction) -> Task<Message> {
|
||||||
match action {
|
match action {
|
||||||
UpdateAction::CheckNow => self.dispatch(AppCommand::CheckUpdate),
|
UpdateAction::CheckNow => {
|
||||||
UpdateAction::Apply => self.dispatch(AppCommand::ApplyUpdate),
|
if self.update_busy() {
|
||||||
|
return self.refuse_busy("立即检查");
|
||||||
|
}
|
||||||
|
self.dispatch(AppCommand::CheckUpdate)
|
||||||
|
}
|
||||||
|
UpdateAction::Apply => {
|
||||||
|
if self.update_busy() {
|
||||||
|
return self.refuse_busy("下载并安装");
|
||||||
|
}
|
||||||
|
self.dispatch(AppCommand::ApplyUpdate)
|
||||||
|
}
|
||||||
UpdateAction::Dismiss => self.dispatch(AppCommand::DismissUpdate),
|
UpdateAction::Dismiss => self.dispatch(AppCommand::DismissUpdate),
|
||||||
UpdateAction::SetAutoCheck(on) => self.dispatch(AppCommand::SetUpdateAutoCheck(on)),
|
UpdateAction::SetAutoCheck(on) => self.dispatch(AppCommand::SetUpdateAutoCheck(on)),
|
||||||
UpdateAction::RestartNow => return self.restart_now(),
|
UpdateAction::RestartNow => return self.restart_now(),
|
||||||
@@ -29,6 +39,25 @@ impl App {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 有没有一条检查/下载正在跑。
|
||||||
|
///
|
||||||
|
/// 与 `pages::Update::busy()` **同一个判据**(`applying` 或 `status == Checking`):
|
||||||
|
/// presenter 的 `begin_check` / `begin_apply` 在 inflight 时静默 `return None`,
|
||||||
|
/// 所以「忙碌」这件事必须在按下之前就挡住——否则用户点了没反应,还以为按钮坏了(M161)。
|
||||||
|
///
|
||||||
|
/// 这一层是**最后一道**:按钮已经画成禁用(`pages/settings/update.rs` 的 `can_check` /
|
||||||
|
/// `can_apply`),但快捷键/未来新增的入口仍会走到这里。
|
||||||
|
fn update_busy(&self) -> bool {
|
||||||
|
let view = &self.state.update;
|
||||||
|
view.applying || view.outcome.is_checking()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 忙碌时把这条动作丢掉(不派发、不乐观改状态),并在日志里留一行取证。
|
||||||
|
fn refuse_busy(&self, what: &str) -> Task<Message> {
|
||||||
|
eprintln!("[update] 已有一条检查/下载在跑,忽略重复的「{what}」");
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
/// 立即重启:按同样的命令行拉起自己,然后退出。
|
/// 立即重启:按同样的命令行拉起自己,然后退出。
|
||||||
///
|
///
|
||||||
/// 截图通道(`--screenshot`)**不重启**:那条路每跑一次就该落一张图然后退出,重启会覆盖同一
|
/// 截图通道(`--screenshot`)**不重启**:那条路每跑一次就该落一张图然后退出,重启会覆盖同一
|
||||||
@@ -142,6 +171,47 @@ mod tests {
|
|||||||
assert_eq!(last(&seen), AppCommand::SetUpdateAutoCheck(false));
|
assert_eq!(last(&seen), AppCommand::SetUpdateAutoCheck(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **忙碌时按下去不发命令**(M161:此前点了没反应,因为 presenter 在 inflight 时静默返回)。
|
||||||
|
///
|
||||||
|
/// 三条都要钉住:①`Checking` 时「立即检查」不派发;②`applying` 时「下载并安装」不派发;
|
||||||
|
/// ③忙碌结束后**恢回可点**(失败/超时不该把按钮永久钉死)。
|
||||||
|
#[test]
|
||||||
|
fn pressing_while_busy_dispatches_nothing() {
|
||||||
|
let (mut app, seen) = app();
|
||||||
|
|
||||||
|
app.state.update.outcome = unlucky_presenter::dto::UpdateOutcome::Checking;
|
||||||
|
let _ = app.on_update_action(UpdateAction::CheckNow);
|
||||||
|
assert!(
|
||||||
|
seen.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.is_empty(),
|
||||||
|
"正在检查时再按「立即检查」不许派发第二条命令"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 检查回来(失败也算回来):不忙了,能再点。
|
||||||
|
app.state.update.outcome = unlucky_presenter::dto::UpdateOutcome::Failed(
|
||||||
|
unlucky_presenter::dto::UpdateFailureView::new("network", "连不上", true),
|
||||||
|
);
|
||||||
|
let _ = app.on_update_action(UpdateAction::CheckNow);
|
||||||
|
assert_eq!(last(&seen), AppCommand::CheckUpdate, "失败之后要能重试");
|
||||||
|
|
||||||
|
// 下载/校验/替换期间:`Apply` 与 `CheckNow` 都不许派发。
|
||||||
|
let before = seen.lock().unwrap_or_else(|p| p.into_inner()).len();
|
||||||
|
app.state.update.applying = true;
|
||||||
|
let _ = app.on_update_action(UpdateAction::Apply);
|
||||||
|
let _ = app.on_update_action(UpdateAction::CheckNow);
|
||||||
|
assert_eq!(
|
||||||
|
seen.lock().unwrap_or_else(|p| p.into_inner()).len(),
|
||||||
|
before,
|
||||||
|
"applying 期间不许派发任何新的检查/安装"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 应用结束:恢复可点。
|
||||||
|
app.state.update.applying = false;
|
||||||
|
let _ = app.on_update_action(UpdateAction::Apply);
|
||||||
|
assert_eq!(last(&seen), AppCommand::ApplyUpdate, "失败/完成后恢回可点");
|
||||||
|
}
|
||||||
|
|
||||||
/// 截图通道不重启:取证进程不许把自己重启成死循环。
|
/// 截图通道不重启:取证进程不许把自己重启成死循环。
|
||||||
#[test]
|
#[test]
|
||||||
fn the_screenshot_channel_never_restarts() {
|
fn the_screenshot_channel_never_restarts() {
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ use super::state::{log_first_frame, App};
|
|||||||
use super::APP_TITLE;
|
use super::APP_TITLE;
|
||||||
|
|
||||||
mod account_page;
|
mod account_page;
|
||||||
mod instances;
|
pub(super) mod instances;
|
||||||
mod logs;
|
mod logs;
|
||||||
mod rail;
|
mod rail;
|
||||||
mod update;
|
mod update;
|
||||||
|
|
||||||
use instances::{detail_of, runtime_status, supporting_line};
|
use instances::{detail_of, runtime_status_row, supporting_line};
|
||||||
use logs::logs_model;
|
use logs::logs_model;
|
||||||
use update::update_of;
|
use update::update_of;
|
||||||
|
|
||||||
@@ -140,6 +140,7 @@ impl App {
|
|||||||
pub(super) fn model(
|
pub(super) fn model(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
width: f32,
|
width: f32,
|
||||||
|
height: f32,
|
||||||
forced: Option<crate::cli::ForcedState>,
|
forced: Option<crate::cli::ForcedState>,
|
||||||
category: pages::Category,
|
category: pages::Category,
|
||||||
draft: &pages::InstallDraft,
|
draft: &pages::InstallDraft,
|
||||||
@@ -158,7 +159,7 @@ pub(super) fn model(
|
|||||||
id: cell.instance.id.clone(),
|
id: cell.instance.id.clone(),
|
||||||
name: cell.instance.name.clone(),
|
name: cell.instance.name.clone(),
|
||||||
supporting: supporting_line(cell.instance, catalog),
|
supporting: supporting_line(cell.instance, catalog),
|
||||||
status: runtime_status(&cell.instance.runtime, catalog),
|
status: runtime_status_row(&cell.instance.runtime, catalog),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -174,6 +175,7 @@ pub(super) fn model(
|
|||||||
pages::Model {
|
pages::Model {
|
||||||
screen,
|
screen,
|
||||||
window_width: width,
|
window_width: width,
|
||||||
|
window_height: height,
|
||||||
active: route.nav(),
|
active: route.nav(),
|
||||||
instances: pages::Instances { rows, detail },
|
instances: pages::Instances { rows, detail },
|
||||||
download: pages::Download::from_snapshot(state, category, draft, lang.catalog()),
|
download: pages::Download::from_snapshot(state, category, draft, lang.catalog()),
|
||||||
|
|||||||
@@ -18,8 +18,11 @@
|
|||||||
//! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、
|
//! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、
|
||||||
//! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。
|
//! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。
|
||||||
|
|
||||||
use unlucky_i18n::{Catalog, Key};
|
use unlucky_i18n::{Catalog, Key, Lang};
|
||||||
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind};
|
use unlucky_presenter::dto::{
|
||||||
|
InstanceRuntime, InstanceSummary, LaunchFailureStage, LaunchFinished, LaunchStage, LoaderKind,
|
||||||
|
LogOutcome,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::pages::{Detail, LaunchFacts, Meta};
|
use crate::pages::{Detail, LaunchFacts, Meta};
|
||||||
|
|
||||||
@@ -59,7 +62,7 @@ fn isolation(raw: &str, catalog: Catalog) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
|
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
|
||||||
pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
|
pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
|
||||||
match runtime {
|
match runtime {
|
||||||
InstanceRuntime::Idle => None,
|
InstanceRuntime::Idle => None,
|
||||||
// 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言
|
// 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言
|
||||||
@@ -71,13 +74,78 @@ pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Opt
|
|||||||
InstanceRuntime::Running { pid } => {
|
InstanceRuntime::Running { pid } => {
|
||||||
Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())]))
|
Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())]))
|
||||||
}
|
}
|
||||||
// `reason` 是 core 给的诊断原文(**数据**,不翻译);只有前半句是界面文案
|
// `detail` 是 core/io 给的原文(**数据**,不翻译);「哪一步」与前半句是界面文案。
|
||||||
InstanceRuntime::Failed { reason } => {
|
// M161 之前这里只有 `reason`,而 reason 里已经是「启动失败:…」,于是英文界面会出现
|
||||||
Some(catalog.render(Key::InstancesFailed, &[("reason", reason)]))
|
// 「Launch failed · 启动失败:找不到 Java」——半句英文半句中文。
|
||||||
}
|
//
|
||||||
|
// 这一份是**详情页**用的全文(详情列会自动换行);列表行那份是 [`runtime_status_row`]
|
||||||
|
// (单行、不许折行,见 D-M79-5)。
|
||||||
|
InstanceRuntime::Failed { stage, detail } => Some(catalog.render(
|
||||||
|
Key::LaunchStageFailed,
|
||||||
|
&[
|
||||||
|
("stage", failure_stage_label(*stage, catalog)),
|
||||||
|
("detail", detail.as_str()),
|
||||||
|
],
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 列表行用的运行状态:**单行**(`ListItem` 的 overline 不折行,D-M79-5)。
|
||||||
|
///
|
||||||
|
/// 与 [`runtime_status`] 只差一处:失败时不带 `detail`——那一段是原始错误文本(可能是一长串
|
||||||
|
/// 路径),放进 60/76 高的单行 overline 会被裁成半截字。全文在**详情页**([`runtime_status`])。
|
||||||
|
pub(crate) fn runtime_status_row(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
|
||||||
|
match runtime {
|
||||||
|
InstanceRuntime::Failed { stage, .. } => Some(catalog.render(
|
||||||
|
Key::InstancesFailedStage,
|
||||||
|
&[("stage", failure_stage_label(*stage, catalog))],
|
||||||
|
)),
|
||||||
|
other => runtime_status(other, catalog),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 失败阶段文案(presenter 给 slug,界面在这里选词)。
|
||||||
|
///
|
||||||
|
/// 与 [`stage_label`] 同一套做法:**不**复用 presenter 侧的任何中文常量。
|
||||||
|
pub(crate) fn failure_stage_label(stage: LaunchFailureStage, catalog: Catalog) -> &'static str {
|
||||||
|
catalog.text(match stage {
|
||||||
|
LaunchFailureStage::VersionManifest => Key::LaunchStageVersionManifest,
|
||||||
|
LaunchFailureStage::Inheritance => Key::LaunchStageInheritance,
|
||||||
|
LaunchFailureStage::InstanceConfig => Key::LaunchStageInstanceConfig,
|
||||||
|
LaunchFailureStage::Isolation => Key::LaunchStageIsolation,
|
||||||
|
LaunchFailureStage::Identity => Key::LaunchStageIdentity,
|
||||||
|
LaunchFailureStage::Spawn => Key::LaunchStageSpawn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次启动收尾的 Toast 文案(**唯一的产地**:presenter 只发结构,不拼句子)。
|
||||||
|
///
|
||||||
|
/// 三种结局各有各的说法:正常结束报时长,崩溃/静默失败报「结局 + 阶段 + 原文」,
|
||||||
|
/// 组装阶段就失败(`failure` 有值)报「哪一步 + 原文」。
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn launch_toast(finished: &LaunchFinished, lang: Lang) -> String {
|
||||||
|
let catalog = lang.catalog();
|
||||||
|
if let Some(failure) = &finished.failure {
|
||||||
|
return catalog.render(
|
||||||
|
Key::LaunchStageFailed,
|
||||||
|
&[
|
||||||
|
("stage", failure_stage_label(failure.stage, catalog)),
|
||||||
|
("detail", failure.detail.as_str()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let outcome = catalog.text(match finished.outcome {
|
||||||
|
LogOutcome::Ok => Key::LaunchOutcomeNormal,
|
||||||
|
LogOutcome::Crashed => Key::LaunchOutcomeCrash,
|
||||||
|
LogOutcome::SilentFail => Key::LaunchOutcomeSilent,
|
||||||
|
});
|
||||||
|
let seconds = format!("{:.1}", finished.uptime_ms as f64 / 1000.0);
|
||||||
|
catalog.render(
|
||||||
|
Key::LaunchToastEnded,
|
||||||
|
&[("outcome", outcome), ("seconds", seconds.as_str())],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// 阶段文案:**不**复用 `LaunchStage::label()`(那是核心层的中文诊断串,英文界面会漏中文)。
|
/// 阶段文案:**不**复用 `LaunchStage::label()`(那是核心层的中文诊断串,英文界面会漏中文)。
|
||||||
fn stage_label(stage: LaunchStage, catalog: Catalog) -> &'static str {
|
fn stage_label(stage: LaunchStage, catalog: Catalog) -> &'static str {
|
||||||
catalog.text(match stage {
|
catalog.text(match stage {
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ fn crashed_only() -> AppState {
|
|||||||
id: "dev".to_string(),
|
id: "dev".to_string(),
|
||||||
session: CRASH.to_string(),
|
session: CRASH.to_string(),
|
||||||
outcome: LogOutcome::Crashed,
|
outcome: LogOutcome::Crashed,
|
||||||
summary: "崩溃(exit 1)".to_string(),
|
exit_code: Some(1),
|
||||||
|
uptime_ms: 3_000,
|
||||||
|
failure: None,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
state
|
state
|
||||||
@@ -166,7 +168,9 @@ fn an_imported_crash_file_still_shows_its_findings() {
|
|||||||
id: "dev".to_string(),
|
id: "dev".to_string(),
|
||||||
session: CRASH.to_string(),
|
session: CRASH.to_string(),
|
||||||
outcome: LogOutcome::Crashed,
|
outcome: LogOutcome::Crashed,
|
||||||
summary: "崩溃(exit 1)".to_string(),
|
exit_code: Some(1),
|
||||||
|
uptime_ms: 3_000,
|
||||||
|
failure: None,
|
||||||
}));
|
}));
|
||||||
state.apply_core(CoreEvent::DiagnosisReady(report(
|
state.apply_core(CoreEvent::DiagnosisReady(report(
|
||||||
"file:crash-2026-11-02_19.40.15-client.txt",
|
"file:crash-2026-11-02_19.40.15-client.txt",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ fn at_lang(state: &AppState, forced: Option<crate::cli::ForcedState>, lang: Lang
|
|||||||
model(
|
model(
|
||||||
state,
|
state,
|
||||||
1180.0,
|
1180.0,
|
||||||
|
760.0,
|
||||||
forced,
|
forced,
|
||||||
pages::Category::All,
|
pages::Category::All,
|
||||||
&draft,
|
&draft,
|
||||||
@@ -125,11 +126,18 @@ fn a_busy_instance_offers_stop_instead_of_play() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn failed_instances_can_be_retried() {
|
fn failed_instances_can_be_retried() {
|
||||||
let failed = instance(InstanceRuntime::Failed {
|
let failed = instance(InstanceRuntime::Failed {
|
||||||
reason: "exit 1".to_string(),
|
stage: unlucky_presenter::dto::LaunchFailureStage::Spawn,
|
||||||
|
detail: "exit 1".to_string(),
|
||||||
});
|
});
|
||||||
|
// 列表行是**单行**:只放「哪一步」(原文太长会被 `Wrapping::None` 裁成半截字)。
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
|
super::instances::runtime_status_row(&failed.runtime, Lang::En.catalog()).as_deref(),
|
||||||
Some("Launch failed · exit 1")
|
Some("Launch failed · Starting the game process")
|
||||||
|
);
|
||||||
|
// 详情页给全文(阶段 + 原文)。
|
||||||
|
assert_eq!(
|
||||||
|
super::instances::runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
|
||||||
|
Some("Launch failed · Starting the game process: exit 1")
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
detail_of(&failed, Lang::En.catalog()).can_launch,
|
detail_of(&failed, Lang::En.catalog()).can_launch,
|
||||||
@@ -319,3 +327,67 @@ fn the_agent_route_has_no_page() {
|
|||||||
assert!(model.instances.detail.is_none());
|
assert!(model.instances.detail.is_none());
|
||||||
assert_eq!(model.active, None, "AI 页不高亮任何导航项");
|
assert_eq!(model.active, None, "AI 页不高亮任何导航项");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **启动收尾的提示语跟着界面语言走**(M161 收的债)。
|
||||||
|
///
|
||||||
|
/// 此前 `LaunchFinished.summary` 是 presenter 拼的中文,英文界面上弹的就是中文;
|
||||||
|
/// 现在 presenter 只给结构(结局 + 退出码 + 失败阶段),文案在这里按 key 现求。
|
||||||
|
///
|
||||||
|
/// 抓得住:①谁把中文串再写回 presenter(英文那一行会露中文);②失败时丢掉 `detail`
|
||||||
|
/// (原文是取证信息,不许翻也不许删);③「跑起来再崩」被说成「启动失败在某一步」。
|
||||||
|
#[test]
|
||||||
|
fn the_launch_toast_speaks_the_interface_language() {
|
||||||
|
use unlucky_presenter::dto::{
|
||||||
|
LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome,
|
||||||
|
};
|
||||||
|
|
||||||
|
let finished = |outcome, failure| LaunchFinished {
|
||||||
|
id: "survival".to_string(),
|
||||||
|
session: "20261031T120000-abcd12".to_string(),
|
||||||
|
outcome,
|
||||||
|
exit_code: Some(1),
|
||||||
|
uptime_ms: 12_500,
|
||||||
|
failure,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 正常结束:只报结局 + 时长(`正常结束 · 12.5 秒` / `Finished normally · 12.5s`)。
|
||||||
|
let ok = finished(LogOutcome::Ok, None);
|
||||||
|
let en = super::instances::launch_toast(&ok, Lang::En);
|
||||||
|
let zh = super::instances::launch_toast(&ok, Lang::ZhCn);
|
||||||
|
assert_eq!(en, "Finished normally · 12.5s");
|
||||||
|
assert_eq!(zh, "正常结束 · 12.5 秒");
|
||||||
|
assert_ne!(en, zh, "切语言时提示语要变");
|
||||||
|
|
||||||
|
// 装配阶段失败:说清**哪一步**,并把原文原样带出来。
|
||||||
|
let failed = finished(
|
||||||
|
LogOutcome::Crashed,
|
||||||
|
Some(LaunchFailureView::new(
|
||||||
|
LaunchFailureStage::InstanceConfig,
|
||||||
|
"permission denied (os error 5)",
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
let en = super::instances::launch_toast(&failed, Lang::En);
|
||||||
|
let zh = super::instances::launch_toast(&failed, Lang::ZhCn);
|
||||||
|
assert!(
|
||||||
|
en.starts_with("Launch failed · Reading the instance settings"),
|
||||||
|
"{en}"
|
||||||
|
);
|
||||||
|
assert!(zh.starts_with("启动失败 · 读实例设置"), "{zh}");
|
||||||
|
for text in [&en, &zh] {
|
||||||
|
assert!(
|
||||||
|
text.contains("permission denied (os error 5)"),
|
||||||
|
"原文(取证信息)不许丢:{text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 英文界面里不许出现中文(反之亦然)——这条是本次债的核心。
|
||||||
|
assert!(!en.chars().any(is_cjk), "英文提示里混了中文:{en}");
|
||||||
|
assert!(
|
||||||
|
!zh.contains("Launch failed"),
|
||||||
|
"中文提示里混了英文前缀:{zh}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 有没有中文字符(上面那条「不许混语言」的判据)。
|
||||||
|
fn is_cjk(ch: char) -> bool {
|
||||||
|
('\u{4e00}'..='\u{9fff}').contains(&ch)
|
||||||
|
}
|
||||||
@@ -81,8 +81,9 @@ pub fn view<'a>(
|
|||||||
metrics: FrameMetrics,
|
metrics: FrameMetrics,
|
||||||
model: &'a Content,
|
model: &'a Content,
|
||||||
lang: Lang,
|
lang: Lang,
|
||||||
|
window_height: f32,
|
||||||
) -> Element<'a, PageMessage> {
|
) -> Element<'a, PageMessage> {
|
||||||
view::view(ui, metrics, model, lang)
|
view::view(ui, metrics, model, lang, window_height)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 演示数据(`pages_demo` 出图与单测用;产品路径走 `Content::from_snapshot`)。
|
/// 演示数据(`pages_demo` 出图与单测用;产品路径走 `Content::from_snapshot`)。
|
||||||
|
|||||||
@@ -74,6 +74,53 @@ impl Tier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 主列在**矮窗口**下切成「整列一个滚动条」的门槛(M161)。
|
||||||
|
///
|
||||||
|
/// ## 为什么需要这条门槛
|
||||||
|
///
|
||||||
|
/// 主列自上而下是:搜索条(48)+ 筛选四排(4×32 + 3×8 = 152)+ 计数头(20)+ 结果列表 + 「加载更多」(40)
|
||||||
|
/// 再加两条 `Space::X2` 间距。列表自己没有最小高度,于是窗口一矮,**列表视口只剩几十像素**:
|
||||||
|
/// 一行 `ListItem` 是 60/76 高,被切成半行——两行文字(overline + 标题)看起来像「折行挤压」。
|
||||||
|
///
|
||||||
|
/// ## 明确的两档行为(不是到处加滚动条)
|
||||||
|
///
|
||||||
|
/// | 正文可用高 | 版面 |
|
||||||
|
/// |---|---|
|
||||||
|
/// | ≥ [`MIN_BODY_HEIGHT`] | 现状:**列表自己滚**(筛选条常驻,滚的时候不跑掉) |
|
||||||
|
/// | < [`MIN_BODY_HEIGHT`] | 主列整列变成**一个**纵向滚动区:列表按自然高度铺开,一行都不压 |
|
||||||
|
///
|
||||||
|
/// 阈值 = 固定件(48 + 152 + 20 + 40 + 间距 ≈ 276)+ 至少三整行(3×60 = 180)= 456,
|
||||||
|
/// 取 **460**(往上抹到 4 的倍数,给 divider / 取整留余量)。数值改了就改这里的算式与
|
||||||
|
/// `layout/tests` 的等式用例。
|
||||||
|
pub const MIN_BODY_HEIGHT: f32 = 460.0;
|
||||||
|
|
||||||
|
/// 正文高度的两档行为。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BodyMode {
|
||||||
|
/// 高够:列表自己滚(筛选条常驻)
|
||||||
|
Split,
|
||||||
|
/// 矮窗口:主列整列一个滚动区,列表按自然高度铺开
|
||||||
|
ScrollWhole,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BodyMode {
|
||||||
|
/// 判据(纯函数,测得到)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn of(body_height: f32) -> Self {
|
||||||
|
if body_height >= MIN_BODY_HEIGHT {
|
||||||
|
Self::Split
|
||||||
|
} else {
|
||||||
|
Self::ScrollWhole
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列表要不要自己滚。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn list_scrolls(self) -> bool {
|
||||||
|
matches!(self, Self::Split)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `Wide` 的门槛:标签栏 + 间距 + 列表下限 + 间距 + 详情。
|
/// `Wide` 的门槛:标签栏 + 间距 + 列表下限 + 间距 + 详情。
|
||||||
pub fn wide_min() -> f32 {
|
pub fn wide_min() -> f32 {
|
||||||
RAIL_LABEL + GAP + layout::LINE_SAFE_WIDTH_PX + GAP + DETAIL
|
RAIL_LABEL + GAP + layout::LINE_SAFE_WIDTH_PX + GAP + DETAIL
|
||||||
@@ -192,3 +239,49 @@ mod tests {
|
|||||||
assert!(!Tier::Narrow.rail_has_labels());
|
assert!(!Tier::Narrow.rail_has_labels());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod body_mode_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// 门槛是**算出来的**:固定件(搜索 48 + 筛选 152 + 计数 20 + 加载更多 40 + 两条 8 间距)
|
||||||
|
/// 加三整行列表(3 × 60)。谁把 460 改成拍脑袋的数,这条会红。
|
||||||
|
#[test]
|
||||||
|
fn the_minimum_body_height_is_fixed_parts_plus_three_rows() {
|
||||||
|
let fixed = 48.0 + 152.0 + 20.0 + 40.0 + 2.0 * Space::X2.dp();
|
||||||
|
let three_rows = 3.0 * 60.0;
|
||||||
|
assert!(
|
||||||
|
MIN_BODY_HEIGHT >= fixed + three_rows,
|
||||||
|
"门槛 {MIN_BODY_HEIGHT} 小于「固定件 {fixed} + 三行 {three_rows}」——矮窗口还是会切半行"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
MIN_BODY_HEIGHT <= fixed + three_rows + 16.0,
|
||||||
|
"门槛留了太多余量:{} 会把还放得下的窗口也推进「整列滚动」档",
|
||||||
|
MIN_BODY_HEIGHT - (fixed + three_rows)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 两档在门槛处切开,且只有 `Split` 让列表自己滚。
|
||||||
|
#[test]
|
||||||
|
fn the_body_mode_flips_at_the_threshold() {
|
||||||
|
assert_eq!(BodyMode::of(MIN_BODY_HEIGHT), BodyMode::Split);
|
||||||
|
assert_eq!(BodyMode::of(MIN_BODY_HEIGHT - 0.5), BodyMode::ScrollWhole);
|
||||||
|
assert!(BodyMode::of(900.0).list_scrolls());
|
||||||
|
assert!(!BodyMode::of(300.0).list_scrolls());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 窗口最小尺寸(880×560)落在**矮**档:这是本条债的真实场景,别让它悄悄变回 `Split`。
|
||||||
|
#[test]
|
||||||
|
fn the_minimum_window_size_uses_the_scrolling_body() {
|
||||||
|
// 560 窗口 − 顶栏 56 − 页头 88(两行标题 + 留白)− 上下页边距 2×16 ≈ 384
|
||||||
|
let body = 560.0 - 56.0 - 88.0 - 32.0;
|
||||||
|
assert_eq!(
|
||||||
|
BodyMode::of(body),
|
||||||
|
BodyMode::ScrollWhole,
|
||||||
|
"最小窗口(560 高)必须走整列滚动:正文只剩 {body}"
|
||||||
|
);
|
||||||
|
// 验收尺寸 760 高 → 约 584,仍走「列表自己滚」。
|
||||||
|
let body = 760.0 - 56.0 - 88.0 - 32.0;
|
||||||
|
assert_eq!(BodyMode::of(body), BodyMode::Split, "验收尺寸不该换版面");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,8 @@ use unlucky_i18n::Lang;
|
|||||||
use unlucky_presenter::dto::{
|
use unlucky_presenter::dto::{
|
||||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView,
|
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView,
|
||||||
ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportErrorView,
|
ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportErrorView,
|
||||||
ImportProgressView, ImportReportView, ImportStageView, ImportState, ImportStatusView,
|
ImportFailureStageView, ImportProgressView, ImportReportView, ImportStageView, ImportState,
|
||||||
InstanceId, LoaderKind, MissingFileView, MissingReasonView, PackFormatView,
|
ImportStatusView, InstanceId, LoaderKind, MissingFileView, MissingReasonView, PackFormatView,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::pages::content::Target;
|
use crate::pages::content::Target;
|
||||||
@@ -88,8 +88,22 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) {
|
|||||||
import.report = Some(report(ImportStatusView::Partial, true));
|
import.report = Some(report(ImportStatusView::Partial, true));
|
||||||
page.expanded = Some("config/sodium-options.json".to_string());
|
page.expanded = Some("config/sodium-options.json".to_string());
|
||||||
}
|
}
|
||||||
// 致命态:整包回滚 + 容忍域仍在(展示两类失败的视觉分野)
|
// 致命态(M161 分成三档出图:预检没过 / 已回滚 / 回滚没做完)。
|
||||||
"failed" => {
|
// `failed` 保留旧名字(= 已回滚),另外两个是新档位。
|
||||||
|
"failed" | "failed_rolled_back" => {
|
||||||
|
let mut fatal = report(ImportStatusView::Failed, false);
|
||||||
|
fatal.error = Some(ImportErrorView {
|
||||||
|
code: "import_integrity".to_string(),
|
||||||
|
label: "Integrity check".to_string(),
|
||||||
|
message: "mods/sodium.jar: sha1 mismatch after write".to_string(),
|
||||||
|
hint: Some("Re-download the pack from its source".to_string()),
|
||||||
|
});
|
||||||
|
fatal.rolled_back = true;
|
||||||
|
fatal.failure_stage = Some(ImportFailureStageView::RolledBack);
|
||||||
|
import.report = Some(fatal);
|
||||||
|
}
|
||||||
|
// 预检没过:**一个字节都没写**(与上一条只差 `failure_stage`,图上要能一眼分辨)
|
||||||
|
"failed_preflight" => {
|
||||||
let mut fatal = report(ImportStatusView::Failed, false);
|
let mut fatal = report(ImportStatusView::Failed, false);
|
||||||
fatal.error = Some(ImportErrorView {
|
fatal.error = Some(ImportErrorView {
|
||||||
code: "pack_traversal".to_string(),
|
code: "pack_traversal".to_string(),
|
||||||
@@ -99,6 +113,21 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) {
|
|||||||
});
|
});
|
||||||
fatal.files.clear();
|
fatal.files.clear();
|
||||||
fatal.bytes_written = 0;
|
fatal.bytes_written = 0;
|
||||||
|
fatal.rolled_back = true;
|
||||||
|
fatal.failure_stage = Some(ImportFailureStageView::NotStarted);
|
||||||
|
import.report = Some(fatal);
|
||||||
|
}
|
||||||
|
// 回滚没做完:目标目录可能与导入前不同
|
||||||
|
"failed_incomplete" => {
|
||||||
|
let mut fatal = report(ImportStatusView::Failed, false);
|
||||||
|
fatal.error = Some(ImportErrorView {
|
||||||
|
code: "import_integrity".to_string(),
|
||||||
|
label: "Integrity check".to_string(),
|
||||||
|
message: "mods/sodium.jar: sha1 mismatch after write".to_string(),
|
||||||
|
hint: None,
|
||||||
|
});
|
||||||
|
fatal.rolled_back = false;
|
||||||
|
fatal.failure_stage = Some(ImportFailureStageView::RollbackIncomplete);
|
||||||
import.report = Some(fatal);
|
import.report = Some(fatal);
|
||||||
}
|
}
|
||||||
// 取消态:面板收回了,但「后台还在跑」这句话要留在屏上
|
// 取消态:面板收回了,但「后台还在跑」这句话要留在屏上
|
||||||
@@ -182,6 +211,7 @@ fn report(status: ImportStatusView, conflicts: bool) -> ImportReportView {
|
|||||||
registered: Some("Survival".to_string()),
|
registered: Some("Survival".to_string()),
|
||||||
bytes_written: 3_400_000,
|
bytes_written: 3_400_000,
|
||||||
rolled_back: true,
|
rolled_back: true,
|
||||||
|
failure_stage: None,
|
||||||
};
|
};
|
||||||
if conflicts {
|
if conflicts {
|
||||||
report.conflicts = vec![
|
report.conflicts = vec![
|
||||||
|
|||||||
@@ -23,7 +23,10 @@
|
|||||||
//! ## 容忍 / 致命在模型层就分开
|
//! ## 容忍 / 致命在模型层就分开
|
||||||
//!
|
//!
|
||||||
//! [`report::Report::missing`] 是「搞不到某个文件」(实例可用),[`report::Report::fatal`]
|
//! [`report::Report::missing`] 是「搞不到某个文件」(实例可用),[`report::Report::fatal`]
|
||||||
//! 是「已整包回滚」。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。
|
//! 是致命失败。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。
|
||||||
|
//!
|
||||||
|
//! 致命那一类内部还要再分三档(M161):预检没过(一个字节都没写)/ 已回滚 / 回滚没做完,
|
||||||
|
//! 见 [`report::FatalKind`]——措辞、图标、底色三处都分开。
|
||||||
|
|
||||||
mod export;
|
mod export;
|
||||||
mod pick;
|
mod pick;
|
||||||
@@ -33,7 +36,7 @@ mod report;
|
|||||||
pub use export::{format_label, request_of as request_export, Export, Preview};
|
pub use export::{format_label, request_of as request_export, Export, Preview};
|
||||||
pub use pick::{Pick, PickRow};
|
pub use pick::{Pick, PickRow};
|
||||||
pub use progress::Running;
|
pub use progress::Running;
|
||||||
pub use report::{ConflictRow, Report, Row};
|
pub use report::{ConflictRow, FatalKind, Report, Row};
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
|
|
||||||
use unlucky_i18n::{Catalog, Key};
|
use unlucky_i18n::{Catalog, Key};
|
||||||
use unlucky_presenter::dto::{
|
use unlucky_presenter::dto::{
|
||||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView, ImportReportView,
|
ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView,
|
||||||
ImportStatusView, MissingFileView, MissingReasonView,
|
ImportFailureStageView, ImportReportView, ImportStatusView, MissingFileView, MissingReasonView,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::PackPageState;
|
use super::PackPageState;
|
||||||
@@ -48,10 +48,40 @@ pub struct ConflictRow {
|
|||||||
pub expanded: bool,
|
pub expanded: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 致命失败块(**已整包回滚**)。
|
/// 致命失败块(**两种措辞,不共用**)。
|
||||||
|
///
|
||||||
|
/// 为什么不是一句「失败」:`NotStarted`(预检就没过,一个字节都没写)与 `RolledBack`
|
||||||
|
/// (写进去了,又整包回滚干净)对用户是不同的消息——前者「去修包再试」,后者「刚才动过你的目录,
|
||||||
|
/// 已经复原」。M161 之前两者共用一句「已回滚 —— 目标目录零残留」,预检失败时那句话是假的。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum FatalKind {
|
||||||
|
/// 预检/装配阶段失败:目标目录**一个字节都没写**
|
||||||
|
NotStarted,
|
||||||
|
/// 写过目标目录,回滚**完整**完成
|
||||||
|
RolledBack,
|
||||||
|
/// 写过,但回滚有失败项(目标目录可能与导入前不同)
|
||||||
|
RollbackIncomplete,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FatalKind {
|
||||||
|
/// 图标(与 [`Fatal::title`] 各说一遍:颜色/图标关掉时标题仍然分得清)。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn icon(self) -> unlucky_design::icons::IconKind {
|
||||||
|
match self {
|
||||||
|
// 预检没过 = 「还没开始」,用警示而不是「已出错」:目录是干净的
|
||||||
|
Self::NotStarted => unlucky_design::icons::IconKind::Warn,
|
||||||
|
Self::RolledBack => unlucky_design::icons::IconKind::Restore,
|
||||||
|
Self::RollbackIncomplete => unlucky_design::icons::IconKind::Error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 致命失败块。
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Fatal {
|
pub struct Fatal {
|
||||||
/// 「已回滚 —— 目标目录零残留」
|
/// 三种阶段之一(决定标题/图标/底色)
|
||||||
|
pub kind: FatalKind,
|
||||||
|
/// 标题(预检未通过 / 已回滚 / 回滚不完整)
|
||||||
pub title: String,
|
pub title: String,
|
||||||
/// 大类名(`路径穿越` / `清单损坏` …)
|
/// 大类名(`路径穿越` / `清单损坏` …)
|
||||||
pub label: String,
|
pub label: String,
|
||||||
@@ -124,7 +154,7 @@ pub(super) fn report_of(
|
|||||||
|
|
||||||
Report {
|
Report {
|
||||||
status: report.status,
|
status: report.status,
|
||||||
title: status_title(report.status, catalog).to_owned(),
|
title: status_title(report, catalog).to_owned(),
|
||||||
subtitle: subtitle(report, catalog),
|
subtitle: subtitle(report, catalog),
|
||||||
written_line: catalog.render(
|
written_line: catalog.render(
|
||||||
Key::PackReportWritten,
|
Key::PackReportWritten,
|
||||||
@@ -133,7 +163,10 @@ pub(super) fn report_of(
|
|||||||
("bytes", &human(report)),
|
("bytes", &human(report)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
fatal: report.error.as_ref().map(|error| fatal_of(error, catalog)),
|
fatal: report
|
||||||
|
.error
|
||||||
|
.as_ref()
|
||||||
|
.map(|error| fatal_of(error, report.failure_stage, catalog)),
|
||||||
missing_title,
|
missing_title,
|
||||||
missing: report
|
missing: report
|
||||||
.missing
|
.missing
|
||||||
@@ -177,11 +210,22 @@ fn human(report: &ImportReportView) -> String {
|
|||||||
unlucky_presenter::dto::human_bytes(report.bytes_written)
|
unlucky_presenter::dto::human_bytes(report.bytes_written)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn status_title(status: ImportStatusView, catalog: Catalog) -> &'static str {
|
/// 标题:三态 + **失败阶段**(预检未通过 ≠ 已回滚,M161)。
|
||||||
match status {
|
fn status_title(report: &ImportReportView, catalog: Catalog) -> &'static str {
|
||||||
|
match report.status {
|
||||||
ImportStatusView::Success => catalog.text(Key::PackStatusSuccess),
|
ImportStatusView::Success => catalog.text(Key::PackStatusSuccess),
|
||||||
ImportStatusView::Partial => catalog.text(Key::PackStatusPartial),
|
ImportStatusView::Partial => catalog.text(Key::PackStatusPartial),
|
||||||
ImportStatusView::Failed => catalog.text(Key::PackStatusFailed),
|
ImportStatusView::Failed => match report.failure_stage {
|
||||||
|
Some(ImportFailureStageView::NotStarted) => {
|
||||||
|
catalog.text(Key::PackStatusFailedNotStarted)
|
||||||
|
}
|
||||||
|
Some(ImportFailureStageView::RollbackIncomplete) => {
|
||||||
|
catalog.text(Key::PackStatusFailedIncomplete)
|
||||||
|
}
|
||||||
|
// `None` 不该出现(失败必有阶段);退回「已回滚」这一句是旧行为,
|
||||||
|
// 只有拿不到阶段时才会走到(例如未来的旧版对端)。
|
||||||
|
_ => catalog.text(Key::PackStatusFailed),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,9 +247,24 @@ fn subtitle(report: &ImportReportView, catalog: Catalog) -> String {
|
|||||||
parts.join(" · ")
|
parts.join(" · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fatal_of(error: &ImportErrorView, catalog: Catalog) -> Fatal {
|
fn fatal_of(
|
||||||
|
error: &ImportErrorView,
|
||||||
|
stage: Option<ImportFailureStageView>,
|
||||||
|
catalog: Catalog,
|
||||||
|
) -> Fatal {
|
||||||
|
let kind = match stage {
|
||||||
|
Some(ImportFailureStageView::NotStarted) => FatalKind::NotStarted,
|
||||||
|
Some(ImportFailureStageView::RollbackIncomplete) => FatalKind::RollbackIncomplete,
|
||||||
|
_ => FatalKind::RolledBack,
|
||||||
|
};
|
||||||
|
let title = match kind {
|
||||||
|
FatalKind::NotStarted => catalog.text(Key::PackFatalTitleNotStarted),
|
||||||
|
FatalKind::RolledBack => catalog.text(Key::PackFatalTitle),
|
||||||
|
FatalKind::RollbackIncomplete => catalog.text(Key::PackFatalTitleIncomplete),
|
||||||
|
};
|
||||||
Fatal {
|
Fatal {
|
||||||
title: catalog.text(Key::PackFatalTitle).to_owned(),
|
kind,
|
||||||
|
title: title.to_owned(),
|
||||||
label: error.label.clone(),
|
label: error.label.clone(),
|
||||||
message: error.message.clone(),
|
message: error.message.clone(),
|
||||||
code: error.code.clone(),
|
code: error.code.clone(),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。
|
//! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。
|
||||||
|
|
||||||
mod pick;
|
mod pick;
|
||||||
|
mod stage;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -226,6 +227,7 @@ fn report_keeps_tolerable_and_fatal_apart() {
|
|||||||
registered: None,
|
registered: None,
|
||||||
bytes_written: 0,
|
bytes_written: 0,
|
||||||
rolled_back: true,
|
rolled_back: true,
|
||||||
|
failure_stage: None,
|
||||||
};
|
};
|
||||||
let block = report_of(&report, &PackPageState::default(), catalog());
|
let block = report_of(&report, &PackPageState::default(), catalog());
|
||||||
assert!(block.fatal.is_some(), "致命块必须单独存在");
|
assert!(block.fatal.is_some(), "致命块必须单独存在");
|
||||||
@@ -262,6 +264,7 @@ fn conflicts_default_to_keeping_the_user_copy() {
|
|||||||
registered: None,
|
registered: None,
|
||||||
bytes_written: 0,
|
bytes_written: 0,
|
||||||
rolled_back: true,
|
rolled_back: true,
|
||||||
|
failure_stage: None,
|
||||||
};
|
};
|
||||||
// 「以包为准」只在导入到**已有实例**时给(复审 r1 P2-2),所以这里选一个实例
|
// 「以包为准」只在导入到**已有实例**时给(复审 r1 P2-2),所以这里选一个实例
|
||||||
let page = PackPageState {
|
let page = PackPageState {
|
||||||
@@ -359,6 +362,7 @@ fn overwrite_is_offered_only_when_importing_into_an_existing_instance() {
|
|||||||
registered: None,
|
registered: None,
|
||||||
bytes_written: 0,
|
bytes_written: 0,
|
||||||
rolled_back: true,
|
rolled_back: true,
|
||||||
|
failure_stage: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_instance = PackPageState::default();
|
let new_instance = PackPageState::default();
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
//! M161:导入失败的**两种阶段**不许共用一句话。
|
||||||
|
//!
|
||||||
|
//! 从 `tests.rs` 拆出(那边贴着 400 行硬线):本文件只守「预检没过(一个字节都没写)」与
|
||||||
|
//! 「已回滚」的措辞/图标分野。夹具在父模块(`use super::*`)。
|
||||||
|
|
||||||
|
use unlucky_presenter::dto::ImportFailureStageView;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// **「预检没过」与「已回滚」必须各说各的话**(M161 收的债)。
|
||||||
|
///
|
||||||
|
/// 此前两者共用 `rolled_back: true`,于是预检失败(一个字节都没写)也被写成
|
||||||
|
/// 「已回滚 —— 目标目录零残留」——一个没发生的回滚。现在标题、图标、底色三处都分开,
|
||||||
|
/// 且 `NotStarted` 的标题**不许**出现「回滚 / rolled back」这类词(那才是真正的谎)。
|
||||||
|
#[test]
|
||||||
|
fn the_two_failure_stages_never_share_one_sentence() {
|
||||||
|
fn failed(stage: Option<ImportFailureStageView>) -> ImportReportView {
|
||||||
|
ImportReportView {
|
||||||
|
status: ImportStatusView::Failed,
|
||||||
|
format: Some(PackFormatView::Modrinth),
|
||||||
|
pack_name: Some("Broken".to_string()),
|
||||||
|
pack_version: None,
|
||||||
|
game_version: None,
|
||||||
|
loader: None,
|
||||||
|
version_id: None,
|
||||||
|
target: PathBuf::from("C:/tmp"),
|
||||||
|
files: Vec::new(),
|
||||||
|
conflicts: Vec::new(),
|
||||||
|
missing: Vec::new(),
|
||||||
|
rejected: Vec::new(),
|
||||||
|
warnings: Vec::new(),
|
||||||
|
error: Some(ImportErrorView {
|
||||||
|
code: "pack_traversal".to_string(),
|
||||||
|
label: "path traversal".to_string(),
|
||||||
|
message: "../etc/passwd".to_string(),
|
||||||
|
hint: None,
|
||||||
|
}),
|
||||||
|
registered: None,
|
||||||
|
bytes_written: 0,
|
||||||
|
rolled_back: true,
|
||||||
|
failure_stage: stage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let zh = Lang::ZhCn.catalog();
|
||||||
|
let not_started = report_of(
|
||||||
|
&failed(Some(ImportFailureStageView::NotStarted)),
|
||||||
|
&PackPageState::default(),
|
||||||
|
zh,
|
||||||
|
);
|
||||||
|
let rolled_back = report_of(
|
||||||
|
&failed(Some(ImportFailureStageView::RolledBack)),
|
||||||
|
&PackPageState::default(),
|
||||||
|
zh,
|
||||||
|
);
|
||||||
|
let incomplete = report_of(
|
||||||
|
&failed(Some(ImportFailureStageView::RollbackIncomplete)),
|
||||||
|
&PackPageState::default(),
|
||||||
|
zh,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 三种标题互不相同(三态压力:谁把它们折叠回同一句,这里就红)。
|
||||||
|
let titles = [
|
||||||
|
not_started.title.as_str(),
|
||||||
|
rolled_back.title.as_str(),
|
||||||
|
incomplete.title.as_str(),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
titles
|
||||||
|
.iter()
|
||||||
|
.collect::<std::collections::HashSet<_>>()
|
||||||
|
.len(),
|
||||||
|
3,
|
||||||
|
"三种失败阶段的标题撞了:{titles:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 「预检没过」那一句里不许出现「回滚」(那是没发生的事)。
|
||||||
|
let fatal = not_started.fatal.expect("致命块");
|
||||||
|
assert_eq!(
|
||||||
|
fatal.kind,
|
||||||
|
crate::pages::content::packui::model::FatalKind::NotStarted
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!fatal.title.contains("回滚"),
|
||||||
|
"预检失败不该说「回滚」:{}",
|
||||||
|
fatal.title
|
||||||
|
);
|
||||||
|
assert!(!not_started.title.contains("回滚"), "{}", not_started.title);
|
||||||
|
// 已回滚那一句必须说清「回滚」这件事。
|
||||||
|
assert!(
|
||||||
|
rolled_back.title.contains("回滚"),
|
||||||
|
"已回滚那句要说明回滚:{}",
|
||||||
|
rolled_back.title
|
||||||
|
);
|
||||||
|
// 图标分开:预检没过不是「错误」字形(目录没动过),回滚不完整才是。
|
||||||
|
assert_ne!(
|
||||||
|
crate::pages::content::packui::model::FatalKind::NotStarted.icon(),
|
||||||
|
crate::pages::content::packui::model::FatalKind::RolledBack.icon(),
|
||||||
|
"两种阶段的图标不许一样"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 成功/部分成功没有「失败阶段」可言。
|
||||||
|
let mut ok = failed(None);
|
||||||
|
ok.status = ImportStatusView::Success;
|
||||||
|
ok.error = None;
|
||||||
|
assert!(report_of(&ok, &PackPageState::default(), zh)
|
||||||
|
.fatal
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ use crate::pages::frame;
|
|||||||
use crate::pages::Message as PageMessage;
|
use crate::pages::Message as PageMessage;
|
||||||
use crate::theme::Ui;
|
use crate::theme::Ui;
|
||||||
|
|
||||||
use super::super::model::{ConflictRow, Report};
|
use super::super::model::{ConflictRow, FatalKind, Report};
|
||||||
use super::super::PackMessage;
|
use super::super::PackMessage;
|
||||||
use super::intent;
|
use super::intent;
|
||||||
use super::parts::{bounded, marker_box, rows_block, tolerable_title};
|
use super::parts::{bounded, marker_box, rows_block, tolerable_title};
|
||||||
@@ -62,34 +62,46 @@ pub(super) fn report_block<'a>(
|
|||||||
.width(Length::Fill);
|
.width(Length::Fill);
|
||||||
|
|
||||||
if let Some(fatal) = &report.fatal {
|
if let Some(fatal) = &report.fatal {
|
||||||
|
// 三种阶段各自的底色/图标:预检没过用 `SecondaryContainer`(**不是错的颜色**——
|
||||||
|
// 目标目录一个字节都没写,没什么可修的残留),已回滚用 `ErrorContainer`,
|
||||||
|
// 回滚不完整留 `ErrorContainer`(它才是真的要对一眼目录的状态)。
|
||||||
|
let (role, on_role) = match fatal.kind {
|
||||||
|
FatalKind::NotStarted => (Role::SecondaryContainer, Role::OnSecondaryContainer),
|
||||||
|
_ => (Role::ErrorContainer, Role::OnErrorContainer),
|
||||||
|
};
|
||||||
block = block.push(marker_box(
|
block = block.push(marker_box(
|
||||||
ui,
|
ui,
|
||||||
Role::ErrorContainer,
|
role,
|
||||||
column![
|
row![
|
||||||
frame::text_role(
|
frame::glyph(fatal.kind.icon(), ui.color(on_role), frame::ICON_PX),
|
||||||
ui,
|
column![
|
||||||
TypeRole::LabelLarge,
|
frame::text_role(
|
||||||
true,
|
ui,
|
||||||
fatal.title.as_str(),
|
TypeRole::LabelLarge,
|
||||||
ui.color(Role::OnErrorContainer),
|
true,
|
||||||
),
|
fatal.title.as_str(),
|
||||||
frame::text_role_owned(
|
ui.color(on_role),
|
||||||
ui,
|
),
|
||||||
TypeRole::BodyMedium,
|
frame::text_role_owned(
|
||||||
false,
|
ui,
|
||||||
format!("{} · {}", fatal.label, fatal.code),
|
TypeRole::BodyMedium,
|
||||||
ui.color(Role::OnErrorContainer),
|
false,
|
||||||
),
|
format!("{} · {}", fatal.label, fatal.code),
|
||||||
frame::text_role(
|
ui.color(on_role),
|
||||||
ui,
|
),
|
||||||
TypeRole::BodySmall,
|
frame::text_role(
|
||||||
false,
|
ui,
|
||||||
fatal.message.as_str(),
|
TypeRole::BodySmall,
|
||||||
ui.color(Role::OnErrorContainer),
|
false,
|
||||||
),
|
fatal.message.as_str(),
|
||||||
|
ui.color(on_role),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.spacing(ui.space(Space::X1))
|
||||||
|
.width(Length::Fill),
|
||||||
]
|
]
|
||||||
.spacing(ui.space(Space::X1))
|
.spacing(ui.space(Space::X2))
|
||||||
.width(Length::Fill),
|
.align_y(Alignment::Start),
|
||||||
));
|
));
|
||||||
if let Some(hint) = &fatal.hint {
|
if let Some(hint) = &fatal.hint {
|
||||||
block = block.push(
|
block = block.push(
|
||||||
|
|||||||
@@ -98,12 +98,18 @@ pub(super) fn search<'a>(
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 结果列表(独立滚动;底部「加载更多」钉在列表之外,见 [`more`])。
|
/// 结果列表。
|
||||||
|
///
|
||||||
|
/// 两档(M161,判据同 [`super::layout::BodyMode`]):
|
||||||
|
///
|
||||||
|
/// * `mode.list_scrolls()`:列表**自己滚**(筛选条常驻,高窗口的常态);
|
||||||
|
/// * 否则:行按自然高度铺开,滚动交给外层主列——矮窗口里一行 `ListItem` 不许被压成半行。
|
||||||
pub(super) fn list<'a>(
|
pub(super) fn list<'a>(
|
||||||
ui: &'a Ui,
|
ui: &'a Ui,
|
||||||
catalog: Catalog,
|
catalog: Catalog,
|
||||||
model: &'a Content,
|
model: &'a Content,
|
||||||
rails: Rails,
|
rails: Rails,
|
||||||
|
mode: super::layout::BodyMode,
|
||||||
) -> Element<'a, PageMessage> {
|
) -> Element<'a, PageMessage> {
|
||||||
let rows = column(
|
let rows = column(
|
||||||
model
|
model
|
||||||
@@ -117,20 +123,32 @@ pub(super) fn list<'a>(
|
|||||||
.width(Length::Fill);
|
.width(Length::Fill);
|
||||||
|
|
||||||
let _ = catalog;
|
let _ = catalog;
|
||||||
container(
|
let inner: Element<'a, PageMessage> = if mode.list_scrolls() {
|
||||||
scrollable(rows)
|
container(
|
||||||
.direction(scrollable::Direction::Vertical(
|
scrollable(rows)
|
||||||
scrollable::Scrollbar::new()
|
.direction(scrollable::Direction::Vertical(
|
||||||
.width(frame::SCROLLBAR_WIDTH)
|
scrollable::Scrollbar::new()
|
||||||
.scroller_width(frame::SCROLLBAR_WIDTH)
|
.width(frame::SCROLLBAR_WIDTH)
|
||||||
.margin(frame::SCROLLBAR_MARGIN),
|
.scroller_width(frame::SCROLLBAR_WIDTH)
|
||||||
))
|
.margin(frame::SCROLLBAR_MARGIN),
|
||||||
.height(Length::Fill)
|
))
|
||||||
.style(frame::scroll_style(ui)),
|
.height(Length::Fill)
|
||||||
)
|
.style(frame::scroll_style(ui)),
|
||||||
.width(Length::Fixed(rails.list))
|
)
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.into()
|
.into()
|
||||||
|
} else {
|
||||||
|
// 自然高度(不给 `Fill`):外层主列的那个滚动条负责滚。
|
||||||
|
rows.into()
|
||||||
|
};
|
||||||
|
container(inner)
|
||||||
|
.width(Length::Fixed(rails.list))
|
||||||
|
.height(if mode.list_scrolls() {
|
||||||
|
Length::Fill
|
||||||
|
} else {
|
||||||
|
Length::Shrink
|
||||||
|
})
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hit_row<'a>(ui: &'a Ui, model: &Content, hit: &'a Hit) -> Element<'a, PageMessage> {
|
fn hit_row<'a>(ui: &'a Ui, model: &Content, hit: &'a Hit) -> Element<'a, PageMessage> {
|
||||||
|
|||||||
@@ -49,12 +49,17 @@ pub(super) fn view<'a>(
|
|||||||
metrics: FrameMetrics,
|
metrics: FrameMetrics,
|
||||||
model: &'a Content,
|
model: &'a Content,
|
||||||
lang: unlucky_i18n::Lang,
|
lang: unlucky_i18n::Lang,
|
||||||
|
window_height: f32,
|
||||||
) -> Element<'a, PageMessage> {
|
) -> Element<'a, PageMessage> {
|
||||||
let catalog = lang.catalog();
|
let catalog = lang.catalog();
|
||||||
|
// 正文可用高 → 版面两档(`Split`:列表自己滚;`ScrollWhole`:主列整列一个滚动条)。
|
||||||
|
// 门槛与判据在 [`content_layout`] 里,那里有用例钉住「不算拍脑袋的数」。
|
||||||
|
let body_height = frame::body_height(ui, metrics, window_height, true);
|
||||||
|
let mode = content_layout::BodyMode::of(body_height);
|
||||||
let header = Header::new(catalog.text(Key::ContentTitle))
|
let header = Header::new(catalog.text(Key::ContentTitle))
|
||||||
.subtitle(catalog.text(Key::ContentSubtitle))
|
.subtitle(catalog.text(Key::ContentSubtitle))
|
||||||
.action(parts::section_switcher(ui, catalog, true));
|
.action(parts::section_switcher(ui, catalog, true));
|
||||||
frame::page(ui, metrics, header, body(ui, metrics, catalog, model))
|
frame::page(ui, metrics, header, body(ui, metrics, catalog, model, mode))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn body<'a>(
|
fn body<'a>(
|
||||||
@@ -62,6 +67,7 @@ fn body<'a>(
|
|||||||
metrics: FrameMetrics,
|
metrics: FrameMetrics,
|
||||||
catalog: Catalog,
|
catalog: Catalog,
|
||||||
model: &'a Content,
|
model: &'a Content,
|
||||||
|
mode: content_layout::BodyMode,
|
||||||
) -> Element<'a, PageMessage> {
|
) -> Element<'a, PageMessage> {
|
||||||
let rails = content_layout::resolve(metrics.available);
|
let rails = content_layout::resolve(metrics.available);
|
||||||
|
|
||||||
@@ -75,14 +81,14 @@ fn body<'a>(
|
|||||||
let main = column![
|
let main = column![
|
||||||
kind_rail(ui, catalog, model, rails),
|
kind_rail(ui, catalog, model, rails),
|
||||||
divider::full(ui),
|
divider::full(ui),
|
||||||
main_column(ui, catalog, model, rails)
|
main_column(ui, catalog, model, rails, mode)
|
||||||
]
|
]
|
||||||
.spacing(ui.space(Space::X2))
|
.spacing(ui.space(Space::X2))
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(Length::Fill);
|
.height(Length::Fill);
|
||||||
columns = row![main].width(Length::Fill).height(Length::Fill);
|
columns = row![main].width(Length::Fill).height(Length::Fill);
|
||||||
} else {
|
} else {
|
||||||
columns = columns.push(main_column(ui, catalog, model, rails));
|
columns = columns.push(main_column(ui, catalog, model, rails, mode));
|
||||||
if rails.tier.has_detail() {
|
if rails.tier.has_detail() {
|
||||||
columns = columns.push(detail_column(ui, catalog, model, rails));
|
columns = columns.push(detail_column(ui, catalog, model, rails));
|
||||||
}
|
}
|
||||||
@@ -151,11 +157,19 @@ fn kind_rail<'a>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 主列:搜索条 + 筛选 + 计数 + 结果(或四态之一)。
|
/// 主列:搜索条 + 筛选 + 计数 + 结果(或四态之一)。
|
||||||
|
///
|
||||||
|
/// `mode` 决定**谁滚**(M161,判据在 `content_layout::BodyMode`):
|
||||||
|
///
|
||||||
|
/// * `Split`:列表自己滚(筛选条常驻,高窗口的常态);
|
||||||
|
/// * `ScrollWhole`:矮窗口——主列(含搜索条与筛选)整列进**一个**纵向滚动区,列表按自然高度铺开。
|
||||||
|
/// 这样一行 `ListItem` 不会被压成半行(那看起来就是「折行挤压」),也不会出现「列表里一个滚动条、
|
||||||
|
/// 外层又一个」的双滚动。
|
||||||
fn main_column<'a>(
|
fn main_column<'a>(
|
||||||
ui: &'a Ui,
|
ui: &'a Ui,
|
||||||
catalog: Catalog,
|
catalog: Catalog,
|
||||||
model: &'a Content,
|
model: &'a Content,
|
||||||
rails: Rails,
|
rails: Rails,
|
||||||
|
mode: content_layout::BodyMode,
|
||||||
) -> Element<'a, PageMessage> {
|
) -> Element<'a, PageMessage> {
|
||||||
// 主列的横向内边距不在这里加:`rails.list` 已经把可用宽算干了,再加 2×X2 会让整排
|
// 主列的横向内边距不在这里加:`rails.list` 已经把可用宽算干了,再加 2×X2 会让整排
|
||||||
// 溢出 32px(详情列被切掉一半,复审截图上正是这条)
|
// 溢出 32px(详情列被切掉一半,复审截图上正是这条)
|
||||||
@@ -178,10 +192,15 @@ fn main_column<'a>(
|
|||||||
} else {
|
} else {
|
||||||
// **必须给间距**(复审 P2-1):这一列原先漏了 `.spacing(..)`,而列表自身的纵向内边距是 0,
|
// **必须给间距**(复审 P2-1):这一列原先漏了 `.spacing(..)`,而列表自身的纵向内边距是 0,
|
||||||
// 于是「列表视口底边」与「加载更多」贴在一起,末行正好被切在半行上。
|
// 于是「列表视口底边」与「加载更多」贴在一起,末行正好被切在半行上。
|
||||||
let mut list = column![parts::list(ui, catalog, model, rails)]
|
let mut list = column![parts::list(ui, catalog, model, rails, mode)]
|
||||||
.spacing(ui.space(Space::X2))
|
.spacing(ui.space(Space::X2))
|
||||||
.width(Length::Fill)
|
.width(Length::Fill);
|
||||||
.height(Length::Fill);
|
// 列表高度:`Split` 吃掉剩余高度(自己滚);`ScrollWhole` 按内容铺开(外层滚)。
|
||||||
|
list = if mode.list_scrolls() {
|
||||||
|
list.height(Length::Fill)
|
||||||
|
} else {
|
||||||
|
list.height(Length::Shrink)
|
||||||
|
};
|
||||||
// 紧凑档没有独立详情列:详情压在列表下面(整页替换的成本比「多一块」高)
|
// 紧凑档没有独立详情列:详情压在列表下面(整页替换的成本比「多一块」高)
|
||||||
if !rails.tier.has_detail() {
|
if !rails.tier.has_detail() {
|
||||||
list = list
|
list = list
|
||||||
@@ -191,10 +210,11 @@ fn main_column<'a>(
|
|||||||
list.push(parts::more(ui, catalog, model)).into()
|
list.push(parts::more(ui, catalog, model)).into()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// `ScrollWhole` 档:主列整列一个滚动区(列表按自然高度)。**只有这一处**加滚动条,
|
||||||
|
// 不在列表里再套一个(双滚动是「到处加滚动条糊」的典型症状)。
|
||||||
let mut column = column![]
|
let mut column = column![]
|
||||||
.spacing(ui.space(Space::X2))
|
.spacing(ui.space(Space::X2))
|
||||||
.width(Length::Fixed(rails.list))
|
.width(Length::Fixed(rails.list));
|
||||||
.height(Length::Fill);
|
|
||||||
|
|
||||||
// M113: import/export —— 只有整合包这一类才有「把本地的包装进来 / 把实例打出去」。
|
// M113: import/export —— 只有整合包这一类才有「把本地的包装进来 / 把实例打出去」。
|
||||||
// 其它三类(Mod / 资源包 / 光影)的导入就是把文件拖进目录,不属于本条任务。
|
// 其它三类(Mod / 资源包 / 光影)的导入就是把文件拖进目录,不属于本条任务。
|
||||||
@@ -204,7 +224,20 @@ fn main_column<'a>(
|
|||||||
.push(divider::full(ui));
|
.push(divider::full(ui));
|
||||||
}
|
}
|
||||||
|
|
||||||
column.push(head).push(body).into()
|
column = column.push(head).push(body);
|
||||||
|
if mode.list_scrolls() {
|
||||||
|
return column.height(Length::Fill).into();
|
||||||
|
}
|
||||||
|
iced::widget::scrollable(column.height(Length::Shrink))
|
||||||
|
.direction(iced::widget::scrollable::Direction::Vertical(
|
||||||
|
iced::widget::scrollable::Scrollbar::new()
|
||||||
|
.width(frame::SCROLLBAR_WIDTH)
|
||||||
|
.scroller_width(frame::SCROLLBAR_WIDTH)
|
||||||
|
.margin(frame::SCROLLBAR_MARGIN),
|
||||||
|
))
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(frame::scroll_style(ui))
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 详情列(只在 `Wide` / `Narrow` 存在)。
|
/// 详情列(只在 `Wide` / `Narrow` 存在)。
|
||||||
|
|||||||
@@ -114,6 +114,19 @@ pub fn page<'a>(
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 页面正文的**可用高度**(窗口高 − 自绘标题栏 − 页头带 − 上下页边距)。
|
||||||
|
///
|
||||||
|
/// M161:内容库要靠它决定「矮窗口换版面」。三个被减掉的量都取自 [`page`]/[`header_bar`]
|
||||||
|
/// 用的**同一个**来源(`Ui::metrics` 与 [`FrameMetrics::gutter`]),不另抄字面量——
|
||||||
|
/// 谁改了页头高,这里跟着变。
|
||||||
|
#[must_use]
|
||||||
|
pub fn body_height(ui: &Ui, metrics: FrameMetrics, window_height: f32, has_subtitle: bool) -> f32 {
|
||||||
|
let chrome = ui.metrics().titlebar_height
|
||||||
|
+ ui.metrics().header_height(has_subtitle)
|
||||||
|
+ 2.0 * metrics.gutter;
|
||||||
|
(window_height - chrome).max(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
fn header_bar<'a>(ui: &'a Ui, metrics: FrameMetrics, header: Header<'a>) -> Element<'a, Message> {
|
fn header_bar<'a>(ui: &'a Ui, metrics: FrameMetrics, header: Header<'a>) -> Element<'a, Message> {
|
||||||
let spec = Component::TopAppBar.spec();
|
let spec = Component::TopAppBar.spec();
|
||||||
// 16.4:标题 TitleLarge(emphasized w500),副标题 LabelMedium + OnSurfaceVariant
|
// 16.4:标题 TitleLarge(emphasized w500),副标题 LabelMedium + OnSurfaceVariant
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ pub fn run(request: Request) -> Result<(), String> {
|
|||||||
let written = Arc::clone(&written);
|
let written = Arc::clone(&written);
|
||||||
iced::application(
|
iced::application(
|
||||||
move || {
|
move || {
|
||||||
let mut model = demo_model(boot.screen, boot.width);
|
let mut model = demo_model(boot.screen, boot.width, boot.height);
|
||||||
// 截图通道把**消费 forced 的那批控件**(实例页)钉成常态,与产品 `--screenshot`
|
// 截图通道把**消费 forced 的那批控件**(实例页)钉成常态,与产品 `--screenshot`
|
||||||
// 的 `ForcedState::Rest` 同一条纪律。它管不到设置页 / 外观区(那边不读 forced),
|
// 的 `ForcedState::Rest` 同一条纪律。它管不到设置页 / 外观区(那边不读 forced),
|
||||||
// 那两屏的密闭靠窗口级 passthrough——别把这条当成万能的。
|
// 那两屏的密闭靠窗口级 passthrough——别把这条当成万能的。
|
||||||
|
|||||||
@@ -61,12 +61,17 @@ const PAGE_SOURCES: [(&str, &str); 6] = [
|
|||||||
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
|
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
|
||||||
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
|
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
|
||||||
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
|
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
|
||||||
const ALLOWED_LITERALS: [&str; 19] = [
|
const ALLOWED_LITERALS: [&str; 22] = [
|
||||||
"{date} {time} UTC",
|
"{date} {time} UTC",
|
||||||
"isolated",
|
"isolated",
|
||||||
"shared",
|
"shared",
|
||||||
"pid",
|
"pid",
|
||||||
"reason",
|
// M161:`InstancesFailed` 拆成「阶段 + 原文」之后,失败行改由 `LaunchStageFailed` 渲染,
|
||||||
|
// 它的两个占位符名是 `{stage}` / `{detail}`(占位符名不是文案)。
|
||||||
|
"stage",
|
||||||
|
"detail",
|
||||||
|
"outcome",
|
||||||
|
"seconds",
|
||||||
"Instances",
|
"Instances",
|
||||||
// M157:图形后端三态的**稳定 id**(与 core 的 `GraphicsBackend::IDS` 同一份词表),
|
// M157:图形后端三态的**稳定 id**(与 core 的 `GraphicsBackend::IDS` 同一份词表),
|
||||||
// `backend_label` 只认这三档,其余原样上屏。
|
// `backend_label` 只认这三档,其余原样上屏。
|
||||||
@@ -87,6 +92,7 @@ const ALLOWED_LITERALS: [&str; 19] = [
|
|||||||
"1.20.4",
|
"1.20.4",
|
||||||
"C:/Java/temurin-21/bin/java.exe",
|
"C:/Java/temurin-21/bin/java.exe",
|
||||||
"ULL_LAUNCH_ARGS_FIXTURE",
|
"ULL_LAUNCH_ARGS_FIXTURE",
|
||||||
|
// M161:示例「被门槛挡下」的取值(夹具)。
|
||||||
"gated",
|
"gated",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -219,11 +225,23 @@ fn the_running_line_keeps_its_number_and_separator() {
|
|||||||
assert!(text.contains(" · pid "), "分隔符或 pid 变形了:{text}");
|
assert!(text.contains(" · pid "), "分隔符或 pid 变形了:{text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 失败那一行同理:core 给的原因原文原样保留,只翻前半句
|
// 失败那一行同理(M161:`InstancesFailed` 已被 `LaunchStageFailed` 取代——「哪一步」也要翻,
|
||||||
let failed_en = Catalog::of(Lang::En).render(Key::InstancesFailed, &[("reason", "exit 1")]);
|
// 只有 `detail` 那段原文原样保留)
|
||||||
let failed_zh = Catalog::of(Lang::ZhCn).render(Key::InstancesFailed, &[("reason", "exit 1")]);
|
let args = [("stage", "Starting the game process"), ("detail", "exit 1")];
|
||||||
assert_eq!(failed_en, "Launch failed · exit 1");
|
let failed_en = Catalog::of(Lang::En).render(Key::LaunchStageFailed, &args);
|
||||||
assert_eq!(failed_zh, "启动失败 · exit 1");
|
let failed_zh = Catalog::of(Lang::ZhCn).render(
|
||||||
|
Key::LaunchStageFailed,
|
||||||
|
&[("stage", "启动游戏进程"), ("detail", "exit 1")],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
failed_en,
|
||||||
|
"Launch failed · Starting the game process: exit 1"
|
||||||
|
);
|
||||||
|
assert_eq!(failed_zh, "启动失败 · 启动游戏进程:exit 1");
|
||||||
|
for text in [&failed_en, &failed_zh] {
|
||||||
|
assert!(text.contains("exit 1"), "原文丢了:{text}");
|
||||||
|
}
|
||||||
|
assert_ne!(failed_en, failed_zh, "切换语言时前半句要变");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **页面里不许再写死界面文案**(白名单):带 ASCII 单词的字面量必须在 [`ALLOWED_LITERALS`] 里。
|
/// **页面里不许再写死界面文案**(白名单):带 ASCII 单词的字面量必须在 [`ALLOWED_LITERALS`] 里。
|
||||||
|
|||||||
@@ -230,7 +230,11 @@ fn render<'a>(
|
|||||||
// M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。
|
// M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。
|
||||||
Screen::Download => download::view(ui, metrics, &model.download, model.lang),
|
Screen::Download => download::view(ui, metrics, &model.download, model.lang),
|
||||||
// M109:内容库页同样跟 `model.lang`
|
// M109:内容库页同样跟 `model.lang`
|
||||||
Screen::Content => content::view(ui, metrics, &model.content, model.lang),
|
// M161:内容库要按**窗口高度**选版面(矮窗口不能把列表压扁),
|
||||||
|
// 正文可用高由 `frame::body_height` 从窗口高减掉外壳算出(唯一来源)。
|
||||||
|
Screen::Content => {
|
||||||
|
content::view(ui, metrics, &model.content, model.lang, model.window_height)
|
||||||
|
}
|
||||||
// M159:设置页「更新」组要画 presenter 的更新快照(不在 `States` 里,理由见该组文档)。
|
// M159:设置页「更新」组要画 presenter 的更新快照(不在 `States` 里,理由见该组文档)。
|
||||||
Screen::Settings => settings::view(ui, metrics, states, language, &model.update),
|
Screen::Settings => settings::view(ui, metrics, states, language, &model.update),
|
||||||
Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang),
|
Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang),
|
||||||
@@ -304,8 +308,11 @@ pub fn app_with_appearance<'a>(
|
|||||||
/// 演示模型(`pages_demo` example 与单测用;真页面走 `app/view.rs` 的 `AppState` 转换)。
|
/// 演示模型(`pages_demo` example 与单测用;真页面走 `app/view.rs` 的 `AppState` 转换)。
|
||||||
///
|
///
|
||||||
/// 实例 / 下载 / 日志三页带演示数据,设置页仍是占位视觉。
|
/// 实例 / 下载 / 日志三页带演示数据,设置页仍是占位视觉。
|
||||||
pub fn demo_model(screen: Screen, window_width: f32) -> Model {
|
///
|
||||||
let mut model = Model::new(screen, window_width, Some(screen.nav()));
|
/// 高度也是模型的一部分(M161):内容库在矮窗口下要换版面,出图时 `--height` 必须
|
||||||
|
/// 真的传进来,否则「矮窗口那几张图」画的是高窗口的版面。
|
||||||
|
pub fn demo_model(screen: Screen, window_width: f32, window_height: f32) -> Model {
|
||||||
|
let mut model = Model::new(screen, window_width, window_height, Some(screen.nav()));
|
||||||
match screen {
|
match screen {
|
||||||
Screen::Instances => model.instances = instances::demo(),
|
Screen::Instances => model.instances = instances::demo(),
|
||||||
Screen::Download => model.download = download::demo(unlucky_i18n::Lang::En.catalog()),
|
Screen::Download => model.download = download::demo(unlucky_i18n::Lang::En.catalog()),
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ pub struct Model {
|
|||||||
pub screen: Screen,
|
pub screen: Screen,
|
||||||
/// 窗口宽度(**含外壳**:布局档位判据就是它)
|
/// 窗口宽度(**含外壳**:布局档位判据就是它)
|
||||||
pub window_width: f32,
|
pub window_width: f32,
|
||||||
|
/// 窗口高度(**含外壳**)。
|
||||||
|
///
|
||||||
|
/// 只有「矮窗口要换版面」这一类判据用它(M161:内容库在低高度下不能把列表压扁)——
|
||||||
|
/// 宽度档位仍只看 [`Self::window_width`]。
|
||||||
|
pub window_height: f32,
|
||||||
/// 导航轨高亮项(`None` = 没有高亮,如 AI 独立页)
|
/// 导航轨高亮项(`None` = 没有高亮,如 AI 独立页)
|
||||||
pub active: Option<NavSection>,
|
pub active: Option<NavSection>,
|
||||||
/// 实例页的数据(其它三页还是占位视觉,不读)
|
/// 实例页的数据(其它三页还是占位视觉,不读)
|
||||||
@@ -134,10 +139,16 @@ pub struct Model {
|
|||||||
|
|
||||||
impl Model {
|
impl Model {
|
||||||
/// 空模型(只有外壳参数,页面数据全空)——窄窗退化与空态的构造入口。
|
/// 空模型(只有外壳参数,页面数据全空)——窄窗退化与空态的构造入口。
|
||||||
pub fn new(screen: Screen, window_width: f32, active: Option<NavSection>) -> Self {
|
pub fn new(
|
||||||
|
screen: Screen,
|
||||||
|
window_width: f32,
|
||||||
|
window_height: f32,
|
||||||
|
active: Option<NavSection>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
screen,
|
screen,
|
||||||
window_width,
|
window_width,
|
||||||
|
window_height,
|
||||||
active,
|
active,
|
||||||
instances: Instances::default(),
|
instances: Instances::default(),
|
||||||
download: Download::loading(),
|
download: Download::loading(),
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ fn the_banner_priority_is_ready_then_applying_then_failure_then_available() {
|
|||||||
/// 演示模型里更新组能画(`pages_demo` 的 harness 路径):`Model.update` 有默认值,不是 `None` 才画。
|
/// 演示模型里更新组能画(`pages_demo` 的 harness 路径):`Model.update` 有默认值,不是 `None` 才画。
|
||||||
#[test]
|
#[test]
|
||||||
fn the_demo_model_carries_an_update_snapshot() {
|
fn the_demo_model_carries_an_update_snapshot() {
|
||||||
let model = crate::pages::demo_model(Screen::Settings, 1180.0);
|
let model = crate::pages::demo_model(Screen::Settings, 1180.0, 760.0);
|
||||||
assert_eq!(model.update, crate::pages::update::demo());
|
assert_eq!(model.update, crate::pages::update::demo());
|
||||||
assert!(
|
assert!(
|
||||||
model.update.available().is_some(),
|
model.update.available().is_some(),
|
||||||
@@ -115,9 +115,73 @@ fn the_demo_model_carries_an_update_snapshot() {
|
|||||||
);
|
);
|
||||||
// 其它页不掺演示更新状态(实例页/下载页的基线图不该多出一条提示条)。
|
// 其它页不掺演示更新状态(实例页/下载页的基线图不该多出一条提示条)。
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
crate::pages::demo_model(Screen::Instances, 1180.0).update,
|
crate::pages::demo_model(Screen::Instances, 1180.0, 760.0).update,
|
||||||
Update::default()
|
Update::default()
|
||||||
);
|
);
|
||||||
let catalog: Catalog = Lang::En.catalog();
|
let catalog: Catalog = Lang::En.catalog();
|
||||||
assert!(!catalog.text(Key::SettingsGroupUpdate).is_empty());
|
assert!(!catalog.text(Key::SettingsGroupUpdate).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **忙碌时按钮禁用,且禁用态与状态行同源**(M161 收的债:以前点了没反应)。
|
||||||
|
///
|
||||||
|
/// 三条判据:
|
||||||
|
/// ① `Checking` / `applying` 时 `can_check()` 与 `can_apply()` 都是 false(按钮画成置灰);
|
||||||
|
/// ② 这两态下设置页的状态行**必须**是「正在检查…」/「正在安装…」——否则用户看到一颗灰按钮
|
||||||
|
/// 却没有任何解释;
|
||||||
|
/// ③ 结论回来(失败也算)之后恢回可点:超时/网络失败不许把按钮永久钉死。
|
||||||
|
#[test]
|
||||||
|
fn the_buttons_are_disabled_exactly_while_something_is_running() {
|
||||||
|
use super::super::update::status_line;
|
||||||
|
|
||||||
|
let catalog = Catalog::of(Lang::ZhCn);
|
||||||
|
let available = UpdateStatus::Available {
|
||||||
|
version: "0.2.3".to_string(),
|
||||||
|
size: 1024,
|
||||||
|
notes: String::new(),
|
||||||
|
forced: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 静息:有候选 ⇒ 两颗按钮都能点。
|
||||||
|
let idle = Update {
|
||||||
|
status: available.clone(),
|
||||||
|
..Update::default()
|
||||||
|
};
|
||||||
|
assert!(!idle.busy());
|
||||||
|
assert!(idle.can_check(), "静息时「立即检查」可点");
|
||||||
|
assert!(idle.can_apply(), "有候选且不忙 ⇒ 「下载并安装」可点");
|
||||||
|
|
||||||
|
// 正在查:两颗都置灰,状态行说「正在检查」。
|
||||||
|
let checking = Update {
|
||||||
|
status: UpdateStatus::Checking,
|
||||||
|
..idle.clone()
|
||||||
|
};
|
||||||
|
assert!(checking.busy(), "Checking 就是忙碌");
|
||||||
|
assert!(!checking.can_check(), "查的时候不许再点「立即检查」");
|
||||||
|
assert!(!checking.can_apply(), "查的时候不许装(presenter 也拒)");
|
||||||
|
assert_eq!(
|
||||||
|
status_line(catalog, &checking).0,
|
||||||
|
catalog.text(Key::SettingsUpdateChecking),
|
||||||
|
"灰按钮旁边必须有「正在检查…」这句话"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 正在装:`applying` 也算忙碌(此时 status 可能仍是 Available)。
|
||||||
|
let applying = Update {
|
||||||
|
applying: true,
|
||||||
|
..idle.clone()
|
||||||
|
};
|
||||||
|
assert!(applying.busy());
|
||||||
|
assert!(!applying.can_apply(), "装的时候安装键置灰,不是消失");
|
||||||
|
assert!(!applying.can_check(), "装的时候也不许再来一条检查");
|
||||||
|
|
||||||
|
// 结论回来(这里是最坏的结论:失败)⇒ 恢回可点。
|
||||||
|
let failed = Update {
|
||||||
|
status: UpdateStatus::Failed {
|
||||||
|
reason: "连不上".to_string(),
|
||||||
|
retryable: true,
|
||||||
|
},
|
||||||
|
..Update::default()
|
||||||
|
};
|
||||||
|
assert!(!failed.busy(), "失败 = 这一轮结束了");
|
||||||
|
assert!(failed.can_check(), "失败之后必须能重试");
|
||||||
|
assert!(!failed.can_apply(), "没有候选时安装键不可点");
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@ pub(super) fn body<'a>(ui: &'a Ui, update: &'a Update, lang: Lang) -> Element<'a
|
|||||||
channel_label(catalog, &update.channel),
|
channel_label(catalog, &update.channel),
|
||||||
));
|
));
|
||||||
stacked = stacked.push(divider(ui));
|
stacked = stacked.push(divider(ui));
|
||||||
stacked = stacked.push(auto_check_row(ui, catalog, update.auto_check));
|
stacked = stacked.push(auto_check_row(ui, catalog, update));
|
||||||
stacked = stacked.push(divider(ui));
|
stacked = stacked.push(divider(ui));
|
||||||
stacked = stacked.push(status_block(ui, catalog, update));
|
stacked = stacked.push(status_block(ui, catalog, update));
|
||||||
|
|
||||||
@@ -117,23 +117,30 @@ fn info_row<'a>(ui: &'a Ui, label: &'a str, value: String) -> Element<'a, Messag
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 自动检查开关 + 「立即检查」按钮。
|
/// 自动检查开关 + 「立即检查」按钮。
|
||||||
fn auto_check_row<'a>(ui: &'a Ui, catalog: Catalog, auto_check: bool) -> Element<'a, Message> {
|
///
|
||||||
|
/// **忙碌时「立即检查」置灰**(M161):此前它永远可点,而 presenter 的 `begin_check` 在
|
||||||
|
/// inflight 时静默 `return None`——用户点了没反应,还以为启动器卡了。禁用态与状态行同源
|
||||||
|
/// (同一个 [`Update::busy`]):按钮灰着的时候,下面那句状态行一定是「正在检查…」/「正在安装…」。
|
||||||
|
fn auto_check_row<'a>(ui: &'a Ui, catalog: Catalog, update: &Update) -> Element<'a, Message> {
|
||||||
|
let check_now = catalog.text(Key::SettingsUpdateCheckNow);
|
||||||
|
let check = if update.can_check() {
|
||||||
|
buttons::text(
|
||||||
|
ui,
|
||||||
|
Button::new(check_now, Message::Update(UpdateAction::CheckNow)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
buttons::text(ui, Button::disabled(check_now))
|
||||||
|
};
|
||||||
row![
|
row![
|
||||||
chip(
|
chip(
|
||||||
ui,
|
ui,
|
||||||
Chip::filter(
|
Chip::filter(
|
||||||
catalog.text(Key::SettingsUpdateAutoCheck),
|
catalog.text(Key::SettingsUpdateAutoCheck),
|
||||||
auto_check,
|
update.auto_check,
|
||||||
Message::Update(UpdateAction::SetAutoCheck(!auto_check)),
|
Message::Update(UpdateAction::SetAutoCheck(!update.auto_check)),
|
||||||
),
|
|
||||||
),
|
|
||||||
buttons::text(
|
|
||||||
ui,
|
|
||||||
Button::new(
|
|
||||||
catalog.text(Key::SettingsUpdateCheckNow),
|
|
||||||
Message::Update(UpdateAction::CheckNow),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
check,
|
||||||
]
|
]
|
||||||
.spacing(ui.space(Space::X3))
|
.spacing(ui.space(Space::X3))
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
@@ -171,17 +178,21 @@ fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element
|
|||||||
});
|
});
|
||||||
block = block.push(text_role_owned(ui, TypeRole::BodySmall, false, text, color));
|
block = block.push(text_role_owned(ui, TypeRole::BodySmall, false, text, color));
|
||||||
|
|
||||||
if let Some((_version, _size, notes, forced)) = update.available().filter(|_| !update.applying)
|
if let Some((_version, _size, notes, forced)) = update.available() {
|
||||||
{
|
// 有候选就**永远画出**这一颗按钮:忙碌时置灰(`can_apply` = false),而不是整颗消失。
|
||||||
let mut actions = row![buttons::filled(
|
// 消失会让人以为「装到一半的功能没了」;灰着 + 状态行那句「正在安装…」才说得清。
|
||||||
ui,
|
let apply_label = catalog.text(Key::SettingsUpdateApply);
|
||||||
Button::new(
|
let apply = if update.can_apply() {
|
||||||
catalog.text(Key::SettingsUpdateApply),
|
buttons::filled(
|
||||||
Message::Update(UpdateAction::Apply),
|
ui,
|
||||||
),
|
Button::new(apply_label, Message::Update(UpdateAction::Apply)),
|
||||||
)]
|
)
|
||||||
.spacing(ui.space(Space::X2))
|
} else {
|
||||||
.align_y(Alignment::Center);
|
buttons::filled(ui, Button::disabled(apply_label))
|
||||||
|
};
|
||||||
|
let mut actions = row![apply]
|
||||||
|
.spacing(ui.space(Space::X2))
|
||||||
|
.align_y(Alignment::Center);
|
||||||
if forced {
|
if forced {
|
||||||
actions = actions.push(text_role_owned(
|
actions = actions.push(text_role_owned(
|
||||||
ui,
|
ui,
|
||||||
@@ -234,7 +245,8 @@ fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element
|
|||||||
/// 一条状态行(文案 + 是不是「错」那一类)。
|
/// 一条状态行(文案 + 是不是「错」那一类)。
|
||||||
///
|
///
|
||||||
/// 四种结论各写各的句子,**不共用**「已是最新」那句:失败/缺产物时那句就是撒谎。
|
/// 四种结论各写各的句子,**不共用**「已是最新」那句:失败/缺产物时那句就是撒谎。
|
||||||
fn status_line(catalog: Catalog, update: &Update) -> (String, bool) {
|
/// `pub(super)` 只为让 `settings/tests` 能断言「忙碌时状态行与按钮禁用态同源」。
|
||||||
|
pub(super) fn status_line(catalog: Catalog, update: &Update) -> (String, bool) {
|
||||||
let (key, args, error) = match &update.status {
|
let (key, args, error) = match &update.status {
|
||||||
UpdateStatus::Idle => (Key::SettingsUpdateNever, Vec::new(), false),
|
UpdateStatus::Idle => (Key::SettingsUpdateNever, Vec::new(), false),
|
||||||
UpdateStatus::Checking => (Key::SettingsUpdateChecking, Vec::new(), false),
|
UpdateStatus::Checking => (Key::SettingsUpdateChecking, Vec::new(), false),
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ fn every_screen_builds_at_the_three_comparison_widths() {
|
|||||||
let form = demo_launch_form();
|
let form = demo_launch_form();
|
||||||
for width in [1180.0, 900.0, 700.0] {
|
for width in [1180.0, 900.0, 700.0] {
|
||||||
for screen in Screen::ALL {
|
for screen in Screen::ALL {
|
||||||
let model = demo_model(screen, width);
|
let model = demo_model(screen, width, 760.0);
|
||||||
let _element: Element<'_, Message> = app(&ui, &model, &form);
|
let _element: Element<'_, Message> = app(&ui, &model, &form);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ fn an_empty_snapshot_still_builds() {
|
|||||||
let ui = ui();
|
let ui = ui();
|
||||||
let form = demo_launch_form();
|
let form = demo_launch_form();
|
||||||
for screen in Screen::ALL {
|
for screen in Screen::ALL {
|
||||||
let model = Model::new(screen, 1180.0, Some(screen.nav()));
|
let model = Model::new(screen, 1180.0, 760.0, Some(screen.nav()));
|
||||||
let _element: Element<'_, Message> = app(&ui, &model, &form);
|
let _element: Element<'_, Message> = app(&ui, &model, &form);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,7 +368,7 @@ fn every_rail_item_points_at_a_route_that_highlights_it() {
|
|||||||
/// 演示模型:12 行 + 1 条选中详情(M84 的出图基线就靠这份数据)。
|
/// 演示模型:12 行 + 1 条选中详情(M84 的出图基线就靠这份数据)。
|
||||||
#[test]
|
#[test]
|
||||||
fn the_demo_model_carries_the_m84_baseline() {
|
fn the_demo_model_carries_the_m84_baseline() {
|
||||||
let model = demo_model(Screen::Instances, 1180.0);
|
let model = demo_model(Screen::Instances, 1180.0, 760.0);
|
||||||
assert_eq!(model.instances.rows.len(), 12);
|
assert_eq!(model.instances.rows.len(), 12);
|
||||||
let detail = model.instances.detail.as_ref().expect("演示数据有选中项");
|
let detail = model.instances.detail.as_ref().expect("演示数据有选中项");
|
||||||
assert_eq!(detail.meta.len(), 6, "元信息 6 格");
|
assert_eq!(detail.meta.len(), 6, "元信息 6 格");
|
||||||
@@ -377,7 +377,7 @@ fn the_demo_model_carries_the_m84_baseline() {
|
|||||||
|
|
||||||
// 其余三页不带实例数据(占位视觉不读它)
|
// 其余三页不带实例数据(占位视觉不读它)
|
||||||
for screen in [Screen::Download, Screen::Settings, Screen::Logs] {
|
for screen in [Screen::Download, Screen::Settings, Screen::Logs] {
|
||||||
let model = demo_model(screen, 1180.0);
|
let model = demo_model(screen, 1180.0, 760.0);
|
||||||
assert!(
|
assert!(
|
||||||
model.instances.rows.is_empty(),
|
model.instances.rows.is_empty(),
|
||||||
"{} 不该带实例数据",
|
"{} 不该带实例数据",
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ pub struct Update {
|
|||||||
pub silent: bool,
|
pub silent: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod busy;
|
||||||
|
|
||||||
impl Update {
|
impl Update {
|
||||||
/// 有没有可装的候选(有的话设置页给「下载并安装」)。
|
/// 有没有可装的候选(有的话设置页给「下载并安装」)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -155,8 +157,37 @@ impl Update {
|
|||||||
/// 出图 / 单测用的演示快照(一个可装的新版本 + 一次成功的检查)。
|
/// 出图 / 单测用的演示快照(一个可装的新版本 + 一次成功的检查)。
|
||||||
///
|
///
|
||||||
/// **不是产品数据**:产品路径的更新状态只来自 presenter(`app/view/update.rs` 转换)。
|
/// **不是产品数据**:产品路径的更新状态只来自 presenter(`app/view/update.rs` 转换)。
|
||||||
|
///
|
||||||
|
/// 取证开关 `ULL_UPDATE_STATE`(M161,沿用 `ULL_CONTENT_*` 那套范式):不设 = 缺省那一态;
|
||||||
|
/// `checking` / `applying` 用来出「按钮置灰 + 状态行说正在干活」的那两张图,
|
||||||
|
/// `failed` / `up_to_date` 用来出另外两条结论。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn demo() -> Update {
|
pub fn demo() -> Update {
|
||||||
|
let mut snapshot = demo_available();
|
||||||
|
match std::env::var("ULL_UPDATE_STATE")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"checking" => snapshot.status = UpdateStatus::Checking,
|
||||||
|
"applying" => snapshot.applying = true,
|
||||||
|
"failed" => {
|
||||||
|
snapshot.status = UpdateStatus::Failed {
|
||||||
|
reason: "Could not fetch the release manifest: connection reset".to_string(),
|
||||||
|
retryable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"up_to_date" => {
|
||||||
|
snapshot.status = UpdateStatus::UpToDate {
|
||||||
|
latest: Some("0.2.2".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 缺省那一态(可装的新版本)。
|
||||||
|
fn demo_available() -> Update {
|
||||||
Update {
|
Update {
|
||||||
current_version: "0.2.2".to_string(),
|
current_version: "0.2.2".to_string(),
|
||||||
channel: "stable".to_string(),
|
channel: "stable".to_string(),
|
||||||
@@ -332,60 +363,6 @@ pub fn banner<'a>(
|
|||||||
.into(),
|
.into(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "update/tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
use unlucky_i18n::Lang;
|
|
||||||
|
|
||||||
/// 提示条的正文**同时**给体积与说明摘要(mission:提示条含版本号、体积、说明摘要)。
|
|
||||||
#[test]
|
|
||||||
fn the_available_banner_carries_both_size_and_notes() {
|
|
||||||
for lang in [Lang::En, Lang::ZhCn] {
|
|
||||||
let body = available_body(lang.catalog(), 21_500_000, "修掉启动崩溃");
|
|
||||||
assert!(body.contains("20.5 MB"), "{lang:?} 缺体积:{body}");
|
|
||||||
assert!(body.contains("修掉启动崩溃"), "{lang:?} 缺说明:{body}");
|
|
||||||
}
|
|
||||||
// 发布没写说明时只留体积,不留一个空尾巴。
|
|
||||||
let bare = available_body(Lang::En.catalog(), 1024, " ");
|
|
||||||
assert!(bare.contains("1.0 KB"), "{bare}");
|
|
||||||
assert!(!bare.contains('·'), "没有说明时不该留分隔符:{bare}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 视图模型的提示条判据与 presenter 同序(就位 > 进行中 > 失败 > 新版本)。
|
|
||||||
#[test]
|
|
||||||
fn the_banner_priority_matches_the_presenter() {
|
|
||||||
let mut update = Update {
|
|
||||||
status: UpdateStatus::Available {
|
|
||||||
version: "0.2.3".to_string(),
|
|
||||||
size: 1,
|
|
||||||
notes: String::new(),
|
|
||||||
forced: false,
|
|
||||||
},
|
|
||||||
..Update::default()
|
|
||||||
};
|
|
||||||
assert!(matches!(update.banner(), Some(Banner::Available { .. })));
|
|
||||||
|
|
||||||
update.skipped = Some("0.2.3".to_string());
|
|
||||||
assert!(update.banner().is_none(), "忽略之后不再打扰");
|
|
||||||
|
|
||||||
// 忽略的是**那一个版本**:同一次运行里冒出 0.2.4 仍然提示(与 presenter 同一语义)。
|
|
||||||
update.status = UpdateStatus::Available {
|
|
||||||
version: "0.2.4".to_string(),
|
|
||||||
size: 1,
|
|
||||||
notes: String::new(),
|
|
||||||
forced: false,
|
|
||||||
};
|
|
||||||
assert!(
|
|
||||||
matches!(update.banner(), Some(Banner::Available { version, .. }) if version == "0.2.4"),
|
|
||||||
"0.2.3 被忽略不该压住 0.2.4"
|
|
||||||
);
|
|
||||||
|
|
||||||
update.skipped = None;
|
|
||||||
update.applying = true;
|
|
||||||
assert!(matches!(update.banner(), Some(Banner::Applying)));
|
|
||||||
|
|
||||||
update.ready = Some("0.2.3".to_string());
|
|
||||||
assert!(matches!(update.banner(), Some(Banner::Ready { .. })));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>";
|
const MASK: &str = "<redacted>";
|
||||||
|
|
||||||
|
mod prefs_migration;
|
||||||
|
|
||||||
|
use prefs_migration::migrate_update_prefs;
|
||||||
|
|
||||||
/// `ull config get [<key>] [--show-secret]`。
|
/// `ull config get [<key>] [--show-secret]`。
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@@ -46,6 +50,9 @@ pub fn get(ctx: &Ctx, key: Option<&str>, show_secret: bool) -> Result<Report, Cl
|
|||||||
|
|
||||||
/// 单键 get:读该键自己的落点文件(数据根键走便携目录那份)。
|
/// 单键 get:读该键自己的落点文件(数据根键走便携目录那份)。
|
||||||
fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError> {
|
fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError> {
|
||||||
|
// 先跑一次遗留偏好迁移(幂等;没有旧文件时只是一次 stat)。必须在 `load` **之前**:
|
||||||
|
// 迁移写的正是这份 `launcher.json`,先读后迁会让这一条输出还是旧值。
|
||||||
|
let migration = migrate_update_prefs(ctx);
|
||||||
let path = ctx.setting_path_for(key);
|
let path = ctx.setting_path_for(key);
|
||||||
let store = SettingStore::load(&path, launcher_schema());
|
let store = SettingStore::load(&path, launcher_schema());
|
||||||
let schema = store.doc().schema().clone();
|
let schema = store.doc().schema().clone();
|
||||||
@@ -65,7 +72,7 @@ fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError>
|
|||||||
value.clone()
|
value.clone()
|
||||||
};
|
};
|
||||||
let problems = render_problems(&store);
|
let problems = render_problems(&store);
|
||||||
let data = json!({
|
let mut data = json!({
|
||||||
"key": key,
|
"key": key,
|
||||||
"value": shown,
|
"value": shown,
|
||||||
"redacted": hidden,
|
"redacted": hidden,
|
||||||
@@ -83,6 +90,12 @@ fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError>
|
|||||||
}
|
}
|
||||||
)];
|
)];
|
||||||
human.push(format!("来自 {}", path.display()));
|
human.push(format!("来自 {}", path.display()));
|
||||||
|
if let Some(note) = migration {
|
||||||
|
human.push(note.human);
|
||||||
|
if let Some(object) = data.as_object_mut() {
|
||||||
|
object.insert("migration".to_string(), note.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
for problem in &problems {
|
for problem in &problems {
|
||||||
human.push(format!("问题:{problem}"));
|
human.push(format!("问题:{problem}"));
|
||||||
}
|
}
|
||||||
@@ -92,6 +105,7 @@ fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError>
|
|||||||
|
|
||||||
/// 无键全量 dump:以数据根那份为底,`core.*` 数据根键换成便携目录那份的生效值。
|
/// 无键全量 dump:以数据根那份为底,`core.*` 数据根键换成便携目录那份的生效值。
|
||||||
fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
|
fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
|
||||||
|
let migration = migrate_update_prefs(ctx);
|
||||||
let path = ctx.launcher_config();
|
let path = ctx.launcher_config();
|
||||||
let store = SettingStore::load(&path, launcher_schema());
|
let store = SettingStore::load(&path, launcher_schema());
|
||||||
let schema = store.doc().schema().clone();
|
let schema = store.doc().schema().clone();
|
||||||
@@ -140,27 +154,33 @@ fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
|
|||||||
ctx.data.game_home.display()
|
ctx.data.game_home.display()
|
||||||
));
|
));
|
||||||
human.extend(text.lines().map(str::to_string));
|
human.extend(text.lines().map(str::to_string));
|
||||||
|
if let Some(note) = &migration {
|
||||||
|
human.push(note.human.clone());
|
||||||
|
}
|
||||||
for problem in &problems {
|
for problem in &problems {
|
||||||
human.push(format!("问题:{problem}"));
|
human.push(format!("问题:{problem}"));
|
||||||
}
|
}
|
||||||
if masked {
|
if masked {
|
||||||
human.push("(敏感值已掩码;加 --show-secret 显示明文)".to_string());
|
human.push("(敏感值已掩码;加 --show-secret 显示明文)".to_string());
|
||||||
}
|
}
|
||||||
Ok(Report::new(
|
let mut data = json!({
|
||||||
json!({
|
"path": path.display().to_string(),
|
||||||
"path": path.display().to_string(),
|
"core_path": core_path.as_ref().map(|core| core.display().to_string()),
|
||||||
"core_path": core_path.as_ref().map(|core| core.display().to_string()),
|
"config": document,
|
||||||
"config": document,
|
"redacted": masked,
|
||||||
"redacted": masked,
|
"problems": problems,
|
||||||
"problems": problems,
|
"data_root": {
|
||||||
"data_root": {
|
"mode": ctx.data.mode.as_str(),
|
||||||
"mode": ctx.data.mode.as_str(),
|
"launcher_home": ctx.data.launcher_home.display().to_string(),
|
||||||
"launcher_home": ctx.data.launcher_home.display().to_string(),
|
"game_home": ctx.data.game_home.display().to_string(),
|
||||||
"game_home": ctx.data.game_home.display().to_string(),
|
},
|
||||||
},
|
});
|
||||||
}),
|
if let Some(note) = migration {
|
||||||
human,
|
if let Some(object) = data.as_object_mut() {
|
||||||
))
|
object.insert("migration".to_string(), note.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Report::new(data, human))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `ull config set <key> <value>`。
|
/// `ull config set <key> <value>`。
|
||||||
@@ -171,6 +191,8 @@ fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
|
|||||||
///
|
///
|
||||||
/// 键不在 schema、值不合法、文件只读/不可读、写盘失败时返回用户错误。
|
/// 键不在 schema、值不合法、文件只读/不可读、写盘失败时返回用户错误。
|
||||||
pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> {
|
pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> {
|
||||||
|
// 同一份遗留偏好先迁进来:否则用户「set 一个、get 另一个」会看到两份真相。
|
||||||
|
let migration = migrate_update_prefs(ctx);
|
||||||
let path = ctx.setting_path_for(key);
|
let path = ctx.setting_path_for(key);
|
||||||
let mut store = SettingStore::load(&path, launcher_schema());
|
let mut store = SettingStore::load(&path, launcher_schema());
|
||||||
let sensitive = is_sensitive(store.doc().schema(), key);
|
let sensitive = is_sensitive(store.doc().schema(), key);
|
||||||
@@ -195,7 +217,7 @@ pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> {
|
|||||||
})?;
|
})?;
|
||||||
match &outcome {
|
match &outcome {
|
||||||
SaveOutcome::Saved(saved) => {
|
SaveOutcome::Saved(saved) => {
|
||||||
let data = json!({
|
let mut data = json!({
|
||||||
"key": key,
|
"key": key,
|
||||||
"old": old,
|
"old": old,
|
||||||
"new": value,
|
"new": value,
|
||||||
@@ -209,13 +231,25 @@ pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> {
|
|||||||
),
|
),
|
||||||
format!("已保存:{}", saved.display()),
|
format!("已保存:{}", saved.display()),
|
||||||
];
|
];
|
||||||
|
if let Some(note) = migration {
|
||||||
|
human.push(note.human);
|
||||||
|
if let Some(object) = data.as_object_mut() {
|
||||||
|
object.insert("migration".to_string(), note.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
human.extend(source_note(ctx, key));
|
human.extend(source_note(ctx, key));
|
||||||
Ok(Report::new(data, human))
|
Ok(Report::new(data, human))
|
||||||
}
|
}
|
||||||
SaveOutcome::Skipped(SkipReason::NotDirty) => Ok(Report::new(
|
SaveOutcome::Skipped(SkipReason::NotDirty) => {
|
||||||
json!({ "key": key, "new": value, "saved": null }),
|
let mut human = vec![format!("{key} 已经是该值,未写盘")];
|
||||||
vec![format!("{key} 已经是该值,未写盘")],
|
if let Some(note) = &migration {
|
||||||
)),
|
human.push(note.human.clone());
|
||||||
|
}
|
||||||
|
Ok(Report::new(
|
||||||
|
json!({ "key": key, "new": value, "saved": null }),
|
||||||
|
human,
|
||||||
|
))
|
||||||
|
}
|
||||||
SaveOutcome::Skipped(reason) => {
|
SaveOutcome::Skipped(reason) => {
|
||||||
let error = CliError::user(
|
let error = CliError::user(
|
||||||
"config_read_only",
|
"config_read_only",
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
//! M159 的遗留更新偏好 → 主配置(`update.auto_check`)的迁移(M161)。
|
||||||
|
//!
|
||||||
|
//! 从 `commands/config.rs` 拆出:那边贴着 400 行硬线,而这块是**独立的一件事**
|
||||||
|
//! (遗留文件的桥接),与 get/set 的排版逻辑无关。
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use unlucky_core::setting::update_prefs::{self, Migration};
|
||||||
|
|
||||||
|
use crate::context::Ctx;
|
||||||
|
|
||||||
|
/// M159 的遗留更新偏好 → 主配置的迁移结果(只在**有话说**时返回)。
|
||||||
|
pub(super) struct PrefsNote {
|
||||||
|
/// 人读一行(`config get/set` 的人类可读输出里追加)
|
||||||
|
pub(super) human: String,
|
||||||
|
/// 同一件事的机器可读形状(`--json` 信封里追加)
|
||||||
|
pub(super) data: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把 `<数据根>/cache/update/prefs.json` 迁进 `launcher.json`(M161)。
|
||||||
|
///
|
||||||
|
/// 为什么 `config` 这个**读命令**也会写盘:那份遗留文件对 CLI 本来就不可见——用户在手头没 GUI
|
||||||
|
/// 的机器上 `config get update.auto_check`,旧实现永远回默认 `true`,而他的开关可能是关的。
|
||||||
|
/// 迁移是**幂等**的(源指纹闸门 + 旧文件改名 `.migrated`),有收据,且在写不进去时什么都不动
|
||||||
|
/// (见 [`unlucky_core::setting::update_prefs`])。没有旧文件时这里只是一次 `stat`。
|
||||||
|
pub(super) fn migrate_update_prefs(ctx: &Ctx) -> Option<PrefsNote> {
|
||||||
|
let layout = ctx.layout();
|
||||||
|
let paths = update_prefs::Targets {
|
||||||
|
launcher: &ctx.launcher_config(),
|
||||||
|
legacy: &layout.cache().join("update").join("prefs.json"),
|
||||||
|
receipt: &layout.config().join(update_prefs::RECEIPT_FILE),
|
||||||
|
};
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let migration = update_prefs::migrate(paths, now);
|
||||||
|
let (human, detail) = match &migration {
|
||||||
|
Migration::Nothing => return None,
|
||||||
|
Migration::Migrated { auto_check, .. } => (
|
||||||
|
format!(
|
||||||
|
"已把旧更新偏好迁入主配置:update.auto_check = {auto_check}(收据 {})",
|
||||||
|
paths.receipt.display()
|
||||||
|
),
|
||||||
|
format!("update.auto_check = {auto_check} 已迁移"),
|
||||||
|
),
|
||||||
|
Migration::NewValueWins { auto_check, .. } => (
|
||||||
|
format!(
|
||||||
|
"旧更新偏好文件已退役(主配置里已有 update.auto_check = {auto_check},新值胜出;收据 {})",
|
||||||
|
paths.receipt.display()
|
||||||
|
),
|
||||||
|
"旧文件已退役,主配置的值未改动".to_string(),
|
||||||
|
),
|
||||||
|
Migration::Unreadable { path, reason } => (
|
||||||
|
format!("旧更新偏好读不出来({}):{reason}", path.display()),
|
||||||
|
"旧文件坏,未迁移".to_string(),
|
||||||
|
),
|
||||||
|
Migration::Refused { reason } => (
|
||||||
|
format!("旧更新偏好未迁移:{reason}"),
|
||||||
|
"主配置不可写,未迁移".to_string(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
Some(PrefsNote {
|
||||||
|
human,
|
||||||
|
data: json!({
|
||||||
|
"id": migration.as_str(),
|
||||||
|
"receipt": migration.receipt().map(|path| path.display().to_string()),
|
||||||
|
"detail": detail,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -254,3 +254,67 @@ fn unknown_schema_prefix_warns_and_still_shows_file_values() {
|
|||||||
report.human
|
report.human
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// M161:`ull config get update.auto_check` 读的是**主配置**,而且会把 M159 遗留的
|
||||||
|
/// `cache/update/prefs.json` 先迁进来(迁移收据在 `migration` 里可查)。
|
||||||
|
///
|
||||||
|
/// 这条是 mission「`ull config get update.auto_check` 真能读到」的端到端证明:
|
||||||
|
/// 旧文件里 `false` → 迁移 → 主配置 → CLI 输出 `false`(**不是**默认的 `true`)。
|
||||||
|
#[test]
|
||||||
|
fn the_update_switch_is_migrated_and_readable_from_the_cli() {
|
||||||
|
let temp = TempDir::new("update-prefs");
|
||||||
|
let exe = temp.path().join("exe");
|
||||||
|
let ctx = ctx_with(Globals::default(), &exe);
|
||||||
|
|
||||||
|
// 旧域内文件(M159 的落点):开关是关的。
|
||||||
|
let legacy = ctx.layout().cache().join("update").join("prefs.json");
|
||||||
|
write_json(&legacy, &json!({ "auto_check": false }));
|
||||||
|
|
||||||
|
let report = get(&ctx, Some("update.auto_check"), false).expect("读 update.auto_check");
|
||||||
|
assert_eq!(
|
||||||
|
report.data["value"],
|
||||||
|
json!(false),
|
||||||
|
"旧文件里的 false 必须读到(默认值是 true,读成默认就是丢值)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
report.data["path"],
|
||||||
|
json!(ctx.launcher_config().display().to_string()),
|
||||||
|
"落点必须是主配置(schema API),不是更新域自己的文件"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
report.data["migration"]["id"],
|
||||||
|
json!("migrated"),
|
||||||
|
"迁移要走一遍并如实报出来:{report:?}"
|
||||||
|
);
|
||||||
|
let receipt = report.data["migration"]["receipt"]
|
||||||
|
.as_str()
|
||||||
|
.expect("收据路径")
|
||||||
|
.to_string();
|
||||||
|
assert!(Path::new(&receipt).is_file(), "收据可查:{receipt}");
|
||||||
|
assert!(!legacy.exists(), "旧文件必须退役");
|
||||||
|
assert!(
|
||||||
|
report
|
||||||
|
.human
|
||||||
|
.iter()
|
||||||
|
.any(|line| line.contains("已把旧更新偏好迁入主配置")),
|
||||||
|
"{:?}",
|
||||||
|
report.human
|
||||||
|
);
|
||||||
|
|
||||||
|
// 第二遍:什么都不做(幂等),值仍是 false。
|
||||||
|
let again = get(&ctx, Some("update.auto_check"), false).expect("再读一遍");
|
||||||
|
assert_eq!(again.data["value"], json!(false));
|
||||||
|
assert!(
|
||||||
|
again.data.get("migration").is_none(),
|
||||||
|
"第二遍不该再报迁移:{again:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// `channels` 里只有 stable 时,写 beta 要当面被拒(而不是静默写成「设了没生效」)。
|
||||||
|
let error = set(&ctx, "update.channel", "beta").expect_err("beta 不在允许值里");
|
||||||
|
assert!(error.to_string().contains("beta"), "{error}");
|
||||||
|
|
||||||
|
// 允许值写得进去,且读得回来。
|
||||||
|
set(&ctx, "update.channel", "stable").expect("写 stable");
|
||||||
|
let channel = get(&ctx, Some("update.channel"), false).expect("读 update.channel");
|
||||||
|
assert_eq!(channel.data["value"], json!("stable"));
|
||||||
|
}
|
||||||
@@ -42,6 +42,17 @@ impl SettingSchema {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 一次登记一批键(M161:某个域的键表拆到自己的文件后,主表只留一行)。
|
||||||
|
///
|
||||||
|
/// 重复键的断言与 [`Self::with`] 同源(`with` 负责),所以批量登记不会绕过那条守门。
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_all(mut self, specs: impl IntoIterator<Item = SettingSpec>) -> Self {
|
||||||
|
for spec in specs {
|
||||||
|
self = self.with(spec);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// 种类。
|
/// 种类。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn id(&self) -> SchemaId {
|
pub const fn id(&self) -> SchemaId {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
//! `config/launcher.json` 的键表(全局设置)。
|
//! `config/launcher.json` 的键表(全局设置)。键名与默认值对齐 `04-data-and-config.md` §2-§3;
|
||||||
//!
|
//! AI 可写 + 敏感分级按 `02-ai-agent-design.md` §3.1 落:**密钥类一律 locked**。
|
||||||
//! 键名与默认值对齐 `DevDocs/PlanDoc/04-data-and-config.md` §2-§3 与 `05/06/07/08` 各专题;
|
|
||||||
//! AI 可写 + 敏感分级按 `02-ai-agent-design.md` §3.1 的边界落:**密钥类一律 locked**。
|
|
||||||
|
|
||||||
use super::catalog::SettingSchema;
|
use super::catalog::SettingSchema;
|
||||||
use super::schema::{SchemaId, SchemaVersion};
|
use super::schema::{SchemaId, SchemaVersion};
|
||||||
@@ -36,11 +34,13 @@ pub const INSTANCE_MODES: &[&str] = &["isolated", "shared"];
|
|||||||
/// `java.installRoot` 未设置时的哨兵(空串 = 按数据目录模式派生落点)。
|
/// `java.installRoot` 未设置时的哨兵(空串 = 按数据目录模式派生落点)。
|
||||||
pub const JAVA_INSTALL_ROOT_UNSET: &str = "";
|
pub const JAVA_INSTALL_ROOT_UNSET: &str = "";
|
||||||
/// `java.vendorPriority` 的允许值(D23:四家发行版,顺序即优先级)。
|
/// `java.vendorPriority` 的允许值(D23:四家发行版,顺序即优先级)。
|
||||||
///
|
/// 与 `game::java::vendor::VENDOR_IDS` 必须同一份集合(setting 层不依赖 game 层),
|
||||||
/// 与 `crate::game::java::vendor::VENDOR_IDS` 必须是同一份集合——setting 层不依赖 game 层
|
/// 一致性由 `tests/setting/java_install_keys.rs` 的契约测试固定。
|
||||||
/// (分层纪律),一致性由 `tests/setting/java_install_keys.rs` 的契约测试固定。
|
|
||||||
pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "termux"];
|
pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "termux"];
|
||||||
|
|
||||||
|
/// 自更新两键(M161;拆到自己的文件守 400 行)。
|
||||||
|
pub mod update;
|
||||||
|
|
||||||
/// launcher.json 当前 schema 版本。
|
/// launcher.json 当前 schema 版本。
|
||||||
///
|
///
|
||||||
/// `1.1.0` 登记 D20 实例隔离键(`instances.mode` 等)。
|
/// `1.1.0` 登记 D20 实例隔离键(`instances.mode` 等)。
|
||||||
@@ -49,11 +49,11 @@ pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "te
|
|||||||
/// 迁移链(按 major 走)不需要新步骤;`1.1.0 → 1.2.0` 的差异只体现在文件里出现新键之后。
|
/// 迁移链(按 major 走)不需要新步骤;`1.1.0 → 1.2.0` 的差异只体现在文件里出现新键之后。
|
||||||
/// `1.3.0` 登记 D17 的「外观(高级)」P1 键(`appearance.bg_image*` / `panel_alpha` / `glass` /
|
/// `1.3.0` 登记 D17 的「外观(高级)」P1 键(`appearance.bg_image*` / `panel_alpha` / `glass` /
|
||||||
/// `accent_color`):同一套纯增量语义——出厂全默认(高级特性全关)即等于老文件缺键。
|
/// `accent_color`):同一套纯增量语义——出厂全默认(高级特性全关)即等于老文件缺键。
|
||||||
/// `1.4.0` 登记 P4 的外观下层三键(`appearance.window_opacity` / `titlebar_mode` /
|
/// `1.4.0` 登记 P4 的外观下层三键(`window_opacity` / `titlebar_mode` / `palette_source`,M91):
|
||||||
/// `palette_source`,M91 · D36/D37/D38):仍然只推 minor、不加迁移步骤——三者的出厂值
|
/// 仍然只推 minor、不加迁移步骤——三者的出厂值正是「老文件没有这些键」时的等价物。
|
||||||
/// (不透明 / auto / 内置色卡)正是「老文件没有这些键」时的等价物。
|
/// `1.5.0` 登记 M116 的窗口圆角策略(`appearance.window_corner`,出厂 `system`):纯增量。
|
||||||
/// `1.5.0` 登记 M116 的窗口圆角策略(`appearance.window_corner`,出厂 `system`):
|
/// `1.6.0` 登记 M161 的「自更新」两键(见 [`update`]):纯增量但带一步数据迁移
|
||||||
/// 同一套纯增量语义——**新读旧**:老文件缺该键 ⇒ 读默认 `system` ⇒ 跟随系统,与写进文件后行为一致。
|
/// (此前住在 `cache/update/prefs.json`,迁移见 [`super::update_prefs`])。
|
||||||
///
|
///
|
||||||
/// **旧读新(方向相反,措辞必须精确)**:1.4.0 的客户端读 1.5.0 的文件时,
|
/// **旧读新(方向相反,措辞必须精确)**:1.4.0 的客户端读 1.5.0 的文件时,
|
||||||
/// [`super::schema::SchemaCompat::check_url`] 走 `found.minor > expected.minor` →
|
/// [`super::schema::SchemaCompat::check_url`] 走 `found.minor > expected.minor` →
|
||||||
@@ -64,7 +64,7 @@ pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "te
|
|||||||
/// `$schema` 那一行里,不看版本号无从排查。
|
/// `$schema` 那一行里,不看版本号无从排查。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn launcher_version() -> SchemaVersion {
|
pub fn launcher_version() -> SchemaVersion {
|
||||||
SchemaVersion::new(1, 5, 0)
|
SchemaVersion::new(1, 6, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `config/launcher.json` 的 schema。
|
/// `config/launcher.json` 的 schema。
|
||||||
@@ -395,4 +395,5 @@ pub fn launcher_schema() -> SettingSchema {
|
|||||||
AiPolicy::audited(),
|
AiPolicy::audited(),
|
||||||
"启动器日志级别",
|
"启动器日志级别",
|
||||||
))
|
))
|
||||||
|
.with_all(update::specs())
|
||||||
}
|
}
|
||||||
@@ -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`]:单文件加载降级 + 原子保存 + 坏文件备份;
|
//! - [`store`]:单文件加载降级 + 原子保存 + 坏文件备份;
|
||||||
//! - [`save`] / [`save_worker`]:200ms 去抖攒批与后台保存线程;
|
//! - [`save`] / [`save_worker`]:200ms 去抖攒批与后台保存线程;
|
||||||
//! - [`migrate`]:`schemaVersion N → N+1` 迁移链 + 收据闸门。
|
//! - [`migrate`]:`schemaVersion N → N+1` 迁移链 + 收据闸门。
|
||||||
|
//! - [`update_prefs`]:自更新偏好(旧 `cache/update/prefs.json`)→ `launcher.json` 的一次性迁移
|
||||||
|
//! (M161:幂等、源指纹闸门、旧文件退役、收据可查)。
|
||||||
|
|
||||||
pub mod appearance;
|
pub mod appearance;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
@@ -34,3 +36,4 @@ pub mod save_worker;
|
|||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod spec;
|
pub mod spec;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
pub mod update_prefs;
|
||||||
@@ -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() {
|
fn schema_registration_shape_is_right() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
launcher_version().minor,
|
launcher_version().minor,
|
||||||
5,
|
6,
|
||||||
"1.5.0 登记 M116 的窗口圆角策略键"
|
"1.6.0 登记 M161 的自更新两键(update.auto_check / update.channel)"
|
||||||
);
|
);
|
||||||
let schema = launcher_schema();
|
let schema = launcher_schema();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -128,9 +128,10 @@ fn overrides_key_is_an_enum_whitelist() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn schema_versions_register_the_new_keys_without_migration() {
|
fn schema_versions_register_the_new_keys_without_migration() {
|
||||||
// 1.5.0 登记 M116 的窗口圆角策略键(1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」,
|
// 1.6.0 登记 M161 的自更新两键(`update.auto_check` / `update.channel`;
|
||||||
|
// 1.5.0 = M116 窗口圆角,1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」,
|
||||||
// 1.2.0 = D23 的 JDK 落点/优先级键)。
|
// 1.2.0 = D23 的 JDK 落点/优先级键)。
|
||||||
assert_eq!(launcher_version(), SchemaVersion::new(1, 5, 0));
|
assert_eq!(launcher_version(), SchemaVersion::new(1, 6, 0));
|
||||||
assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0));
|
assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0));
|
||||||
assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER);
|
assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER);
|
||||||
// major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。
|
// major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ fn install_root_accepts_a_path_and_keeps_empty_as_unset() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() {
|
fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() {
|
||||||
assert_eq!(launcher_version(), SchemaVersion::new(1, 5, 0));
|
// 1.6.0 = M161 的自更新两键(此前 1.5.0 是 M116 窗口圆角)。
|
||||||
|
assert_eq!(launcher_version(), SchemaVersion::new(1, 6, 0));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
launcher_version().major,
|
launcher_version().major,
|
||||||
1,
|
1,
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ mod redirect;
|
|||||||
mod schema_compat;
|
mod schema_compat;
|
||||||
mod spec_ai;
|
mod spec_ai;
|
||||||
mod store;
|
mod store;
|
||||||
|
mod update_keys;
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
//! M161:`update.auto_check` / `update.channel` 的 schema 契约 + 旧 `cache/update/prefs.json` 的迁移。
|
||||||
|
//!
|
||||||
|
//! 迁移的四条硬约束在这里逐条钉住(幂等 / 不丢 `false` / 新值胜出 / 旧文件退役),
|
||||||
|
//! 另外还有一条 schema 层面的:`update.channel` 现在只允许 `stable`——清单里没有的渠道
|
||||||
|
//! 写进文件等于「设了但没生效」,宁可让 `config set` 当面拒绝。
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
use unlucky_core::setting::document::{RenderMode, SettingDocument};
|
||||||
|
use unlucky_core::setting::keys::launcher_schema;
|
||||||
|
use unlucky_core::setting::keys::update::UPDATE_CHANNELS;
|
||||||
|
use unlucky_core::setting::spec::{Sensitivity, ValueKind};
|
||||||
|
use unlucky_core::setting::store::SettingStore;
|
||||||
|
use unlucky_core::setting::update_prefs::{
|
||||||
|
self, Migration, Targets, DEFAULT_AUTO_CHECK, DEFAULT_CHANNEL, KEY_AUTO_CHECK, KEY_CHANNEL,
|
||||||
|
RECEIPT_FILE,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::common::TempDir;
|
||||||
|
|
||||||
|
/// 三个路径(`<root>/config/launcher.json` + `<root>/cache/update/prefs.json` + 收据)。
|
||||||
|
fn targets(_temp: &TempDir) -> (String, String, String) {
|
||||||
|
(
|
||||||
|
"config/launcher.json".to_string(),
|
||||||
|
"cache/update/prefs.json".to_string(),
|
||||||
|
format!("config/{RECEIPT_FILE}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(temp: &TempDir) -> Migration {
|
||||||
|
let (launcher, legacy, receipt) = targets(temp);
|
||||||
|
update_prefs::migrate(
|
||||||
|
Targets {
|
||||||
|
launcher: &temp.child(&launcher),
|
||||||
|
legacy: &temp.child(&legacy),
|
||||||
|
receipt: &temp.child(&receipt),
|
||||||
|
},
|
||||||
|
1_700_000_000_000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读主配置里的显式值(`None` = 文件里没这个键)。
|
||||||
|
fn explicit(temp: &TempDir, key: &str) -> Option<serde_json::Value> {
|
||||||
|
let store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
|
||||||
|
store.doc().get(key).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_two_keys_are_registered_with_the_expected_shape() {
|
||||||
|
let schema = launcher_schema();
|
||||||
|
let auto = schema
|
||||||
|
.spec(KEY_AUTO_CHECK)
|
||||||
|
.expect("update.auto_check 必须登记");
|
||||||
|
assert_eq!(auto.default, json!(DEFAULT_AUTO_CHECK), "出厂是开");
|
||||||
|
assert_eq!(auto.kind, ValueKind::Bool);
|
||||||
|
assert_eq!(
|
||||||
|
auto.ai.sensitivity,
|
||||||
|
Sensitivity::Medium,
|
||||||
|
"改它会让启动器自己下载并替换 exe,AI 改过要留审计痕迹"
|
||||||
|
);
|
||||||
|
|
||||||
|
let channel = schema.spec(KEY_CHANNEL).expect("update.channel 必须登记");
|
||||||
|
assert_eq!(channel.default, json!(DEFAULT_CHANNEL));
|
||||||
|
assert_eq!(channel.kind, ValueKind::Enum(UPDATE_CHANNELS));
|
||||||
|
assert!(schema.validate(KEY_CHANNEL, &json!("stable")).is_ok());
|
||||||
|
// 清单里还没有的渠道:**当面拒绝**(写进文件等于「设了但没生效」)。
|
||||||
|
assert!(schema.validate(KEY_CHANNEL, &json!("beta")).is_err());
|
||||||
|
assert!(schema.validate(KEY_CHANNEL, &json!("nightly")).is_err());
|
||||||
|
assert!(schema.validate(KEY_AUTO_CHECK, &json!(1)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_keys_render_in_the_nested_shape() {
|
||||||
|
let (mut document, problems) = SettingDocument::load(&launcher_schema(), &json!({}));
|
||||||
|
assert!(problems.is_empty(), "{problems:?}");
|
||||||
|
// 默认值不落盘:空文档渲染出来不该有 update 段。
|
||||||
|
assert!(
|
||||||
|
document.render(RenderMode::Full).get("update").is_none(),
|
||||||
|
"出厂值不该写进文件"
|
||||||
|
);
|
||||||
|
|
||||||
|
document.set(KEY_AUTO_CHECK, json!(false)).expect("写");
|
||||||
|
document.set(KEY_CHANNEL, json!("stable")).expect("写");
|
||||||
|
let rendered = document.render(RenderMode::Full);
|
||||||
|
assert_eq!(
|
||||||
|
rendered["update"],
|
||||||
|
json!({ "auto_check": false, "channel": "stable" }),
|
||||||
|
"点分键在文件里是嵌套对象(与既有键同一形状)"
|
||||||
|
);
|
||||||
|
// 读回来也要是同一个值(IPC/CLI 都读这一份)。
|
||||||
|
let (reloaded, problems) = SettingDocument::load(&launcher_schema(), &rendered);
|
||||||
|
assert!(problems.is_empty(), "{problems:?}");
|
||||||
|
assert!(
|
||||||
|
!update_prefs::auto_check(&reloaded),
|
||||||
|
"读回来的生效值必须是文件里的 false"
|
||||||
|
);
|
||||||
|
assert_eq!(update_prefs::channel(&reloaded), "stable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_false_switch_is_migrated_and_the_old_file_is_retired() {
|
||||||
|
let temp = TempDir::new("m161-migrate-false");
|
||||||
|
temp.write("cache/update/prefs.json", r#"{"auto_check": false}"#);
|
||||||
|
|
||||||
|
let outcome = run(&temp);
|
||||||
|
let Migration::Migrated {
|
||||||
|
auto_check,
|
||||||
|
retired,
|
||||||
|
receipt,
|
||||||
|
} = outcome.clone()
|
||||||
|
else {
|
||||||
|
panic!("应当迁入,实际 {outcome:?}");
|
||||||
|
};
|
||||||
|
assert!(!auto_check, "旧文件里的 false 不许被默认值盖掉");
|
||||||
|
|
||||||
|
// 主配置里是显式的 false(不是「缺键取默认」)。
|
||||||
|
assert_eq!(explicit(&temp, KEY_AUTO_CHECK), Some(json!(false)));
|
||||||
|
// 旧文件不能再被读到。
|
||||||
|
assert!(!temp.exists("cache/update/prefs.json"), "旧文件必须退役");
|
||||||
|
assert!(temp.exists("cache/update/prefs.json.migrated"), "留着取证");
|
||||||
|
assert_eq!(retired, temp.child("cache/update/prefs.json.migrated"));
|
||||||
|
// 收据可查:落盘、类型/指纹/端点齐全。
|
||||||
|
assert!(receipt.exists(), "收据要落盘:{}", receipt.display());
|
||||||
|
let text = temp.read(&format!("config/{RECEIPT_FILE}"));
|
||||||
|
assert!(text.contains("\"legacy_config\""), "{text}");
|
||||||
|
assert!(text.contains("prefs.json"), "{text}");
|
||||||
|
assert!(text.contains("launcher.json"), "{text}");
|
||||||
|
assert!(text.contains("\"applied_at_ms\": 1700000000000"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn running_twice_is_idempotent() {
|
||||||
|
let temp = TempDir::new("m161-migrate-twice");
|
||||||
|
temp.write("cache/update/prefs.json", r#"{"auto_check": false}"#);
|
||||||
|
|
||||||
|
assert!(run(&temp).touched(), "第一遍要真的动手");
|
||||||
|
let after_first = temp.read("config/launcher.json");
|
||||||
|
let receipt_first = temp.read(&format!("config/{RECEIPT_FILE}"));
|
||||||
|
|
||||||
|
// 第二遍:旧文件已经改名 ⇒ 什么都没得做。
|
||||||
|
let second = run(&temp);
|
||||||
|
assert_eq!(second, Migration::Nothing, "第二遍必须是空操作");
|
||||||
|
assert_eq!(temp.read("config/launcher.json"), after_first, "文件不许变");
|
||||||
|
assert_eq!(
|
||||||
|
temp.read(&format!("config/{RECEIPT_FILE}")),
|
||||||
|
receipt_first,
|
||||||
|
"收据也不该被重写(源指纹相同 = 跳过)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_legacy_file_is_a_silent_noop() {
|
||||||
|
let temp = TempDir::new("m161-migrate-absent");
|
||||||
|
assert_eq!(run(&temp), Migration::Nothing);
|
||||||
|
// 连主配置都不该建(没有值要写,就不该白建一个文件)。
|
||||||
|
assert!(!temp.exists("config/launcher.json"), "不该凭空建配置");
|
||||||
|
assert!(
|
||||||
|
!temp.exists(&format!("config/{RECEIPT_FILE}")),
|
||||||
|
"不该写收据"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_explicit_new_value_wins_over_the_legacy_file() {
|
||||||
|
let temp = TempDir::new("m161-migrate-new-wins");
|
||||||
|
temp.write(
|
||||||
|
"config/launcher.json",
|
||||||
|
&json!({
|
||||||
|
"$schema": schema_url(),
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"update": { "auto_check": true }
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
temp.write("cache/update/prefs.json", r#"{"auto_check": false}"#);
|
||||||
|
|
||||||
|
let outcome = run(&temp);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.as_str(),
|
||||||
|
"new_value_wins",
|
||||||
|
"主配置里有显式值时不许覆盖:{outcome:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
explicit(&temp, KEY_AUTO_CHECK),
|
||||||
|
Some(json!(true)),
|
||||||
|
"新值胜出"
|
||||||
|
);
|
||||||
|
// 旧文件照样退役(它已经不是真相源),收据也写了。
|
||||||
|
assert!(!temp.exists("cache/update/prefs.json"));
|
||||||
|
assert!(temp.exists(&format!("config/{RECEIPT_FILE}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_default_true_is_not_written_but_the_file_still_retires() {
|
||||||
|
let temp = TempDir::new("m161-migrate-default");
|
||||||
|
temp.write("cache/update/prefs.json", r#"{"auto_check": true}"#);
|
||||||
|
|
||||||
|
let outcome = run(&temp);
|
||||||
|
assert_eq!(outcome.as_str(), "migrated");
|
||||||
|
// 「默认值不落盘」:写进去只是噪音,生效值本来就是 true。
|
||||||
|
assert_eq!(explicit(&temp, KEY_AUTO_CHECK), None, "出厂值不落盘");
|
||||||
|
assert!(!temp.exists("cache/update/prefs.json"), "旧文件仍要退役");
|
||||||
|
let store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
|
||||||
|
assert!(update_prefs::auto_check(store.doc()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_corrupt_legacy_file_touches_nothing() {
|
||||||
|
let temp = TempDir::new("m161-migrate-corrupt");
|
||||||
|
temp.write("cache/update/prefs.json", "{ not json");
|
||||||
|
let outcome = run(&temp);
|
||||||
|
assert_eq!(outcome.as_str(), "unreadable", "{outcome:?}");
|
||||||
|
// 坏文件保持原样:下一次还能再试(我们既不猜值也不删证据)。
|
||||||
|
assert!(temp.exists("cache/update/prefs.json"));
|
||||||
|
assert!(!temp.exists(&format!("config/{RECEIPT_FILE}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_legacy_file_still_means_the_default() {
|
||||||
|
// `serde(default)`:老文件里没有 auto_check 字段(或整份是 `{}`)⇒ 按出厂值处理,
|
||||||
|
// 而不是把整份文件判成坏文件。
|
||||||
|
let temp = TempDir::new("m161-migrate-empty");
|
||||||
|
temp.write("cache/update/prefs.json", "{}");
|
||||||
|
let outcome = run(&temp);
|
||||||
|
assert_eq!(outcome.as_str(), "migrated");
|
||||||
|
let store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
|
||||||
|
assert!(update_prefs::auto_check(store.doc()), "缺字段取出厂值 true");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 真 `$schema`(加载不降级才看得到显式值)。
|
||||||
|
fn schema_url() -> String {
|
||||||
|
use unlucky_core::setting::keys::launcher_version;
|
||||||
|
use unlucky_core::setting::schema::{SchemaId, SchemaRef};
|
||||||
|
SchemaRef::new(SchemaId::LAUNCHER, launcher_version()).url()
|
||||||
|
}
|
||||||
@@ -292,6 +292,26 @@ macro_rules! content_entries {
|
|||||||
"路径过不了包内规整,已跳过"
|
"路径过不了包内规整,已跳过"
|
||||||
),
|
),
|
||||||
PackReasonUnreadable => ("cannot read it, skipped", "读不出来,已跳过"),
|
PackReasonUnreadable => ("cannot read it, skipped", "读不出来,已跳过"),
|
||||||
|
// --- M161:导入结果的两种失败**不许共用一句话** ---
|
||||||
|
// 「预检没通过(一个字节都没写)」与「写进去之后回滚完成」是两件事:
|
||||||
|
// 前者用户去修包/修配置再试,后者说明已经动过目标目录。此前两者共用
|
||||||
|
// `PackStatusFailed`("Import failed — rolled back"),预检失败时那句话是假的。
|
||||||
|
PackStatusFailedNotStarted => (
|
||||||
|
"Import failed — nothing was written",
|
||||||
|
"导入失败 —— 还没写任何文件"
|
||||||
|
),
|
||||||
|
PackStatusFailedIncomplete => (
|
||||||
|
"Import failed — rollback did not finish",
|
||||||
|
"导入失败 —— 回滚没做完"
|
||||||
|
),
|
||||||
|
PackFatalTitleNotStarted => (
|
||||||
|
"Preflight failed — nothing was written",
|
||||||
|
"预检未通过 —— 一个字节都没写"
|
||||||
|
),
|
||||||
|
PackFatalTitleIncomplete => (
|
||||||
|
"Rollback did not finish — the target folder may differ",
|
||||||
|
"回滚没做完 —— 目标目录可能与导入前不同"
|
||||||
|
),
|
||||||
} }
|
} }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
|||||||
("InstancesIsolationIsolated", "Isolated", "独立目录"),
|
("InstancesIsolationIsolated", "Isolated", "独立目录"),
|
||||||
("InstancesIsolationShared", "Shared", "共用目录"),
|
("InstancesIsolationShared", "Shared", "共用目录"),
|
||||||
("InstancesRunning", "Running · pid {pid}", "运行中 · pid {pid}"),
|
("InstancesRunning", "Running · pid {pid}", "运行中 · pid {pid}"),
|
||||||
("InstancesFailed", "Launch failed · {reason}", "启动失败 · {reason}"),
|
("InstancesFailedStage", "Launch failed · {stage}", "启动失败 · {stage}"),
|
||||||
("InstancesStageDownload", "Downloading", "下载中"),
|
("InstancesStageDownload", "Downloading", "下载中"),
|
||||||
("InstancesStageVerify", "Verifying", "校验中"),
|
("InstancesStageVerify", "Verifying", "校验中"),
|
||||||
("InstancesStageLaunch", "Starting", "启动中"),
|
("InstancesStageLaunch", "Starting", "启动中"),
|
||||||
@@ -59,17 +59,21 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
|||||||
("InstancesLaunchReset", "Reset to global", "恢复跟随全局"),
|
("InstancesLaunchReset", "Reset to global", "恢复跟随全局"),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 金表的**全量**行(前半段在本文件、后半段在 [`tail`],顺序即 `Key::ALL`)。
|
/// 金表的**全量**行(三段:本文件 + [`tail`] + [`tail2`],顺序即 `Key::ALL`)。
|
||||||
///
|
///
|
||||||
/// 拆成两段只为守 400 行硬线(M157 加了 19 行启动参数文案后越线);对调用方是**一条**序列,
|
/// 拆成两段只为守 400 行硬线(M157 加了 19 行启动参数文案后越线);对调用方是**一条**序列,
|
||||||
/// `catalog/tests.rs` 的逐字节断言不用关心分段。
|
/// `catalog/tests.rs` 的逐字节断言不用关心分段。
|
||||||
pub(super) fn rows() -> impl Iterator<Item = &'static (&'static str, &'static str, &'static str)> {
|
pub(super) fn rows() -> impl Iterator<Item = &'static (&'static str, &'static str, &'static str)> {
|
||||||
GOLDEN.iter().chain(tail::ROWS.iter())
|
GOLDEN
|
||||||
|
.iter()
|
||||||
|
.chain(tail::ROWS.iter())
|
||||||
|
.chain(tail2::ROWS.iter())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 金表总行数(`Key::ALL` 的条数)。
|
/// 金表总行数(`Key::ALL` 的条数)。
|
||||||
pub(super) fn len() -> usize {
|
pub(super) fn len() -> usize {
|
||||||
GOLDEN.len() + tail::ROWS.len()
|
GOLDEN.len() + tail::ROWS.len() + tail2::ROWS.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
mod tail;
|
mod tail;
|
||||||
|
mod tail2;
|
||||||
@@ -3,13 +3,20 @@
|
|||||||
//! 为什么拆:`golden.rs` 在 M157 加了 19 行启动参数文案后到 416 行,越过 400 行硬线;
|
//! 为什么拆:`golden.rs` 在 M157 加了 19 行启动参数文案后到 416 行,越过 400 行硬线;
|
||||||
//! 拆分口径与仓库其它 `-p1/-p2` 一致——**纯搬移、一行不改**,顺序即 `Key::ALL` 的后半段。
|
//! 拆分口径与仓库其它 `-p1/-p2` 一致——**纯搬移、一行不改**,顺序即 `Key::ALL` 的后半段。
|
||||||
//! 拼装与断言在 `golden.rs` 的 [`rows`] 与 `catalog/tests.rs`。
|
//! 拼装与断言在 `golden.rs` 的 [`rows`] 与 `catalog/tests.rs`。
|
||||||
|
|
||||||
pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||||
("LoaderVanilla", "Vanilla", "原版"),
|
("LoaderVanilla", "Vanilla", "原版"),
|
||||||
("LaunchOutcomeNormal", "Finished normally", "正常结束"),
|
("LaunchOutcomeNormal", "Finished normally", "正常结束"),
|
||||||
("LaunchOutcomeCrash", "The game crashed", "游戏崩溃"),
|
("LaunchOutcomeCrash", "The game crashed", "游戏崩溃"),
|
||||||
("LaunchOutcomeSilent", "Suspected silent failure: exit code 0, but the game never really started", "疑似静默失败:退出码 0,但游戏没真正跑起来"),
|
("LaunchOutcomeSilent", "Suspected silent failure: exit code 0, but the game never really started", "疑似静默失败:退出码 0,但游戏没真正跑起来"),
|
||||||
("LaunchFailedWithCode", "Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"),
|
("LaunchFailedWithCode", "Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"),
|
||||||
|
("LaunchStageVersionManifest", "Reading the version file", "读版本文件"),
|
||||||
|
("LaunchStageInheritance", "Resolving the inheritance chain", "解析继承链"),
|
||||||
|
("LaunchStageInstanceConfig", "Reading the instance settings", "读实例设置"),
|
||||||
|
("LaunchStageIsolation", "Preparing the isolated game folder", "准备隔离游戏目录"),
|
||||||
|
("LaunchStageIdentity", "Preparing the account", "准备账户"),
|
||||||
|
("LaunchStageSpawn", "Starting the game process", "启动游戏进程"),
|
||||||
|
("LaunchStageFailed", "Launch failed · {stage}: {detail}", "启动失败 · {stage}:{detail}"),
|
||||||
|
("LaunchToastEnded", "{outcome} · {seconds}s", "{outcome} · {seconds} 秒"),
|
||||||
("LaunchMsaNotConfigured", "Microsoft sign-in is not configured: it needs an Azure public client id (the login flow itself is implemented). Build with ULL_MSA_CLIENT_ID=<id>, or set that environment variable before launching.", "微软登录未配置:缺少 Azure 公共客户端 id(登录功能已实现,只差这一项配置)。构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build;或启动前设置同名环境变量 ULL_MSA_CLIENT_ID=<id>。"),
|
("LaunchMsaNotConfigured", "Microsoft sign-in is not configured: it needs an Azure public client id (the login flow itself is implemented). Build with ULL_MSA_CLIENT_ID=<id>, or set that environment variable before launching.", "微软登录未配置:缺少 Azure 公共客户端 id(登录功能已实现,只差这一项配置)。构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build;或启动前设置同名环境变量 ULL_MSA_CLIENT_ID=<id>。"),
|
||||||
("FontCjkNeedsDownload", "No Chinese font found on this system. Download an open-source (OFL) Chinese font?", "系统里没找到中文字体。要下载一套开源(OFL)中文字体吗?"),
|
("FontCjkNeedsDownload", "No Chinese font found on this system. Download an open-source (OFL) Chinese font?", "系统里没找到中文字体。要下载一套开源(OFL)中文字体吗?"),
|
||||||
("FontCjkDownloading", "Downloading Chinese font… {percent}%", "正在下载中文字体… {percent}%"),
|
("FontCjkDownloading", "Downloading Chinese font… {percent}%", "正在下载中文字体… {percent}%"),
|
||||||
@@ -112,8 +119,6 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
|||||||
("AgentSubtitleReal", "Conversation", "会话"),
|
("AgentSubtitleReal", "Conversation", "会话"),
|
||||||
("AgentPlaceholderReal", "Message the assistant…", "给助手发消息…"),
|
("AgentPlaceholderReal", "Message the assistant…", "给助手发消息…"),
|
||||||
("AgentEmptyBodyReal", "Ask about instances, downloads or logs.", "可以问实例、下载或日志。"),
|
("AgentEmptyBodyReal", "Ask about instances, downloads or logs.", "可以问实例、下载或日志。"),
|
||||||
// ── M106 `I18NFOLLOW`:General 分组真控件(语言选择器 + 其余三行)。**只追加在链尾**,
|
|
||||||
// 上一行到 `LogsEmptyAction` 的既有顺序一字未动(金表要证明的正是这件事)。
|
|
||||||
("SettingsLanguageFollowSystem", "Follow system", "跟随系统"),
|
("SettingsLanguageFollowSystem", "Follow system", "跟随系统"),
|
||||||
("SettingsLanguageZhCn", "Chinese (Simplified)", "简体中文"),
|
("SettingsLanguageZhCn", "Chinese (Simplified)", "简体中文"),
|
||||||
("SettingsLanguageEn", "English", "英文"),
|
("SettingsLanguageEn", "English", "英文"),
|
||||||
@@ -136,7 +141,6 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
|||||||
("SettingsOverridesEmptyBody", "Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest of the launcher keeps the defaults above.", "逐实例覆盖让某个实例保留自己的 Java 运行时、内存或 JVM 参数,其余实例沿用上面的默认值。"),
|
("SettingsOverridesEmptyBody", "Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest of the launcher keeps the defaults above.", "逐实例覆盖让某个实例保留自己的 Java 运行时、内存或 JVM 参数,其余实例沿用上面的默认值。"),
|
||||||
("SettingsOverridesAdd", "Add override", "新增覆盖"),
|
("SettingsOverridesAdd", "Add override", "新增覆盖"),
|
||||||
("AgentFallbackBody", "The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"),
|
("AgentFallbackBody", "The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"),
|
||||||
// --- M107 版本管理页(追加在链尾:`Key::ALL` 顺序 = 金表顺序) ---
|
|
||||||
("VersionRailTitle", "Version type", "版本类型"),
|
("VersionRailTitle", "Version type", "版本类型"),
|
||||||
("VersionMajorTitle", "Major version", "主版本"),
|
("VersionMajorTitle", "Major version", "主版本"),
|
||||||
("VersionMajorAll", "All versions", "全部版本"),
|
("VersionMajorAll", "All versions", "全部版本"),
|
||||||
@@ -147,11 +151,9 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
|||||||
("VersionSearchEmptyBody", "Try a shorter piece of the number — 1.21, 24w, a1.", "试试更短的片段 — 1.21、24w、a1。"),
|
("VersionSearchEmptyBody", "Try a shorter piece of the number — 1.21, 24w, a1.", "试试更短的片段 — 1.21、24w、a1。"),
|
||||||
("VersionSearchElsewhere", "{count} versions match, but they are in another type.", "有 {count} 个版本匹配,但它们在别的类型里。"),
|
("VersionSearchElsewhere", "{count} versions match, but they are in another type.", "有 {count} 个版本匹配,但它们在别的类型里。"),
|
||||||
("VersionSearchElsewhereAction", "Show all types", "看全部类型"),
|
("VersionSearchElsewhereAction", "Show all types", "看全部类型"),
|
||||||
// --- M107 r2(追加在末尾:`Key::ALL` 顺序 = 金表顺序) ---
|
|
||||||
("VersionSearchHitsOne", "1 match", "1 个匹配"),
|
("VersionSearchHitsOne", "1 match", "1 个匹配"),
|
||||||
("VersionSearchElsewhereTitle", "Found in another type", "在别的类型里找到了"),
|
("VersionSearchElsewhereTitle", "Found in another type", "在别的类型里找到了"),
|
||||||
("VersionSearchElsewhereOne", "1 version matches, but it is in another type.", "有 1 个版本匹配,但它在别的类型里。"),
|
("VersionSearchElsewhereOne", "1 version matches, but it is in another type.", "有 1 个版本匹配,但它在别的类型里。"),
|
||||||
// --- M109 内容库页(同样追加在链尾:`Key::ALL` 顺序 = 金表顺序) ---
|
|
||||||
("ContentTitle", "Content", "内容库"),
|
("ContentTitle", "Content", "内容库"),
|
||||||
("ContentSubtitle", "Modpacks, mods, resource packs and shaders", "整合包、Mod、资源包与光影"),
|
("ContentSubtitle", "Modpacks, mods, resource packs and shaders", "整合包、Mod、资源包与光影"),
|
||||||
("ContentSectionVersions", "Version management", "版本管理"),
|
("ContentSectionVersions", "Version management", "版本管理"),
|
||||||
@@ -206,206 +208,4 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
|||||||
("PackDismiss", "Dismiss", "关闭"),
|
("PackDismiss", "Dismiss", "关闭"),
|
||||||
("PackTargetLabel", "Install into", "装到实例"),
|
("PackTargetLabel", "Install into", "装到实例"),
|
||||||
("PackTargetNew", "New instance", "新建实例"),
|
("PackTargetNew", "New instance", "新建实例"),
|
||||||
("PackInstanceName", "Instance name", "实例名"),
|
|
||||||
("PackStageLine", "Step {index}/{total} · {stage}", "步骤 {index}/{total} · {stage}"),
|
|
||||||
("PackStageProbe", "Reading the manifest", "读取清单"),
|
|
||||||
("PackStageExtract", "Unpacking overrides", "解包覆盖内容"),
|
|
||||||
("PackStageFetch", "Fetching missing files", "补下载文件"),
|
|
||||||
("PackStageCommit", "Writing files", "落地写盘"),
|
|
||||||
("PackStageRegister", "Registering the instance", "登记实例"),
|
|
||||||
("PackProbeTitle", "This pack is almost a known format", "这个包差一点就是已知格式"),
|
|
||||||
("PackProbeLooksLike", "Looks like {format}, but missing: {missing}", "看起来像 {format},但缺:{missing}"),
|
|
||||||
("PackProbeUnknown", "No known format claims this pack", "没有任何已知格式认领这个包"),
|
|
||||||
("PackProbeSaw", "Found in the pack: {saw}", "包里看到:{saw}"),
|
|
||||||
("PackProbeNothing", "nothing recognisable", "没有可识别的东西"),
|
|
||||||
("PackStatusSuccess", "Imported", "已导入"),
|
|
||||||
("PackStatusPartial", "Imported with gaps", "导入完成,但有缺口"),
|
|
||||||
("PackStatusFailed", "Import failed — rolled back", "导入失败 —— 已整包回滚"),
|
|
||||||
("PackReportWritten", "Files written: {count} · {bytes}", "{count} 个文件已写盘 · {bytes}"),
|
|
||||||
("PackReportMissing", "Could not fetch (tolerated): {count}", "有 {count} 个文件没搞到(可容忍)"),
|
|
||||||
("PackReportConflicts", "Conflicts: {count}", "冲突:{count} 个"),
|
|
||||||
("PackReportRejected", "The client does not support these: {count}", "这些组件客户端不支持:{count} 个"),
|
|
||||||
("PackReportRejectedNote", "not supported by the client", "客户端不支持"),
|
|
||||||
("PackCancelled", "Import keeps running in the background — this result is discarded. Files already written stay.", "导入会在后台继续跑完 —— 本次结果作废;已经写下的文件会留下。"),
|
|
||||||
("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"),
|
|
||||||
("PackOverwriteNote", "Keeping both (renamed) is not supported yet: for each existing file you pick either “keep” or “overwrite”.", "「两者都留(改名)」暂不支持:每个已存在的文件只能选「保留」或「覆盖」。"),
|
|
||||||
("PackMissingOffline", "offline import", "离线导入"),
|
|
||||||
("PackMissingNoSource", "no download source available yet", "暂时拿不到下载地址"),
|
|
||||||
("PackMissingFetchFailed", "download failed: {detail}", "下载失败:{detail}"),
|
|
||||||
("PackFatalTitle", "Rolled back — nothing was left behind", "已回滚 —— 目标目录零残留"),
|
|
||||||
("PackFatalHint", "What to try next", "下一步可以试"),
|
|
||||||
("PackConflictUserModified", "you changed it", "你改过"),
|
|
||||||
("PackConflictUserDeleted", "you deleted it", "你删过"),
|
|
||||||
("PackConflictPreExisting", "was already there", "本来就在"),
|
|
||||||
("PackChoiceKeepUser", "kept yours", "保留我的"),
|
|
||||||
("PackChoiceOverwrite", "pack version used", "用包里的"),
|
|
||||||
("PackConflictHashes", "pack {pack} · base {base} · disk {disk}", "包内 {pack} · 基线 {base} · 磁盘 {disk}"),
|
|
||||||
("PackConflictDetail", "Hashes", "三方哈希"),
|
|
||||||
("PackPickTitle", "Before importing: {count} files already exist with different content", "导入前:有 {count} 个文件已存在且内容不同"),
|
|
||||||
("PackPickSummary", "Keep {keep} · overwrite {overwrite} · overwriting discards {bytes}", "保留 {keep} · 覆盖 {overwrite} · 覆盖将丢弃 {bytes}"),
|
|
||||||
("PackPickKeepAll", "Keep all mine", "全部保留我的"),
|
|
||||||
("PackPickOverwriteAll", "Overwrite all", "全部覆盖"),
|
|
||||||
("PackPickRowKeep", "Keep", "保留"),
|
|
||||||
("PackPickRowOverwrite", "Overwrite", "覆盖"),
|
|
||||||
("PackPickPage", "Items {from}-{to} of {total}", "第 {from}-{to} 项,共 {total} 项"),
|
|
||||||
("PackPickPrev", "Previous", "上一页"),
|
|
||||||
("PackPickNext", "Next", "下一页"),
|
|
||||||
("PackPickConfirm", "Import with these choices", "按这些选择导入"),
|
|
||||||
("PackPickCancel", "Cancel import", "取消导入"),
|
|
||||||
("PackPickDefaultNote", "Anything you do not change stays as you have it: file content is only replaced where you pick “Overwrite”.", "没有改动的一律按「保留」:只有你点了「覆盖」的文件才会被包内内容替换。"),
|
|
||||||
("PackPickUnknownRemote", "{count} remote entries only learn their path after download; they follow “keep”.", "还有 {count} 条远端条目要下载后才知道路径,一律按「保留」处置。"),
|
|
||||||
("PackPickSourceOverrides", "in the pack (overrides/)", "包内 overrides/"),
|
|
||||||
("PackPickSourceClientOverrides", "in the pack (client-overrides/)", "包内 client-overrides/"),
|
|
||||||
("PackPickSourceManifest", "manifest files[]", "清单 files[]"),
|
|
||||||
("PackPickDisk", "yours: {size}", "你的:{size}"),
|
|
||||||
("PackPickIncoming", "pack: {size}", "包内:{size}"),
|
|
||||||
("PackFormatModrinth", "Modrinth .mrpack", "Modrinth 的 .mrpack"),
|
|
||||||
("PackFormatCurseforge", "CurseForge manifest", "CurseForge 清单"),
|
|
||||||
("PackFormatMultimc", "MultiMC / Prism instance", "MultiMC·Prism 实例"),
|
|
||||||
("PackExportTitle", "Export this instance", "导出这个实例"),
|
|
||||||
("PackExportOpen", "Export…", "导出…"),
|
|
||||||
("PackExportFormat", "Format", "格式"),
|
|
||||||
("PackExportName", "Pack name", "包名"),
|
|
||||||
("PackExportVersion", "Version", "版本"),
|
|
||||||
("PackExportOutput", "Save to", "保存到"),
|
|
||||||
("PackExportOutputPlaceholder", "empty = suggested path", "留空 = 自动给一个路径"),
|
|
||||||
("PackExportPrecheck", "Dry run", "预检"),
|
|
||||||
("PackExportConfirm", "Write the pack", "确认写盘"),
|
|
||||||
("PackExportAbort", "Back", "返回"),
|
|
||||||
("PackExportMultimcNote", "MultiMC/Prism can only be imported, not exported (by design).", "MultiMC·Prism 只能导入、不能导出(有意省略)。"),
|
|
||||||
("PackExportDone", "Wrote {path} · {bytes}", "已写出 {path} · {bytes}"),
|
|
||||||
("PackExportFailed", "Export failed: {reason}", "导出失败:{reason}"),
|
|
||||||
("PackExportPending", "Scanning the instance…", "正在扫描实例…"),
|
|
||||||
("PackPreviewHead", "Will inline {inline} files · cannot export: {skipped}", "将内联 {inline} 个文件 · 无法导出 {skipped} 项"),
|
|
||||||
("PackPreviewRemote", "{count} files can be referenced remotely", "{count} 个文件可走远程引用"),
|
|
||||||
("PackPreviewInlineBytes", "{bytes} copied into overrides/", "{bytes} 会原样拷进 overrides/"),
|
|
||||||
("PackPreviewInlineTitle", "Copied into overrides/ (first {shown}):", "原样拷进 overrides/(只列前 {shown} 个):"),
|
|
||||||
("PackPreviewSkippedTitle", "Not in the pack: {count}", "没进包:{count} 项"),
|
|
||||||
("PackPreviewNotes", "Known losses", "已知损失"),
|
|
||||||
("PackPreviewTarget", "Will be written to {path}", "将写到 {path}"),
|
|
||||||
("PackPreviewMore", "…and {count} more files (not shown here)", "…还有 {count} 个文件(这里不展开)"),
|
|
||||||
("PackPreviewComplete", "Nothing is left out", "没有遗漏任何文件"),
|
|
||||||
("PackPreviewIncomplete", "Some files will not be in the pack", "有文件不会进包"),
|
|
||||||
("PackReasonModrinthSha1", "Modrinth match (SHA-1)", "Modrinth 命中(SHA-1)"),
|
|
||||||
("PackReasonCurseFingerprint", "CurseForge match (fingerprint)", "CurseForge 命中(文件指纹)"),
|
|
||||||
("PackReasonNoRemoteMatch", "unknown to both catalogs, inlined", "两个目录都不认识,已内联"),
|
|
||||||
("PackReasonCurseOnly", "only CurseForge knows it, this format cannot use it, inlined", "只有 CF 认识,目标格式用不上,已内联"),
|
|
||||||
("PackReasonModrinthOnly", "only Modrinth knows it, the CF manifest cannot use it, inlined", "只有 Modrinth 认识,CF 清单用不上,已内联"),
|
|
||||||
("PackReasonMissingSha512", "the catalogue entry has no sha512 hash, inlined", "命中缺 sha512,已内联"),
|
|
||||||
("PackReasonMissingCurseIds", "the catalogue entry has no CurseForge ids, inlined", "命中缺 projectID/fileID,已内联"),
|
|
||||||
("PackReasonDisabled", "disabled file, this format cannot express it (.disabled kept)", "禁用文件,目标格式表达不了(保留 .disabled)"),
|
|
||||||
("PackReasonSymlink", "symbolic link, skipped", "符号链接,已跳过"),
|
|
||||||
("PackReasonUnsafePath", "path fails the pack path rules, skipped", "路径过不了包内规整,已跳过"),
|
|
||||||
("PackReasonUnreadable", "cannot read it, skipped", "读不出来,已跳过"),
|
|
||||||
// ── M114 账户页:追加在 main 的全部域之后(金表只追加不重排)
|
|
||||||
("AccountTitle", "Accounts", "账户"),
|
|
||||||
("AccountSubtitle", "Microsoft, third-party skin servers and local profiles", "微软正版、第三方皮肤站与本地档案"),
|
|
||||||
("AccountListTitle", "All accounts", "全部账户"),
|
|
||||||
("AccountAddedOn", "Added {date}", "添加于 {date}"),
|
|
||||||
("AccountActiveChip", "Active", "当前"),
|
|
||||||
("AccountSwitch", "Use for launch", "切换为启动账户"),
|
|
||||||
("AccountRelogin", "Sign in again", "重新登录"),
|
|
||||||
("AccountRemove", "Remove", "删除"),
|
|
||||||
("AccountKindMicrosoft", "Microsoft", "微软正版"),
|
|
||||||
("AccountKindAuthlib", "Third-party skin server", "第三方皮肤站"),
|
|
||||||
("AccountKindOffline", "Local profile", "本地档案"),
|
|
||||||
("AccountServer", "Server {server}", "服务器 {server}"),
|
|
||||||
("AccountEmptyTitle", "No accounts yet", "还没有账户"),
|
|
||||||
("AccountEmptyBody", "Add a Microsoft account, a third-party skin server account, or a local profile.", "添加微软正版账户、第三方皮肤站账户,或建一个本地档案。"),
|
|
||||||
("AccountEmptyAction", "Add an account", "添加账户"),
|
|
||||||
("AccountTrackMicrosoftTitle", "Microsoft account", "微软正版"),
|
|
||||||
("AccountTrackMicrosoftBody", "Sign in with a device code or in your browser. Online servers require this kind of account.", "用设备码或浏览器登录。联机服务器必须用这种账户。"),
|
|
||||||
("AccountTrackAuthlibTitle", "Third-party skin server", "第三方皮肤站"),
|
|
||||||
("AccountTrackAuthlibBody", "An authlib-injector compatible server (LittleSkin and friends) brings its own skins and capes.", "兼容 authlib-injector 的皮肤站(如 LittleSkin)自带皮肤与披风。"),
|
|
||||||
("AccountTrackOfflineTitle", "Local profile", "本地档案"),
|
|
||||||
("AccountTrackOfflineBody", "Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"),
|
|
||||||
("AccountTrackOpen", "Sign in", "登录"),
|
|
||||||
("AccountTrackCreate", "Create profile", "创建档案"),
|
|
||||||
("AccountDialogClose", "Close", "关闭"),
|
|
||||||
("AccountOfflineNameLabel", "Profile name", "档案名"),
|
|
||||||
("AccountOfflineNameHint", "3–16 letters, digits or underscore", "3–16 位字母、数字或下划线"),
|
|
||||||
("AccountOfflineSubmit", "Create profile", "创建档案"),
|
|
||||||
("AccountOfflineWarning", "Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"),
|
|
||||||
("AccountDeviceCodeLabel", "Enter this code", "输入这个用户码"),
|
|
||||||
("AccountDeviceCodeCopy", "Copy code", "复制用户码"),
|
|
||||||
("AccountDeviceLinkCopy", "Copy link", "复制链接"),
|
|
||||||
("AccountDeviceCountdown", "{time} left", "剩 {time}"),
|
|
||||||
("AccountDeviceExpired", "This code has expired. Start again for a fresh one.", "这个用户码已过期,重新开始换一个新的。"),
|
|
||||||
("AccountDeviceRetry", "Try again", "重试"),
|
|
||||||
("AccountDeviceCancel", "Cancel", "取消"),
|
|
||||||
("AccountDeviceBrowser", "Sign in with browser", "用浏览器登录"),
|
|
||||||
("AccountDeviceUseCode", "Use a device code instead", "改用设备码"),
|
|
||||||
("AccountServerLabel", "Server address", "服务器地址"),
|
|
||||||
("AccountServerHint", "API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"),
|
|
||||||
("AccountServerProbe", "Check server", "校验服务器"),
|
|
||||||
("AccountLoginNameLabel", "Email or username", "邮箱或用户名"),
|
|
||||||
("AccountPasswordLabel", "Password", "密码"),
|
|
||||||
("AccountPasswordHint", "Used once for this sign-in. It is never stored.", "只在这次登录里用一次,不会保存。"),
|
|
||||||
("AccountAuthlibSubmit", "Sign in", "登录"),
|
|
||||||
("AccountAuthlibIdleHint", "The server is checked automatically when you sign in; Check server only does it up front.", "登录时会自动校验服务器;「校验服务器」只是提前跑一遍。"),
|
|
||||||
("AccountSkinTitle", "Skin", "皮肤"),
|
|
||||||
("AccountSkinNone", "No skin available", "没有可用皮肤"),
|
|
||||||
("AccountSkinHint", "Skins come from the account's own server; nothing is generated locally.", "皮肤来自账户自己的服务器,本地不生成任何假皮肤。"),
|
|
||||||
("AccountRailSignedOut", "Not signed in", "未登录"),
|
|
||||||
("AccountDeviceHint", "Open the link in your browser and enter the code above.", "在浏览器里打开链接,输入上面的用户码。"),
|
|
||||||
("AccountDeviceBrowserHint", "Finish the sign-in in the browser window we just opened; the link below also works.", "在我们刚打开的浏览器窗口里完成登录;下面的链接也能手动打开。"),
|
|
||||||
("AccountServerChecking", "Checking the server…", "正在校验服务器…"),
|
|
||||||
("AccountServerOk", "Server {server} is ready", "服务器 {server} 可用"),
|
|
||||||
("AccountLoginFailed", "Sign-in failed", "登录失败"),
|
|
||||||
("AccountCurrentTitle", "Current account", "当前账户"),
|
|
||||||
("AccountFailureCode", "Code: {code}", "代码:{code}"),
|
|
||||||
("AccountFailureDenied", "The server refused this sign-in (access denied).", "服务器拒绝了这次登录(拒绝访问)。"),
|
|
||||||
("AccountFailureWhitelist", "Microsoft has not approved this launcher's app registration yet. An administrator has to accept the app, then try again.", "微软还没批准本启动器的应用注册:需要管理员同意该应用后再试。"),
|
|
||||||
("AccountFailureUnreachable", "Could not reach the server. Check the network connection or the address, then try again.", "连不上服务器:检查网络或地址后重试。"),
|
|
||||||
("AccountFailureNotAuthlib", "This address does not look like an authlib-injector server (the API root usually ends with /api/yggdrasil/).", "这个地址不像 authlib-injector 服务器(API 根通常以 /api/yggdrasil/ 结尾)。"),
|
|
||||||
("AccountFailureBadCredentials", "The server rejected the username or password.", "服务器不认这个用户名或密码。"),
|
|
||||||
("AccountFailureNoProfile", "The account has no Minecraft profile on this server yet. Create the character on the server first.", "这个账号在该服务器上还没有角色:先在皮肤站里创建角色。"),
|
|
||||||
("AccountFailureStore", "Could not save the account to disk.", "账户没能写到磁盘上。"),
|
|
||||||
("AccountFailureOther", "The sign-in failed. The full reason is in the launcher log.", "登录失败,完整原因在启动器日志里。"),
|
|
||||||
("AccountLoginNotConfigured", "Microsoft sign-in is not configured", "微软登录未配置"),
|
|
||||||
("AccountFailureMsaNotConfigured", "This build has no Azure public client id, so Microsoft sign-in cannot start (the sign-in flow itself is implemented). Set the environment variable ULL_MSA_CLIENT_ID=<id> before starting the launcher, or build with ULL_MSA_CLIENT_ID=<id>. Then restart the launcher: a running process does not pick up a new environment variable.", "本启动器没有配置 Azure 公共客户端 id,微软登录无法开始(登录功能已实现,只差这一项配置)。启动前设置环境变量 ULL_MSA_CLIENT_ID=<id>,或构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build。改完请重启启动器——已在运行的进程读不到新设的环境变量。"),
|
|
||||||
("AccountDeviceRequesting", "Contacting Microsoft…", "正在联系微软…"),
|
|
||||||
("AccountLoginSucceeded", "Signed in as {name}", "已登录 {name}"),
|
|
||||||
("AccountNoticeAdded", "Added {name}", "已添加 {name}"),
|
|
||||||
("AccountNoticeRemoved", "Removed {name}", "已删除 {name}"),
|
|
||||||
("AccountNoticeSwitched", "{name} will be used for the next launch", "{name} 将作为下次启动的账户"),
|
|
||||||
("AccountNoticeOfflineNameInvalid", "Profile names must be 3–16 letters, digits or underscores.", "档案名必须是 3–16 位字母、数字或下划线。"),
|
|
||||||
("AccountNoticeAccountExists", "An account with that name already exists.", "同名账户已经存在。"),
|
|
||||||
("AccountNoticeStoreFailed", "Could not save the account store. Check the disk and try again.", "账户库写不进去:检查磁盘后重试。"),
|
|
||||||
("AccountNoticeNotFound", "That account no longer exists.", "这个账户已经不在了。"),
|
|
||||||
("AccountNoticeOther", "The account action failed. The full reason is in the launcher log.", "账户操作失败,完整原因在启动器日志里。"),
|
|
||||||
("AccountNoticeDismiss", "Dismiss", "关闭"),
|
|
||||||
("AccountNoticeTitleFailed", "Account action failed", "账户操作失败"),
|
|
||||||
("AccountNoticeTitleDone", "Account updated", "账户已更新"),
|
|
||||||
("AccountHintAuthlibReauth", "Enter the password again for {server}.", "请输入 {server} 的密码。"),
|
|
||||||
("AccountHintOfflineNoRelogin", "Local profiles have no server account to sign in again — create a new profile instead.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"),
|
|
||||||
("SettingsGroupUpdate", "Updates", "更新"),
|
|
||||||
("SettingsUpdateCurrent", "Current version", "当前版本"),
|
|
||||||
("SettingsUpdateChannel", "Channel", "更新渠道"),
|
|
||||||
("SettingsUpdateChannelStable", "Stable", "稳定版"),
|
|
||||||
("SettingsUpdateAutoCheck", "Check for updates on startup", "启动时检查更新"),
|
|
||||||
("SettingsUpdateCheckNow", "Check now", "立即检查"),
|
|
||||||
("SettingsUpdateChecking", "Checking…", "正在检查…"),
|
|
||||||
("SettingsUpdateNever", "Not checked yet", "还没检查过"),
|
|
||||||
("SettingsUpdateUpToDate", "You are on the latest version ({version})", "已是最新({version})"),
|
|
||||||
("SettingsUpdateAvailable", "Version {version} is available ({size})", "有新版本 {version}({size})"),
|
|
||||||
("SettingsUpdateMissingArtifact", "Version {version} is out, but there is no build for this platform yet.", "{version} 已发布,但还没有本平台的安装包。"),
|
|
||||||
("SettingsUpdateFailed", "Check failed: {reason}", "检查失败:{reason}"),
|
|
||||||
("SettingsUpdateApply", "Download and install", "下载并安装"),
|
|
||||||
("SettingsUpdateApplying", "Downloading and verifying…", "正在下载并校验…"),
|
|
||||||
("SettingsUpdateReady", "Version {version} is ready — restart to use it.", "版本 {version} 已就位,重启后生效。"),
|
|
||||||
("SettingsUpdateNotes", "Release notes: {notes}", "更新说明:{notes}"),
|
|
||||||
("SettingsUpdateForced", "This update is required", "这是一次强制更新"),
|
|
||||||
("SettingsUpdateLastChecked", "Last check", "上次检查"),
|
|
||||||
("SettingsUpdateHint", "Downloads are verified with SHA256 before they touch the launcher. The running copy stays as it is until you restart.", "更新包在落到启动器之前会做 SHA256 校验;正在运行的这一份在你重启之前不会变。"),
|
|
||||||
("UpdateBannerTitleAvailable", "Version {version} is available", "发现新版本 {version}"),
|
|
||||||
("UpdateBannerBodyAvailable", "Download size {size}", "下载体积 {size}"),
|
|
||||||
("UpdateBannerTitleReady", "Update ready", "更新已就位"),
|
|
||||||
("UpdateBannerBodyReady", "Version {version} will run after a restart.", "重启后运行版本 {version}。"),
|
|
||||||
("UpdateBannerTitleFailed", "Update failed", "更新失败"),
|
|
||||||
("UpdateBannerApplying", "Downloading the update…", "正在下载更新…"),
|
|
||||||
("UpdateBannerInstall", "Download and install", "下载并安装"),
|
|
||||||
("UpdateBannerDismiss", "Skip this version", "忽略这个版本"),
|
|
||||||
("UpdateBannerRestartNow", "Restart now", "立即重启"),
|
|
||||||
("UpdateBannerRetry", "Try again", "重试"),
|
|
||||||
];
|
];
|
||||||
@@ -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,7 @@ macro_rules! instances_entries {
|
|||||||
InstancesIsolationShared => ("Shared", "共用目录"),
|
InstancesIsolationShared => ("Shared", "共用目录"),
|
||||||
// ── 运行状态与启动阶段(M120;数字与分隔符不随语言变形)
|
// ── 运行状态与启动阶段(M120;数字与分隔符不随语言变形)
|
||||||
InstancesRunning => ("Running · pid {pid}", "运行中 · pid {pid}"),
|
InstancesRunning => ("Running · pid {pid}", "运行中 · pid {pid}"),
|
||||||
InstancesFailed => ("Launch failed · {reason}", "启动失败 · {reason}"),
|
InstancesFailedStage => ("Launch failed · {stage}", "启动失败 · {stage}"),
|
||||||
InstancesStageDownload => ("Downloading", "下载中"),
|
InstancesStageDownload => ("Downloading", "下载中"),
|
||||||
InstancesStageVerify => ("Verifying", "校验中"),
|
InstancesStageVerify => ("Verifying", "校验中"),
|
||||||
InstancesStageLaunch => ("Starting", "启动中"),
|
InstancesStageLaunch => ("Starting", "启动中"),
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ macro_rules! launch_entries {
|
|||||||
"疑似静默失败:退出码 0,但游戏没真正跑起来"
|
"疑似静默失败:退出码 0,但游戏没真正跑起来"
|
||||||
),
|
),
|
||||||
LaunchFailedWithCode => ("Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"),
|
LaunchFailedWithCode => ("Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"),
|
||||||
|
// M161:启动失败的**阶段**词表。presenter 只给稳定 slug
|
||||||
|
// (`LaunchFailureStage`),界面在这里选词——此前这些前缀是 presenter 里拼的中文
|
||||||
|
// (「读版本 JSON 失败」「隔离目录派生失败」…),英文界面上原样露出来。
|
||||||
|
LaunchStageVersionManifest => ("Reading the version file", "读版本文件"),
|
||||||
|
LaunchStageInheritance => ("Resolving the inheritance chain", "解析继承链"),
|
||||||
|
LaunchStageInstanceConfig => ("Reading the instance settings", "读实例设置"),
|
||||||
|
LaunchStageIsolation => ("Preparing the isolated game folder", "准备隔离游戏目录"),
|
||||||
|
LaunchStageIdentity => ("Preparing the account", "准备账户"),
|
||||||
|
LaunchStageSpawn => ("Starting the game process", "启动游戏进程"),
|
||||||
|
LaunchStageFailed => ("Launch failed · {stage}: {detail}", "启动失败 · {stage}:{detail}"),
|
||||||
|
LaunchToastEnded => ("{outcome} · {seconds}s", "{outcome} · {seconds} 秒"),
|
||||||
// M117:微软轨的 client id 没配是**配置缺失**,不是「本期未支持」。这句话是那条
|
// M117:微软轨的 client id 没配是**配置缺失**,不是「本期未支持」。这句话是那条
|
||||||
// 启动失败文案的**双语口径**(真源在 `core::auth::microsoft` 的
|
// 启动失败文案的**双语口径**(真源在 `core::auth::microsoft` 的
|
||||||
// `MISSING_CLIENT_ID_MESSAGE`,中文逐字一致;英文这条给 En 界面用)。
|
// `MISSING_CLIENT_ID_MESSAGE`,中文逐字一致;英文这条给 En 界面用)。
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use crate::dto::{
|
|||||||
InstanceSummary,
|
InstanceSummary,
|
||||||
JavaPageView,
|
JavaPageView,
|
||||||
LaunchArgsPatch,
|
LaunchArgsPatch,
|
||||||
|
LaunchFailureView,
|
||||||
LoaderKind,
|
LoaderKind,
|
||||||
LogLine,
|
LogLine,
|
||||||
LogOutcome,
|
LogOutcome,
|
||||||
@@ -54,6 +55,11 @@ pub struct LiveLogBatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 一次启动收尾(磁贴状态回写 + 日志页会话列表刷新)。
|
/// 一次启动收尾(磁贴状态回写 + 日志页会话列表刷新)。
|
||||||
|
///
|
||||||
|
/// **不带人读散文**(M161):早先这里有一个 `summary: String`,内容是 presenter 拼的中文
|
||||||
|
/// (`正常退出` / `崩溃(exit 1)` / `启动失败:找不到 Java`)。于是英文界面上弹出的是中文 Toast,
|
||||||
|
/// 磁贴那行还叠成「Launch failed · 启动失败:…」。现在只给**结构**,文案由 app 层按 key 现求
|
||||||
|
/// (与 `InstallCancelled` 同一条纪律:presenter 不依赖 `unlucky-i18n`)。
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct LaunchFinished {
|
pub struct LaunchFinished {
|
||||||
/// 实例 id
|
/// 实例 id
|
||||||
@@ -62,8 +68,13 @@ pub struct LaunchFinished {
|
|||||||
pub session: SessionId,
|
pub session: SessionId,
|
||||||
/// 结局
|
/// 结局
|
||||||
pub outcome: LogOutcome,
|
pub outcome: LogOutcome,
|
||||||
/// 一句人读结论(`正常退出` / `崩溃(exit 1)` / `启动失败:…`)
|
/// 进程退出码(`None` = 进程没起来 / 平台没给)
|
||||||
pub summary: String,
|
pub exit_code: Option<i32>,
|
||||||
|
/// 从 spawn 到收尾的毫秒数(没起来时是 0)
|
||||||
|
pub uptime_ms: u64,
|
||||||
|
/// 失败发生在哪一步(`None` = 这一场没有装配/启动阶段的失败;
|
||||||
|
/// 「跑起来又崩了」看 `outcome`)
|
||||||
|
pub failure: Option<LaunchFailureView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// core 侧事件(不可变快照发布 + 事件流,01c §5 的三条通道之二)。
|
/// core 侧事件(不可变快照发布 + 事件流,01c §5 的三条通道之二)。
|
||||||
@@ -248,9 +259,17 @@ pub enum AppCommand {
|
|||||||
CheckUpdate,
|
CheckUpdate,
|
||||||
/// 把当前候选下载下来、校验、替换本程序(结果经 [`CoreEvent::Update`] 回来)
|
/// 把当前候选下载下来、校验、替换本程序(结果经 [`CoreEvent::Update`] 回来)
|
||||||
ApplyUpdate,
|
ApplyUpdate,
|
||||||
/// 忽略这个版本(本次会话不再弹提示条;候选仍留在快照里,设置页可手动装)
|
/// 忽略**这一个候选版本**(记的是版本号,不是「别再问我」)。
|
||||||
|
///
|
||||||
|
/// 语义三条(`UpdateView::skipped` 的契约,`dto::update` 的模块文档与用例守着):
|
||||||
|
/// 同一次运行里同一个版本不再弹提示条;**更高的版本照弹**(0.2.3 被忽略不影响 0.2.4);
|
||||||
|
/// 设置页的「立即检查」会清掉它。候选本身仍留在快照里,设置页可手动装。
|
||||||
DismissUpdate,
|
DismissUpdate,
|
||||||
/// 开关「启动时静默检查」(写进更新域自己的偏好文件)
|
/// 开关「启动时静默检查」。
|
||||||
|
///
|
||||||
|
/// 落点是主配置 `config/launcher.json` 的 `update.auto_check`(M161 从更新域自己的
|
||||||
|
/// `cache/update/prefs.json` 迁过来的 schema 键)——`ull config get update.auto_check`
|
||||||
|
/// 读到的是同一个值。
|
||||||
SetUpdateAutoCheck(bool),
|
SetUpdateAutoCheck(bool),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub use super::launch_failure::{LaunchFailureStage, LaunchFailureView};
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use unlucky_core::game::LoaderKind as CoreLoaderKind;
|
use unlucky_core::game::LoaderKind as CoreLoaderKind;
|
||||||
@@ -208,9 +210,15 @@ pub enum InstanceRuntime {
|
|||||||
pid: u32,
|
pid: u32,
|
||||||
},
|
},
|
||||||
/// 上次启动失败(磁贴显示「重试」)
|
/// 上次启动失败(磁贴显示「重试」)
|
||||||
|
///
|
||||||
|
/// M161 之前这里只有一个 `reason: String`,而且里面已经写着「启动失败:…」——
|
||||||
|
/// 界面再包一层 `InstancesFailed`(`Launch failed · {reason}`)就成了「英文前缀 + 中文句子」,
|
||||||
|
/// 中文界面则是「启动失败 · 启动失败:…」。现在拆成「阶段(可本地化)+ 原文(不翻译)」。
|
||||||
Failed {
|
Failed {
|
||||||
/// 失败原因(用户可见文案)
|
/// 失败阶段
|
||||||
reason: String,
|
stage: LaunchFailureStage,
|
||||||
|
/// 原始错误文本(不翻译)
|
||||||
|
detail: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
//! 启动失败的**结构**(M161:presenter 只给阶段 + 原文,文案由界面按 i18n key 现求)。
|
||||||
|
//!
|
||||||
|
//! 从 `dto/instances.rs` 拆出:那边贴着 400 行硬线,而这两个类型是「启动」这条线上的
|
||||||
|
//! 独立一块(磁贴状态行、收尾事件、Toast 三处都读它)。
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 启动失败发生在**哪一步**(M161:界面文案按它选 i18n key,presenter 不写散文)。
|
||||||
|
///
|
||||||
|
/// 分档只在「用户该去做什么」不同的地方切,不按代码结构切:
|
||||||
|
///
|
||||||
|
/// | 档 | 意思 | 用户下一步 |
|
||||||
|
/// |---|---|---|
|
||||||
|
/// | [`Self::VersionManifest`] | `versions/<id>.json` 读不出来 / 解析不了 | 去版本管理重装这个版本 |
|
||||||
|
/// | [`Self::Inheritance`] | 继承链断了(父版本缺失) | 同上(父版本缺了) |
|
||||||
|
/// | [`Self::InstanceConfig`] | 实例设置文件读不出来 | 去实例页看一眼那个实例 |
|
||||||
|
/// | [`Self::Isolation`] | 隔离目录派生失败(路径/权限) | 换隔离档位或修目录权限 |
|
||||||
|
/// | [`Self::Identity`] | 账户装配失败(找不到账户 / 凭据过期) | 去账户页重新登录 |
|
||||||
|
/// | [`Self::Spawn`] | 命令行装配完了但进程起不来 | 看 Java 与日志 |
|
||||||
|
///
|
||||||
|
/// 「跑起来了但崩了」不在这张表里:那是**结局**,由 `LogOutcome` 表达(三个变体各有 i18n key)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum LaunchFailureStage {
|
||||||
|
/// 版本 JSON 读不出来 / 解析不了
|
||||||
|
VersionManifest,
|
||||||
|
/// 继承链断裂
|
||||||
|
Inheritance,
|
||||||
|
/// 实例设置文件读不出来
|
||||||
|
InstanceConfig,
|
||||||
|
/// 隔离目录派生失败
|
||||||
|
Isolation,
|
||||||
|
/// 账户装配失败
|
||||||
|
Identity,
|
||||||
|
/// 进程起不来
|
||||||
|
Spawn,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LaunchFailureStage {
|
||||||
|
/// 稳定 slug(日志 / 事件流 / 测试用;**不要改名**)。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn slug(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::VersionManifest => "version_manifest",
|
||||||
|
Self::Inheritance => "inheritance",
|
||||||
|
Self::InstanceConfig => "instance_config",
|
||||||
|
Self::Isolation => "isolation",
|
||||||
|
Self::Identity => "identity",
|
||||||
|
Self::Spawn => "spawn",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次启动失败:**哪一步** + 那一步的原文。
|
||||||
|
///
|
||||||
|
/// `detail` 是当场的原始错误文本(core / io / 账户层给的),**故意不翻译**:里面有路径、
|
||||||
|
/// 版本 id、HTTP 状态这类机器细节,翻成人话就丢了取证价值。要本地化的是「哪一步」
|
||||||
|
/// ([`LaunchFailureStage`]),界面按它选词。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct LaunchFailureView {
|
||||||
|
/// 失败阶段
|
||||||
|
pub stage: LaunchFailureStage,
|
||||||
|
/// 原始错误文本(不翻译)
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LaunchFailureView {
|
||||||
|
/// 建一条。
|
||||||
|
pub fn new(stage: LaunchFailureStage, detail: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
stage,
|
||||||
|
detail: detail.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ mod instances;
|
|||||||
mod java;
|
mod java;
|
||||||
// M157: 实例启动参数(启动参数区的读取视图 + 写回补丁)
|
// M157: 实例启动参数(启动参数区的读取视图 + 写回补丁)
|
||||||
mod launch_args;
|
mod launch_args;
|
||||||
|
mod launch_failure;
|
||||||
mod logs;
|
mod logs;
|
||||||
mod notices;
|
mod notices;
|
||||||
// M113: import/export —— 整合包导入/导出的 DTO(M111/M112 管线的 IPC 面)
|
// M113: import/export —— 整合包导入/导出的 DTO(M111/M112 管线的 IPC 面)
|
||||||
@@ -45,8 +46,8 @@ pub use content::{
|
|||||||
pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch};
|
pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch};
|
||||||
pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage};
|
pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage};
|
||||||
pub use instances::{
|
pub use instances::{
|
||||||
CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo,
|
CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchFailureStage,
|
||||||
LoaderKind, Progress,
|
LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, Progress,
|
||||||
};
|
};
|
||||||
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
|
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
|
||||||
pub use launch_args::{LaunchArgsPatch, LaunchArgsView};
|
pub use launch_args::{LaunchArgsPatch, LaunchArgsView};
|
||||||
@@ -57,10 +58,10 @@ pub use pack::{
|
|||||||
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView,
|
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView,
|
||||||
ConflictReasonView, ConflictSourceView, ExportClassView, ExportDoneView, ExportFileView,
|
ConflictReasonView, ConflictSourceView, ExportClassView, ExportDoneView, ExportFileView,
|
||||||
ExportPreviewView, ExportReasonView, ExportRequestView, ExportState, FileActionView,
|
ExportPreviewView, ExportReasonView, ExportRequestView, ExportState, FileActionView,
|
||||||
ImportConflictView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView,
|
ImportConflictView, ImportErrorView, ImportFailureStageView, ImportProgressView,
|
||||||
ImportScanView, ImportStageView, ImportState, ImportStatusView, ImportedFileView,
|
ImportReportView, ImportRequestView, ImportScanView, ImportStageView, ImportState,
|
||||||
MissingFileView, MissingReasonView, PackCommand, PackEvent, PackFormatView, PackState,
|
ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView, PackCommand, PackEvent,
|
||||||
ProbeView,
|
PackFormatView, PackState, ProbeView,
|
||||||
};
|
};
|
||||||
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
|
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
|
||||||
// M159: 自更新
|
// M159: 自更新
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
//! 1. **三态**([`ImportStatusView`]):`Partial` 不是 `Success`。把「缺了 3 个文件」说成成功,
|
//! 1. **三态**([`ImportStatusView`]):`Partial` 不是 `Success`。把「缺了 3 个文件」说成成功,
|
||||||
//! 用户会在启动后才发现缺依赖;
|
//! 用户会在启动后才发现缺依赖;
|
||||||
//! 2. **容忍 / 致命的分野**:[`MissingFileView`] 是「搞不到某个文件」(实例可用),
|
//! 2. **容忍 / 致命的分野**:[`MissingFileView`] 是「搞不到某个文件」(实例可用),
|
||||||
//! [`ImportErrorView`] 是「已整包回滚」(实例零残留)。UI 必须让这两类**视觉可区分**;
|
//! [`ImportErrorView`] 是致命失败。UI 必须让这两类**视觉可区分**——并且致命那一类还要按
|
||||||
|
//! [`ImportReportView::failure_stage`] 再分三档说(预检没过 / 已回滚 / 回滚没做完,M161);
|
||||||
//! 3. **导出的三分类**([`ExportClassView`]):`Inline` 是「证明不了可被远程替代,原字节进
|
//! 3. **导出的三分类**([`ExportClassView`]):`Inline` 是「证明不了可被远程替代,原字节进
|
||||||
//! `overrides/`」,`Skipped` 是「压根没进包」。`skipped > 0` 意味着包**不完整**。
|
//! `overrides/`」,`Skipped` 是「压根没进包」。`skipped > 0` 意味着包**不完整**。
|
||||||
//!
|
//!
|
||||||
@@ -56,7 +57,7 @@ pub use export::{
|
|||||||
};
|
};
|
||||||
pub use format::PackFormatView;
|
pub use format::PackFormatView;
|
||||||
pub use import::{
|
pub use import::{
|
||||||
FileActionView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView,
|
FileActionView, ImportErrorView, ImportFailureStageView, ImportProgressView, ImportReportView,
|
||||||
ImportStageView, ImportState, ImportStatusView, ImportedFileView, MissingFileView,
|
ImportRequestView, ImportStageView, ImportState, ImportStatusView, ImportedFileView,
|
||||||
MissingReasonView, ProbeView,
|
MissingFileView, MissingReasonView, ProbeView,
|
||||||
};
|
};
|
||||||
@@ -62,6 +62,43 @@ impl ImportStageView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 致命失败的**阶段**(M161:`rolled_back` 一个人说不了两件事)。
|
||||||
|
///
|
||||||
|
/// core 的 `ImportReport::rolled_back` 语义是「目标目录已确认与导入前一致」——它同时覆盖
|
||||||
|
/// 「回滚完整完成」和「**从未写过**目标目录」两种情形(见 `core::addon::import::pipeline` 的
|
||||||
|
/// `record_rollback` / `abandoned`)。UI 拿一个 bool 判不出该说哪一句,于是预检失败也会被
|
||||||
|
/// 说成「已回滚」,而实际上一个字节都没写。这个枚举把两种情形重新分开,并补上第三种
|
||||||
|
/// (回滚有失败项,目标目录可能与导入前不同)。
|
||||||
|
///
|
||||||
|
/// 只在 `status == Failed` 时有值:成功/部分成功的报告没有「失败阶段」可言。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ImportFailureStageView {
|
||||||
|
/// 预检/装配阶段就失败了:**目标目录一个字节都没写**(用户该去修包/修配置再试)。
|
||||||
|
NotStarted,
|
||||||
|
/// 已经写过目标目录,回滚**完整**完成(零残留)。
|
||||||
|
RolledBack,
|
||||||
|
/// 写过了,但回滚有失败项:目标目录可能与导入前不同(`warnings` 里逐条列了)。
|
||||||
|
RollbackIncomplete,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImportFailureStageView {
|
||||||
|
/// 稳定 slug(日志 / 测试用;**不要改名**)。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn slug(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::NotStarted => "not_started",
|
||||||
|
Self::RolledBack => "rolled_back",
|
||||||
|
Self::RollbackIncomplete => "rollback_incomplete",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本次失败到底动没动目标目录。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn touched_target(self) -> bool {
|
||||||
|
!matches!(self, Self::NotStarted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 导入三态(`Partial` **不是** `Success`)。
|
/// 导入三态(`Partial` **不是** `Success`)。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum ImportStatusView {
|
pub enum ImportStatusView {
|
||||||
@@ -69,7 +106,11 @@ pub enum ImportStatusView {
|
|||||||
Success,
|
Success,
|
||||||
/// 有缺文件或有冲突(内容不完整,但实例可用)。
|
/// 有缺文件或有冲突(内容不完整,但实例可用)。
|
||||||
Partial,
|
Partial,
|
||||||
/// 致命失败,**已整包回滚**。
|
/// 致命失败。
|
||||||
|
///
|
||||||
|
/// **「动没动过目标目录」不在这里**(M161):同一个 `Failed` 既可能是预检没过(一个字节都没写),
|
||||||
|
/// 也可能是写完又回滚干净,还可能是回滚没做完——三种由
|
||||||
|
/// [`ImportReportView::failure_stage`] 分开说,界面按它选标题与图标。
|
||||||
Failed,
|
Failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +284,7 @@ pub struct MissingFileView {
|
|||||||
pub detail: String,
|
pub detail: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一次**已整包回滚**的致命失败。
|
/// 一次致命失败(是不是「已整包回滚」看 [`ImportReportView::failure_stage`])。
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ImportErrorView {
|
pub struct ImportErrorView {
|
||||||
/// 稳定码(`pack_traversal` / `import_integrity` …,进日志与 `--json`)。
|
/// 稳定码(`pack_traversal` / `import_integrity` …,进日志与 `--json`)。
|
||||||
@@ -292,7 +333,13 @@ pub struct ImportReportView {
|
|||||||
/// 本次写入的字节数。
|
/// 本次写入的字节数。
|
||||||
pub bytes_written: u64,
|
pub bytes_written: u64,
|
||||||
/// 回滚是否完整(`false` = 目标目录可能与导入前不一致,逐条在 `warnings` 里)。
|
/// 回滚是否完整(`false` = 目标目录可能与导入前不一致,逐条在 `warnings` 里)。
|
||||||
|
///
|
||||||
|
/// **注意**:`true` 有两种来源——「回滚完整完成」与「从未写过目标目录」(core 的
|
||||||
|
/// `abandoned`)。要区分它们看 [`ImportReportView::failure_stage`];界面上的措辞
|
||||||
|
/// **必须**按那个字段选(M161)。
|
||||||
pub rolled_back: bool,
|
pub rolled_back: bool,
|
||||||
|
/// 失败发生在哪一步(`None` = 这次不是致命失败)。
|
||||||
|
pub failure_stage: Option<ImportFailureStageView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ImportReportView {
|
impl ImportReportView {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use unlucky_core::addon::export::{ExportReport, FileClass, FileReason};
|
use unlucky_core::addon::export::{ExportReport, FileClass, FileReason};
|
||||||
|
use unlucky_core::addon::import::report::ImportStatus;
|
||||||
use unlucky_core::addon::import::{
|
use unlucky_core::addon::import::{
|
||||||
ConflictChoice, ConflictDecisions, ConflictFile, ConflictReason, FileAction, FileSource,
|
ConflictChoice, ConflictDecisions, ConflictFile, ConflictReason, FileAction, FileSource,
|
||||||
ImportError, ImportProgress, ImportReport, ImportScan, LoaderTarget, MissingReason,
|
ImportError, ImportProgress, ImportReport, ImportScan, LoaderTarget, MissingReason,
|
||||||
@@ -19,9 +20,9 @@ use unlucky_core::addon::import::{
|
|||||||
use crate::dto::{
|
use crate::dto::{
|
||||||
ConflictChoiceView, ConflictDecisionsView, ConflictFileView, ConflictReasonView,
|
ConflictChoiceView, ConflictDecisionsView, ConflictFileView, ConflictReasonView,
|
||||||
ConflictSourceView, ExportClassView, ExportDoneView, ExportReasonView, FileActionView,
|
ConflictSourceView, ExportClassView, ExportDoneView, ExportReasonView, FileActionView,
|
||||||
ImportConflictView, ImportErrorView, ImportProgressView, ImportReportView, ImportScanView,
|
ImportConflictView, ImportErrorView, ImportFailureStageView, ImportProgressView,
|
||||||
ImportStageView, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView,
|
ImportReportView, ImportScanView, ImportStageView, ImportStatusView, ImportedFileView,
|
||||||
PackFormatView,
|
MissingFileView, MissingReasonView, PackFormatView,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 一次导入**预检** → DTO(M160:冲突清单)。
|
/// 一次导入**预检** → DTO(M160:冲突清单)。
|
||||||
@@ -150,9 +151,29 @@ pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportV
|
|||||||
.map(|registered| registered.name.clone()),
|
.map(|registered| registered.name.clone()),
|
||||||
bytes_written: report.bytes_written,
|
bytes_written: report.bytes_written,
|
||||||
rolled_back: report.rolled_back,
|
rolled_back: report.rolled_back,
|
||||||
|
failure_stage: failure_stage(report),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 致命失败发生在哪一步(M161)。
|
||||||
|
///
|
||||||
|
/// 判据只用 core 的两个机器位,**不看 warnings 的措辞**:
|
||||||
|
/// - `rollback: Some(outcome)` ⇒ 真的跑过回滚(写过了),完整与否看 `is_complete()`;
|
||||||
|
/// - `rollback: None` ⇒ 没跑过回滚。core 的约定是「失败发生在碰目标目录之前」(`abandoned`),
|
||||||
|
/// 此时 `rolled_back == true`;若 `rolled_back` 也是 `false`(既没回滚也没宣称干净),
|
||||||
|
/// 那就如实报「可能不一致」,不替它圆场。
|
||||||
|
fn failure_stage(report: &ImportReport) -> Option<ImportFailureStageView> {
|
||||||
|
if report.status != ImportStatus::Failed {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(match &report.rollback {
|
||||||
|
Some(outcome) if outcome.is_complete() => ImportFailureStageView::RolledBack,
|
||||||
|
Some(_) => ImportFailureStageView::RollbackIncomplete,
|
||||||
|
None if report.rolled_back => ImportFailureStageView::NotStarted,
|
||||||
|
None => ImportFailureStageView::RollbackIncomplete,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// 一次导出结果 → DTO。
|
/// 一次导出结果 → DTO。
|
||||||
pub(super) fn done_view(report: &ExportReport) -> ExportDoneView {
|
pub(super) fn done_view(report: &ExportReport) -> ExportDoneView {
|
||||||
ExportDoneView {
|
ExportDoneView {
|
||||||
@@ -296,6 +317,8 @@ fn failed_report_with(target: PathBuf, pack: &Path, error: ImportErrorView) -> I
|
|||||||
registered: None,
|
registered: None,
|
||||||
bytes_written: 0,
|
bytes_written: 0,
|
||||||
rolled_back: true,
|
rolled_back: true,
|
||||||
|
// 预检/装配阶段就失败了:目标目录一个字节都没写(不是「写完又回滚」)。
|
||||||
|
failure_stage: Some(ImportFailureStageView::NotStarted),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
//!
|
//!
|
||||||
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。
|
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。
|
||||||
|
|
||||||
use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LogOutcome, PackEvent};
|
use crate::dto::{
|
||||||
|
AppEvent, CoreEvent, InstanceRuntime, LaunchFailureStage, LaunchFailureView, LogOutcome,
|
||||||
|
PackEvent,
|
||||||
|
};
|
||||||
use crate::toast::ToastKind;
|
use crate::toast::ToastKind;
|
||||||
|
|
||||||
use super::{addon, logs, CoreHost, HostTask, LiveLaunch};
|
use super::{addon, logs, CoreHost, HostTask, LiveLaunch};
|
||||||
@@ -160,12 +163,19 @@ impl CoreHost {
|
|||||||
self.failed.remove(&finished.id);
|
self.failed.remove(&finished.id);
|
||||||
InstanceRuntime::Idle
|
InstanceRuntime::Idle
|
||||||
} else {
|
} else {
|
||||||
// 记住失败原因:随后的 refresh_instances 会重建快照,
|
// 记住失败(阶段 + 原文):随后的 refresh_instances 会重建快照,
|
||||||
// 不记的话磁贴的「失败/重试」外观会被 Idle 盖掉。
|
// 不记的话磁贴的「失败/重试」外观会被 Idle 盖掉。
|
||||||
self.failed
|
let failure = finished.failure.clone().unwrap_or_else(|| {
|
||||||
.insert(finished.id.clone(), finished.summary.clone());
|
// 跑起来再崩:没有「装配阶段」可言,用结局本身当阶段外的说明。
|
||||||
|
LaunchFailureView::new(
|
||||||
|
LaunchFailureStage::Spawn,
|
||||||
|
format!("exit {:?}", finished.exit_code),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
self.failed.insert(finished.id.clone(), failure.clone());
|
||||||
InstanceRuntime::Failed {
|
InstanceRuntime::Failed {
|
||||||
reason: finished.summary.clone(),
|
stage: failure.stage,
|
||||||
|
detail: failure.detail,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.send(CoreEvent::InstanceRuntimeChanged {
|
self.send(CoreEvent::InstanceRuntimeChanged {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use unlucky_core::download::blocking::block_on;
|
|||||||
use unlucky_core::game::ProfileId;
|
use unlucky_core::game::ProfileId;
|
||||||
use unlucky_core::launch::request::AuthPlaceholders;
|
use unlucky_core::launch::request::AuthPlaceholders;
|
||||||
|
|
||||||
use crate::dto::LogOutcome;
|
use crate::dto::{LaunchFailureStage, LaunchFailureView, LogOutcome};
|
||||||
|
|
||||||
use super::env::HostEnv;
|
use super::env::HostEnv;
|
||||||
|
|
||||||
@@ -22,13 +22,16 @@ use super::env::HostEnv;
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// 账户不存在/凭据类型不符/令牌过期且刷新失败时返回用户可读文案。
|
/// 账户不存在/凭据类型不符/令牌过期且刷新失败时返回用户可读文案。
|
||||||
pub fn resolve(env: &HostEnv, account: Option<&str>) -> Result<(AuthPlaceholders, String), String> {
|
pub fn resolve(
|
||||||
|
env: &HostEnv,
|
||||||
|
account: Option<&str>,
|
||||||
|
) -> Result<(AuthPlaceholders, String), LaunchFailureView> {
|
||||||
let store = AccountStore::open(env.layout.auth_data());
|
let store = AccountStore::open(env.layout.auth_data());
|
||||||
let chosen = match account {
|
let chosen = match account {
|
||||||
Some(key) => store
|
Some(key) => store
|
||||||
.find(key)
|
.find(key)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| format!("找不到账户 `{key}`"))?,
|
.ok_or_else(|| identity_failure(format!("找不到账户 `{key}`")))?,
|
||||||
None => match store.accounts() {
|
None => match store.accounts() {
|
||||||
[] => {
|
[] => {
|
||||||
let uuid = ProfileId::generate().as_str().replace('-', "");
|
let uuid = ProfileId::generate().as_str().replace('-', "");
|
||||||
@@ -44,27 +47,32 @@ pub fn resolve(env: &HostEnv, account: Option<&str>) -> Result<(AuthPlaceholders
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
[only] => only.clone(),
|
[only] => only.clone(),
|
||||||
many => return Err(format!("有 {} 个账户,请在账户页选一个再启动", many.len())),
|
many => {
|
||||||
|
return Err(identity_failure(format!(
|
||||||
|
"有 {} 个账户,请在账户页选一个再启动",
|
||||||
|
many.len()
|
||||||
|
)))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let line = format!("账户 {}({})", chosen.display_name(), chosen.kind.label());
|
let line = format!("账户 {}({})", chosen.display_name(), chosen.kind.label());
|
||||||
match chosen.kind {
|
match chosen.kind {
|
||||||
AccountKind::Offline => {
|
AccountKind::Offline => {
|
||||||
let placeholders =
|
let placeholders = offline_login(&chosen)
|
||||||
offline_login(&chosen).map_err(|error| format!("离线账户解析失败:{error}"))?;
|
.map_err(|error| identity_failure(format!("离线账户解析失败:{error}")))?;
|
||||||
Ok((placeholders, line))
|
Ok((placeholders, line))
|
||||||
}
|
}
|
||||||
AccountKind::Microsoft => {
|
AccountKind::Microsoft => {
|
||||||
let Some(Credentials::Microsoft(session)) = store.credentials(&chosen.id) else {
|
let Some(Credentials::Microsoft(session)) = store.credentials(&chosen.id) else {
|
||||||
return Err(format!(
|
return Err(identity_failure(format!(
|
||||||
"账户 {} 的微软凭据缺失或类型不符,请重新登录",
|
"账户 {} 的微软凭据缺失或类型不符,请重新登录",
|
||||||
chosen.display_name()
|
chosen.display_name()
|
||||||
));
|
)));
|
||||||
};
|
};
|
||||||
let auth =
|
let auth = MicrosoftAuth::from_env()
|
||||||
MicrosoftAuth::from_env().map_err(|error| format!("微软登录不可用:{error}"))?;
|
.map_err(|error| identity_failure(format!("微软登录不可用:{error}")))?;
|
||||||
let (fresh, _) = block_on(auth.ensure_fresh(session))
|
let (fresh, _) = block_on(auth.ensure_fresh(session))
|
||||||
.map_err(|error| format!("微软令牌刷新失败:{error}"))?;
|
.map_err(|error| identity_failure(format!("微软令牌刷新失败:{error}")))?;
|
||||||
if &fresh != session {
|
if &fresh != session {
|
||||||
// refresh_token 已轮换:立刻回写,旧值作废。
|
// refresh_token 已轮换:立刻回写,旧值作废。
|
||||||
let mut store = AccountStore::open(env.layout.auth_data());
|
let mut store = AccountStore::open(env.layout.auth_data());
|
||||||
@@ -73,24 +81,29 @@ pub fn resolve(env: &HostEnv, account: Option<&str>) -> Result<(AuthPlaceholders
|
|||||||
}
|
}
|
||||||
let placeholders = fresh
|
let placeholders = fresh
|
||||||
.to_placeholders()
|
.to_placeholders()
|
||||||
.map_err(|error| format!("微软身份转换失败:{error}"))?;
|
.map_err(|error| identity_failure(format!("微软身份转换失败:{error}")))?;
|
||||||
Ok((placeholders, line))
|
Ok((placeholders, line))
|
||||||
}
|
}
|
||||||
AccountKind::AuthlibInjector => {
|
AccountKind::AuthlibInjector => {
|
||||||
let Some(Credentials::AuthlibInjector(session)) = store.credentials(&chosen.id) else {
|
let Some(Credentials::AuthlibInjector(session)) = store.credentials(&chosen.id) else {
|
||||||
return Err(format!(
|
return Err(identity_failure(format!(
|
||||||
"账户 {} 的第三方凭据缺失或类型不符",
|
"账户 {} 的第三方凭据缺失或类型不符",
|
||||||
chosen.display_name()
|
chosen.display_name()
|
||||||
));
|
)));
|
||||||
};
|
};
|
||||||
let placeholders = session
|
let placeholders = session
|
||||||
.to_placeholders()
|
.to_placeholders()
|
||||||
.map_err(|error| format!("第三方身份转换失败:{error}"))?;
|
.map_err(|error| identity_failure(format!("第三方身份转换失败:{error}")))?;
|
||||||
Ok((placeholders, line))
|
Ok((placeholders, line))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 账户/身份这一步的失败(阶段固定 `Identity`;`detail` 保留原始文案与本机错误)。
|
||||||
|
fn identity_failure(detail: String) -> LaunchFailureView {
|
||||||
|
LaunchFailureView::new(LaunchFailureStage::Identity, detail)
|
||||||
|
}
|
||||||
|
|
||||||
/// `SessionHandle` → 日志页的三种结局。
|
/// `SessionHandle` → 日志页的三种结局。
|
||||||
pub fn outcome_of(handle: &unlucky_core::launch::SessionHandle) -> LogOutcome {
|
pub fn outcome_of(handle: &unlucky_core::launch::SessionHandle) -> LogOutcome {
|
||||||
if handle.exit.silent_failure.is_some() {
|
if handle.exit.silent_failure.is_some() {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ use crate::dto::{
|
|||||||
LiveLogBatch,
|
LiveLogBatch,
|
||||||
LoaderKind,
|
LoaderKind,
|
||||||
LogLine,
|
LogLine,
|
||||||
LogOutcome,
|
|
||||||
LogSession,
|
LogSession,
|
||||||
NoticeView,
|
NoticeView,
|
||||||
PackEvent,
|
PackEvent,
|
||||||
@@ -155,18 +154,6 @@ pub fn spawn(name: &str, job: impl FnOnce() + Send + 'static) -> std::thread::Jo
|
|||||||
.unwrap_or_else(|error| panic!("起不来后台线程 {name}: {error}"))
|
.unwrap_or_else(|error| panic!("起不来后台线程 {name}: {error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 人读一行「启动结束」。
|
|
||||||
pub fn launch_summary(outcome: LogOutcome, exit_code: Option<i32>) -> String {
|
|
||||||
match outcome {
|
|
||||||
LogOutcome::Ok => "正常退出".to_string(),
|
|
||||||
LogOutcome::Crashed => match exit_code {
|
|
||||||
Some(code) => format!("崩溃(exit {code})"),
|
|
||||||
None => "崩溃".to_string(),
|
|
||||||
},
|
|
||||||
LogOutcome::SilentFail => "静默失败(exit 0 但游戏没起来)".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -187,14 +174,4 @@ mod tests {
|
|||||||
let task = rx.recv().expect("收到");
|
let task = rx.recv().expect("收到");
|
||||||
assert!(matches!(task, HostTask::Toast(toast) if toast.text == "hi"));
|
assert!(matches!(task, HostTask::Toast(toast) if toast.text == "hi"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn launch_summary_names_the_outcome() {
|
|
||||||
assert_eq!(launch_summary(LogOutcome::Ok, Some(0)), "正常退出");
|
|
||||||
assert_eq!(
|
|
||||||
launch_summary(LogOutcome::Crashed, Some(1)),
|
|
||||||
"崩溃(exit 1)"
|
|
||||||
);
|
|
||||||
assert!(launch_summary(LogOutcome::SilentFail, Some(0)).contains("静默"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -17,16 +17,22 @@ use unlucky_core::game::{self, VersionJson};
|
|||||||
use unlucky_core::launch::adapter::{
|
use unlucky_core::launch::adapter::{
|
||||||
self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput,
|
self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput,
|
||||||
};
|
};
|
||||||
use unlucky_core::launch::request::{JavaSelection, LaunchRequest, MemoryOptions};
|
use unlucky_core::launch::request::{LaunchRequest, MemoryOptions};
|
||||||
use unlucky_core::launch::time::{format_rfc3339_millis, now};
|
use unlucky_core::launch::time::{format_rfc3339_millis, now};
|
||||||
use unlucky_core::launch::tree_kill;
|
use unlucky_core::launch::tree_kill;
|
||||||
use unlucky_core::launch::{Launcher, LauncherConfig};
|
use unlucky_core::launch::{Launcher, LauncherConfig};
|
||||||
use unlucky_core::platform::paths::ProcessEnv;
|
|
||||||
|
|
||||||
use crate::dto::{InstanceId, InstanceRuntime, LaunchFinished, LaunchStage, LogOutcome, Progress};
|
use crate::dto::{
|
||||||
|
InstanceId, InstanceRuntime, LaunchFailureStage, LaunchFailureView, LaunchFinished,
|
||||||
|
LaunchStage, LogOutcome, Progress,
|
||||||
|
};
|
||||||
|
|
||||||
use super::env::HostEnv;
|
use super::env::HostEnv;
|
||||||
use super::job::{launch_summary, HostTask, JobSender};
|
|
||||||
|
mod java;
|
||||||
|
|
||||||
|
use super::job::{HostTask, JobSender};
|
||||||
|
pub use java::pick_java;
|
||||||
|
|
||||||
/// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。
|
/// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。
|
||||||
pub const LAUNCHER_NAME: &str = "Unlucky Launcher";
|
pub const LAUNCHER_NAME: &str = "Unlucky Launcher";
|
||||||
@@ -44,104 +50,38 @@ pub struct LaunchTarget {
|
|||||||
pub account: Option<String>,
|
pub account: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 选中并用起来的 Java(`summary` 给 UI 一行提示)。
|
|
||||||
pub struct JavaPick {
|
|
||||||
/// 交给 adapter 的选择
|
|
||||||
pub selection: JavaSelection,
|
|
||||||
/// 人读一行
|
|
||||||
pub summary: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 选一个能用的 Java:显式路径(实例 → 全局 `java.selected_path`)> 托管 > JAVA_HOME > PATH。
|
|
||||||
///
|
|
||||||
/// 显式路径**先探测**(`java -version`):探测失败不报错,按版本要求的基准主版本兜底——
|
|
||||||
/// 与自动挑的退路同一口径(启动路径不该因为探测不到就拒绝启动)。
|
|
||||||
pub fn pick_java(env: &HostEnv, version_id: &str, explicit: Option<&std::path::Path>) -> JavaPick {
|
|
||||||
let store = env.store();
|
|
||||||
let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id);
|
|
||||||
if let Some(path) = explicit {
|
|
||||||
let runtime = unlucky_core::game::java::probe::probe(
|
|
||||||
path,
|
|
||||||
unlucky_core::game::java::probe::JavaOrigin::Instance,
|
|
||||||
);
|
|
||||||
let major = runtime.major.unwrap_or(required.minimum);
|
|
||||||
return JavaPick {
|
|
||||||
selection: JavaSelection::new(runtime.binary.clone(), major, runtime.bits),
|
|
||||||
summary: format!(
|
|
||||||
"实例指定 Java {}({})",
|
|
||||||
runtime.version.clone().unwrap_or_else(|| major.to_string()),
|
|
||||||
path.display()
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let install_root = store
|
|
||||||
.doc()
|
|
||||||
.effective_str("java.installRoot")
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|text| !text.is_empty())
|
|
||||||
.map(PathBuf::from);
|
|
||||||
let roots = unlucky_core::game::java::location::scan_roots(
|
|
||||||
env.mode(),
|
|
||||||
&env.layout,
|
|
||||||
install_root.as_deref(),
|
|
||||||
env.platform,
|
|
||||||
&ProcessEnv,
|
|
||||||
);
|
|
||||||
let candidates = unlucky_core::game::java::candidates_in_roots(&roots);
|
|
||||||
let picked = unlucky_core::game::java::pick(&candidates, &required).cloned();
|
|
||||||
match picked {
|
|
||||||
Some(runtime) => {
|
|
||||||
let major = runtime
|
|
||||||
.major
|
|
||||||
.unwrap_or(unlucky_core::game::java::DEFAULT_MAJOR);
|
|
||||||
let summary = format!(
|
|
||||||
"Java {}({}{})",
|
|
||||||
runtime.version.clone().unwrap_or_else(|| major.to_string()),
|
|
||||||
runtime.origin.category(),
|
|
||||||
runtime
|
|
||||||
.vendor
|
|
||||||
.as_ref()
|
|
||||||
.map(|vendor| format!(" · {vendor}"))
|
|
||||||
.unwrap_or_default()
|
|
||||||
);
|
|
||||||
JavaPick {
|
|
||||||
selection: JavaSelection::new(runtime.binary, major, runtime.bits),
|
|
||||||
summary,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let binary = PathBuf::from(unlucky_core::game::java::java_binary_name());
|
|
||||||
let major = required.minimum;
|
|
||||||
JavaPick {
|
|
||||||
selection: JavaSelection::new(binary, major, 64),
|
|
||||||
summary: format!("PATH 上的 Java(未探测到版本,按 Java {major} 启动)"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 装配 [`LaunchRequest`](与 CLI `snapshot::prepare` 同序)。
|
/// 装配 [`LaunchRequest`](与 CLI `snapshot::prepare` 同序)。
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// 版本 JSON 缺失/非法、继承链断裂、隔离派生失败时返回用户可读文案。
|
/// 任一步失败都返回 [`LaunchFailureView`]:**阶段**(版本 JSON / 继承链 / 实例设置 / 隔离目录 /
|
||||||
|
/// 账户)+ 那一步的原文。文案不在这一层拼(presenter 不依赖 `unlucky-i18n`)。
|
||||||
pub fn build_request(
|
pub fn build_request(
|
||||||
env: &HostEnv,
|
env: &HostEnv,
|
||||||
target: &LaunchTarget,
|
target: &LaunchTarget,
|
||||||
) -> Result<(LaunchRequest, Vec<String>), String> {
|
) -> Result<(LaunchRequest, Vec<String>), LaunchFailureView> {
|
||||||
let version_id = target.version_id.clone();
|
let version_id = target.version_id.clone();
|
||||||
let manifest_path = env.game.version_json(&version_id);
|
let manifest_path = env.game.version_json(&version_id);
|
||||||
let text = std::fs::read_to_string(&manifest_path)
|
let text = std::fs::read_to_string(&manifest_path).map_err(|error| {
|
||||||
.map_err(|error| format!("读版本 JSON 失败 {}:{error}", manifest_path.display()))?;
|
LaunchFailureView::new(
|
||||||
let manifest =
|
LaunchFailureStage::VersionManifest,
|
||||||
VersionJson::from_json_str(&text).map_err(|error| format!("版本 JSON 非法:{error}"))?;
|
format!("{}:{error}", manifest_path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let manifest = VersionJson::from_json_str(&text).map_err(|error| {
|
||||||
|
LaunchFailureView::new(
|
||||||
|
LaunchFailureStage::VersionManifest,
|
||||||
|
format!("{}:{error}", manifest_path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
let source = |id: &str| -> Option<VersionJson> {
|
let source = |id: &str| -> Option<VersionJson> {
|
||||||
std::fs::read_to_string(env.game.version_json(id))
|
std::fs::read_to_string(env.game.version_json(id))
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|text| VersionJson::from_json_str(&text).ok())
|
.and_then(|text| VersionJson::from_json_str(&text).ok())
|
||||||
};
|
};
|
||||||
let resolved =
|
let resolved = game::resolve(&source, &manifest).map_err(|error| {
|
||||||
game::resolve(&source, &manifest).map_err(|error| format!("解析继承链失败:{error}"))?;
|
LaunchFailureView::new(LaunchFailureStage::Inheritance, error.to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
let environment = Environment::host();
|
let environment = Environment::host();
|
||||||
let expanded = resolved
|
let expanded = resolved
|
||||||
@@ -252,12 +192,15 @@ pub fn build_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 派生运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
|
/// 派生运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
|
||||||
fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result<PathBuf, String> {
|
fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result<PathBuf, LaunchFailureView> {
|
||||||
let global = super::game::global_mode(env);
|
let global = super::game::global_mode(env);
|
||||||
let config = read_instance_config(&env.layout, &target.id)
|
let config = read_instance_config(&env.layout, &target.id).map_err(|error| {
|
||||||
.map_err(|error| format!("读实例设置失败:{error}"))?;
|
LaunchFailureView::new(LaunchFailureStage::InstanceConfig, error.to_string())
|
||||||
|
})?;
|
||||||
let derivation = derive_game_dir(&env.game, &target.version_id, global, config.as_ref(), None)
|
let derivation = derive_game_dir(&env.game, &target.version_id, global, config.as_ref(), None)
|
||||||
.map_err(|error| format!("隔离目录派生失败:{error}"))?;
|
.map_err(|error| {
|
||||||
|
LaunchFailureView::new(LaunchFailureStage::Isolation, error.to_string())
|
||||||
|
})?;
|
||||||
if let Some(mut config) = config {
|
if let Some(mut config) = config {
|
||||||
let at = format_rfc3339_millis(now());
|
let at = format_rfc3339_millis(now());
|
||||||
if observe_mode(&mut config, &env.game, &at, derivation.isolation).unwrap_or(false) {
|
if observe_mode(&mut config, &env.game, &at, derivation.isolation).unwrap_or(false) {
|
||||||
@@ -282,12 +225,14 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
|||||||
});
|
});
|
||||||
let (request, warnings) = match build_request(&env, &target) {
|
let (request, warnings) = match build_request(&env, &target) {
|
||||||
Ok(ok) => ok,
|
Ok(ok) => ok,
|
||||||
Err(reason) => {
|
Err(failure) => {
|
||||||
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||||
id,
|
id,
|
||||||
session: String::new(),
|
session: String::new(),
|
||||||
outcome: LogOutcome::Crashed,
|
outcome: LogOutcome::Crashed,
|
||||||
summary: format!("启动失败:{reason}"),
|
exit_code: None,
|
||||||
|
uptime_ms: 0,
|
||||||
|
failure: Some(failure),
|
||||||
})));
|
})));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -309,7 +254,12 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
|||||||
id,
|
id,
|
||||||
session: String::new(),
|
session: String::new(),
|
||||||
outcome: LogOutcome::Crashed,
|
outcome: LogOutcome::Crashed,
|
||||||
summary: format!("启动失败:{error}"),
|
exit_code: None,
|
||||||
|
uptime_ms: 0,
|
||||||
|
failure: Some(LaunchFailureView::new(
|
||||||
|
LaunchFailureStage::Spawn,
|
||||||
|
error.to_string(),
|
||||||
|
)),
|
||||||
})));
|
})));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -332,12 +282,10 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
|||||||
id,
|
id,
|
||||||
session: session_id,
|
session: session_id,
|
||||||
outcome,
|
outcome,
|
||||||
summary: format!(
|
exit_code: Some(handle.exit.code),
|
||||||
"{}({},{:.1}s)",
|
uptime_ms: handle.uptime_ms,
|
||||||
launch_summary(outcome, Some(handle.exit.code)),
|
// 跑起来再崩不算「装配失败」:结局由 `outcome` 表达,这里留 `None`。
|
||||||
handle.exit.exit_type.as_str(),
|
failure: None,
|
||||||
handle.uptime_ms as f64 / 1000.0
|
|
||||||
),
|
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -345,7 +293,12 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
|||||||
id,
|
id,
|
||||||
session: session_id,
|
session: session_id,
|
||||||
outcome: LogOutcome::SilentFail,
|
outcome: LogOutcome::SilentFail,
|
||||||
summary: format!("启动收尾失败:{error}"),
|
exit_code: None,
|
||||||
|
uptime_ms: 0,
|
||||||
|
failure: Some(LaunchFailureView::new(
|
||||||
|
LaunchFailureStage::Spawn,
|
||||||
|
error.to_string(),
|
||||||
|
)),
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! 「用哪个 Java」:显式路径(实例 → 全局)> 托管 > JAVA_HOME > PATH(M161 从 `host/launch.rs` 拆出)。
|
||||||
|
//!
|
||||||
|
//! 拆出的理由与其它 400 行拆分一致:`host/launch.rs` 到线了,而「挑 Java」本来就是独立一段。
|
||||||
|
//! 口径没变(见原文件与 `core::game::java` 的文档):显式路径**先探测**,探测失败按版本要求的
|
||||||
|
//! 基准主版本兜底——启动路径不该因为探测不到就拒绝启动。
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use unlucky_core::launch::request::JavaSelection;
|
||||||
|
use unlucky_core::platform::paths::ProcessEnv;
|
||||||
|
|
||||||
|
use super::super::env::HostEnv;
|
||||||
|
|
||||||
|
/// 选中并用起来的 Java(`summary` 给 UI 一行提示)。
|
||||||
|
pub struct JavaPick {
|
||||||
|
/// 交给 adapter 的选择
|
||||||
|
pub selection: JavaSelection,
|
||||||
|
/// 人读一行
|
||||||
|
pub summary: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选一个能用的 Java:显式路径(实例 → 全局 `java.selected_path`)> 托管 > JAVA_HOME > PATH。
|
||||||
|
///
|
||||||
|
/// 显式路径**先探测**(`java -version`):探测失败不报错,按版本要求的基准主版本兜底——
|
||||||
|
/// 与自动挑的退路同一口径(启动路径不该因为探测不到就拒绝启动)。
|
||||||
|
pub fn pick_java(env: &HostEnv, version_id: &str, explicit: Option<&std::path::Path>) -> JavaPick {
|
||||||
|
let store = env.store();
|
||||||
|
let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id);
|
||||||
|
if let Some(path) = explicit {
|
||||||
|
let runtime = unlucky_core::game::java::probe::probe(
|
||||||
|
path,
|
||||||
|
unlucky_core::game::java::probe::JavaOrigin::Instance,
|
||||||
|
);
|
||||||
|
let major = runtime.major.unwrap_or(required.minimum);
|
||||||
|
return JavaPick {
|
||||||
|
selection: JavaSelection::new(runtime.binary.clone(), major, runtime.bits),
|
||||||
|
summary: format!(
|
||||||
|
"实例指定 Java {}({})",
|
||||||
|
runtime.version.clone().unwrap_or_else(|| major.to_string()),
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let install_root = store
|
||||||
|
.doc()
|
||||||
|
.effective_str("java.installRoot")
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.map(PathBuf::from);
|
||||||
|
let roots = unlucky_core::game::java::location::scan_roots(
|
||||||
|
env.mode(),
|
||||||
|
&env.layout,
|
||||||
|
install_root.as_deref(),
|
||||||
|
env.platform,
|
||||||
|
&ProcessEnv,
|
||||||
|
);
|
||||||
|
let candidates = unlucky_core::game::java::candidates_in_roots(&roots);
|
||||||
|
let picked = unlucky_core::game::java::pick(&candidates, &required).cloned();
|
||||||
|
match picked {
|
||||||
|
Some(runtime) => {
|
||||||
|
let major = runtime
|
||||||
|
.major
|
||||||
|
.unwrap_or(unlucky_core::game::java::DEFAULT_MAJOR);
|
||||||
|
let summary = format!(
|
||||||
|
"Java {}({}{})",
|
||||||
|
runtime.version.clone().unwrap_or_else(|| major.to_string()),
|
||||||
|
runtime.origin.category(),
|
||||||
|
runtime
|
||||||
|
.vendor
|
||||||
|
.as_ref()
|
||||||
|
.map(|vendor| format!(" · {vendor}"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
);
|
||||||
|
JavaPick {
|
||||||
|
selection: JavaSelection::new(runtime.binary, major, runtime.bits),
|
||||||
|
summary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let binary = PathBuf::from(unlucky_core::game::java::java_binary_name());
|
||||||
|
let major = required.minimum;
|
||||||
|
JavaPick {
|
||||||
|
selection: JavaSelection::new(binary, major, 64),
|
||||||
|
summary: format!("PATH 上的 Java(未探测到版本,按 Java {major} 启动)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,7 +145,9 @@ fn launch_started_registers_and_launch_end_clears() {
|
|||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
session: "20261031T120000-abcd12".to_string(),
|
session: "20261031T120000-abcd12".to_string(),
|
||||||
outcome: LogOutcome::Ok,
|
outcome: LogOutcome::Ok,
|
||||||
summary: "正常退出".to_string(),
|
exit_code: Some(0),
|
||||||
|
uptime_ms: 12_500,
|
||||||
|
failure: None,
|
||||||
})));
|
})));
|
||||||
assert!(host.launches.is_empty(), "收尾后启动表要清空");
|
assert!(host.launches.is_empty(), "收尾后启动表要清空");
|
||||||
assert!(!host.failed.contains_key(&id));
|
assert!(!host.failed.contains_key(&id));
|
||||||
@@ -197,7 +199,9 @@ fn refresh_keeps_pending_as_busy_and_failed_as_failed() {
|
|||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
session: "20261031T120000-dead01".to_string(),
|
session: "20261031T120000-dead01".to_string(),
|
||||||
outcome: LogOutcome::Crashed,
|
outcome: LogOutcome::Crashed,
|
||||||
summary: "崩溃(exit 1)".to_string(),
|
exit_code: Some(1),
|
||||||
|
uptime_ms: 3_000,
|
||||||
|
failure: None,
|
||||||
})));
|
})));
|
||||||
assert!(!host.pending.contains(&id), "LaunchEnd 必须清 pending");
|
assert!(!host.pending.contains(&id), "LaunchEnd 必须清 pending");
|
||||||
assert!(!host.in_flight(&id), "收尾后该实例要能被重新启动");
|
assert!(!host.in_flight(&id), "收尾后该实例要能被重新启动");
|
||||||
@@ -212,7 +216,8 @@ fn refresh_keeps_pending_as_busy_and_failed_as_failed() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
failed.runtime,
|
failed.runtime,
|
||||||
InstanceRuntime::Failed {
|
InstanceRuntime::Failed {
|
||||||
reason: "崩溃(exit 1)".to_string()
|
stage: crate::dto::LaunchFailureStage::Spawn,
|
||||||
|
detail: "exit Some(1)".to_string()
|
||||||
},
|
},
|
||||||
"失败原因要经得住一次刷新(否则磁贴的「重试」外观会被 Idle 盖掉)"
|
"失败原因要经得住一次刷新(否则磁贴的「重试」外观会被 Idle 盖掉)"
|
||||||
);
|
);
|
||||||
@@ -229,7 +234,12 @@ fn launch_end_before_launch_started_still_releases_the_instance() {
|
|||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
session: String::new(),
|
session: String::new(),
|
||||||
outcome: LogOutcome::Crashed,
|
outcome: LogOutcome::Crashed,
|
||||||
summary: "启动失败:找不到 Java".to_string(),
|
exit_code: None,
|
||||||
|
uptime_ms: 0,
|
||||||
|
failure: Some(crate::dto::LaunchFailureView::new(
|
||||||
|
crate::dto::LaunchFailureStage::Identity,
|
||||||
|
"找不到账户 `steve`",
|
||||||
|
)),
|
||||||
})));
|
})));
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
@@ -248,7 +258,8 @@ fn launch_end_before_launch_started_still_releases_the_instance() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
failed.runtime,
|
failed.runtime,
|
||||||
InstanceRuntime::Failed {
|
InstanceRuntime::Failed {
|
||||||
reason: "启动失败:找不到 Java".to_string()
|
stage: crate::dto::LaunchFailureStage::Identity,
|
||||||
|
detail: "找不到账户 `steve`".to_string()
|
||||||
},
|
},
|
||||||
"早失败也要在磁贴上留下「失败/重试」,不能被 Idle 盖掉"
|
"早失败也要在磁贴上留下「失败/重试」,不能被 Idle 盖掉"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use crate::dto::{
|
use crate::dto::{
|
||||||
AccountView, AppCommand, AppEvent, Backend, CoreEvent, DiagnosisReport, InstallProgressView,
|
AccountView, AppCommand, AppEvent, Backend, CoreEvent, DiagnosisReport, InstallProgressView,
|
||||||
InstanceRuntime, JavaChainStep, NoticeView, SkinImage,
|
InstanceRuntime, JavaChainStep, LaunchFailureView, NoticeView, SkinImage,
|
||||||
};
|
};
|
||||||
use crate::mock::MockBackend;
|
use crate::mock::MockBackend;
|
||||||
use crate::toast::{Toast, ToastKind};
|
use crate::toast::{Toast, ToastKind};
|
||||||
@@ -92,7 +92,8 @@ pub struct CoreHost {
|
|||||||
/// 已受理但后台线程还没回报 `LaunchStarted` 的实例(防「启动中重复点启动」起第二个进程)
|
/// 已受理但后台线程还没回报 `LaunchStarted` 的实例(防「启动中重复点启动」起第二个进程)
|
||||||
pending: BTreeSet<String>,
|
pending: BTreeSet<String>,
|
||||||
/// 上次启动失败的实例与原因(刷新快照时保住磁贴的「失败/重试」外观)
|
/// 上次启动失败的实例与原因(刷新快照时保住磁贴的「失败/重试」外观)
|
||||||
failed: BTreeMap<String, String>,
|
/// 上次启动失败(阶段 + 原文;磁贴的「失败/重试」外观靠它重建)
|
||||||
|
failed: BTreeMap<String, LaunchFailureView>,
|
||||||
/// 实时日志尾随器
|
/// 实时日志尾随器
|
||||||
tail: logs::Tail,
|
tail: logs::Tail,
|
||||||
/// AI 对话的临时后端(agent GUI host 落地前的占位)
|
/// AI 对话的临时后端(agent GUI host 落地前的占位)
|
||||||
@@ -228,9 +229,10 @@ impl CoreHost {
|
|||||||
stage: crate::dto::LaunchStage::Launch,
|
stage: crate::dto::LaunchStage::Launch,
|
||||||
progress: crate::dto::Progress { done: 0, total: 1 },
|
progress: crate::dto::Progress { done: 0, total: 1 },
|
||||||
};
|
};
|
||||||
} else if let Some(reason) = self.failed.get(&instance.id) {
|
} else if let Some(failure) = self.failed.get(&instance.id) {
|
||||||
instance.runtime = InstanceRuntime::Failed {
|
instance.runtime = InstanceRuntime::Failed {
|
||||||
reason: reason.clone(),
|
stage: failure.stage,
|
||||||
|
detail: failure.detail.clone(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
//! ```text
|
//! ```text
|
||||||
//! core::update::Job ← 检查/应用的做法(版本比较、渠道准入、双检、原子替换全在 core)
|
//! core::update::Job ← 检查/应用的做法(版本比较、渠道准入、双检、原子替换全在 core)
|
||||||
//! ▲
|
//! ▲
|
||||||
//! │ 本模块只做三件事
|
//! │ 本模块只做四件事
|
||||||
//! Factory ← 用哪个源、暂存放哪、替换谁(生产:GitHub + 本进程 exe)
|
//! Factory ← 用哪个源、暂存放哪、替换谁(生产:GitHub + 本进程 exe)
|
||||||
//! outcome_of ← core 的结论 → DTO(GUI 与 CLI 共用同一条判据)
|
//! outcome_of ← core 的结论 → DTO(GUI 与 CLI 共用同一条判据)
|
||||||
//! Prefs ← 更新域自己的开关(启动时静默检查)
|
//! Paths / Migration ← 两个开关的落点,以及旧 `cache/update/prefs.json` 的迁移(M161)
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! 「GUI 与 CLI 行为一致」由结构保证:两边都只构造 `core::update::Job` 并调用 `check` / `apply`,
|
//! 「GUI 与 CLI 行为一致」由结构保证:两边都只构造 `core::update::Job` 并调用 `check` / `apply`,
|
||||||
@@ -16,83 +16,73 @@
|
|||||||
//!
|
//!
|
||||||
//! # 静默检查的边界
|
//! # 静默检查的边界
|
||||||
//!
|
//!
|
||||||
//! 启动时的检查能被关掉([`Prefs::auto_check`]):关掉之后 host 的装配路径**根本不派发**
|
//! 启动时的检查能被关掉(`update.auto_check`):关掉之后 host 的装配路径**根本不派发**
|
||||||
//! (判据在 `CoreHost::refresh_all` 的那一行 `if self.update.should_check_on_start()`)。
|
//! (判据在 `CoreHost::refresh_all` 的那一行 `if self.update.should_check_on_start()`)。
|
||||||
//!
|
//!
|
||||||
//! # 偏好文件为什么不在 `launcher.json` 里
|
//! # 两个开关住在主配置里(M161)
|
||||||
//!
|
//!
|
||||||
//! `launcher.json` 的每个键都归 `core::setting::keys` 的 schema 管,而本 mission 的 scope 不含
|
//! `update.auto_check` / `update.channel` 是 `crates/core/src/setting/keys.rs` 的 schema 键,
|
||||||
//! `crates/core/src/setting/**`。所以这一条先落在更新域自己的文件
|
//! 落在 `<数据根>/config/launcher.json`——与「所有配置走 `core::setting`」的约定一致,
|
||||||
//! `<数据根>/cache/update/prefs.json`(与 `cache/region.json` 同族:域内状态,坏了就回默认)。
|
//! 于是 `ull config get update.auto_check` 读到的是**同一个值**。
|
||||||
//! **加 `update.auto_check` schema 键之后应当迁过去**(已作为 finding 上报)。
|
//!
|
||||||
|
//! M159 曾把这两项临时放进更新域自己的文件 `<数据根>/cache/update/prefs.json`(当时
|
||||||
|
//! `crates/core/src/setting/**` 不在那条 mission 的 scope),代价是「GUI 里关掉、CLI 仍报 true」。
|
||||||
|
//! 存量值的迁移在 [`unlucky_core::setting::update_prefs`]:幂等(源指纹闸门)、不丢 `false`、
|
||||||
|
//! 新值胜出、旧文件改名为 `prefs.json.migrated` 后不再有任何读路径。`Runtime::with_factory`
|
||||||
|
//! 在建会话时跑一次并记住结局([`Runtime::migration`],`null` = 没有遗留文件)。
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use crate::dto::{UpdateCandidateView, UpdateFailureView, UpdateOutcome};
|
||||||
use unlucky_core::download::DownloadEngine;
|
use unlucky_core::download::DownloadEngine;
|
||||||
use unlucky_core::launch::time::{format_rfc3339_millis, now};
|
use unlucky_core::launch::time::{format_rfc3339_millis, now};
|
||||||
|
use unlucky_core::setting::update_prefs;
|
||||||
use unlucky_core::update::{
|
use unlucky_core::update::{
|
||||||
default_source, staging_dir, Applied, Channel, CheckOutcome, DirSource, Job, Target,
|
default_source, staging_dir, Applied, CheckOutcome, DirSource, Job, Target, UpdateError,
|
||||||
UpdateError, UpdateSource, Version,
|
UpdateSource, Version,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::dto::{UpdateCandidateView, UpdateFailureView, UpdateOutcome};
|
|
||||||
|
|
||||||
use super::engine;
|
use super::engine;
|
||||||
use super::env::HostEnv;
|
use super::env::HostEnv;
|
||||||
use super::logs::display_time;
|
use super::logs::display_time;
|
||||||
|
|
||||||
/// 渠道(D50:先只发 stable)。
|
|
||||||
pub const CHANNEL: Channel = Channel::Stable;
|
|
||||||
|
|
||||||
/// 本进程版本。整个 workspace 同版本(根 `Cargo.toml` 的 `[workspace.package]`)。
|
/// 本进程版本。整个 workspace 同版本(根 `Cargo.toml` 的 `[workspace.package]`)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn current_version() -> &'static str {
|
pub fn current_version() -> &'static str {
|
||||||
env!("CARGO_PKG_VERSION")
|
env!("CARGO_PKG_VERSION")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新域偏好(现在只有一项)。
|
/// 更新域的三个落点(都由数据根派生,core 不猜布局)。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Prefs {
|
pub struct Paths {
|
||||||
/// 启动时静默检查。缺省开:检查本身不打扰用户,发现新版本才有提示条。
|
/// `<数据根>/config/launcher.json`(两个开关的真相源)
|
||||||
#[serde(default = "default_auto_check")]
|
pub launcher: PathBuf,
|
||||||
pub auto_check: bool,
|
/// `<数据根>/cache/update/prefs.json`(M159 的旧文件;迁移后改名 `.migrated`)
|
||||||
|
pub legacy: PathBuf,
|
||||||
|
/// `<数据根>/config/update-prefs-migration.json`(迁移收据)
|
||||||
|
pub receipt: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_auto_check() -> bool {
|
impl Paths {
|
||||||
true
|
/// 按数据根装配。
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Prefs {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self { auto_check: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Prefs {
|
|
||||||
/// 读偏好:文件不存在 / 坏 JSON 一律回默认(偏好坏了不该让启动器起不来)。
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn load(path: &Path) -> Self {
|
pub fn for_env(env: &HostEnv) -> Self {
|
||||||
std::fs::read_to_string(path)
|
Self {
|
||||||
.ok()
|
launcher: env.launcher_config(),
|
||||||
.and_then(|text| serde_json::from_str(&text).ok())
|
legacy: env.layout.cache().join("update").join("prefs.json"),
|
||||||
.unwrap_or_default()
|
receipt: env.layout.config().join(update_prefs::RECEIPT_FILE),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写偏好。
|
/// 迁移要的三个路径。
|
||||||
///
|
#[must_use]
|
||||||
/// # Errors
|
pub fn targets(&self) -> update_prefs::Targets<'_> {
|
||||||
///
|
update_prefs::Targets {
|
||||||
/// 目录建不出来或写盘失败时返回人读文案。
|
launcher: &self.launcher,
|
||||||
pub fn save(&self, path: &Path) -> Result<(), String> {
|
legacy: &self.legacy,
|
||||||
if let Some(parent) = path.parent() {
|
receipt: &self.receipt,
|
||||||
std::fs::create_dir_all(parent)
|
|
||||||
.map_err(|error| format!("建更新偏好目录失败:{error}"))?;
|
|
||||||
}
|
}
|
||||||
let text = serde_json::to_string_pretty(self)
|
|
||||||
.map_err(|error| format!("序列化更新偏好失败:{error}"))?;
|
|
||||||
std::fs::write(path, text).map_err(|error| format!("写更新偏好失败:{error}"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ fn sha256_mismatch_refuses_the_apply_and_leaves_no_temp_file() {
|
|||||||
std::fs::create_dir_all(dest.parent().expect("父目录")).expect("bin");
|
std::fs::create_dir_all(dest.parent().expect("父目录")).expect("bin");
|
||||||
std::fs::write(&dest, b"old-binary-v1").expect("旧版本");
|
std::fs::write(&dest, b"old-binary-v1").expect("旧版本");
|
||||||
|
|
||||||
let report = Report::applied(job(&tmp).apply(&version("0.2.2"), CHANNEL));
|
let report = Report::applied(job(&tmp).apply(&version("0.2.2"), Channel::Stable));
|
||||||
match report {
|
match report {
|
||||||
Report::Applied(ApplyReport::Failed(failure)) => {
|
Report::Applied(ApplyReport::Failed(failure)) => {
|
||||||
assert_eq!(failure.kind, "digest_mismatch", "失败分类要能区分摘要不符");
|
assert_eq!(failure.kind, "digest_mismatch", "失败分类要能区分摘要不符");
|
||||||
@@ -67,7 +67,7 @@ fn a_successful_apply_leaves_the_new_file_and_a_backup() {
|
|||||||
std::fs::write(&dest, b"old-binary-v1").expect("旧版本");
|
std::fs::write(&dest, b"old-binary-v1").expect("旧版本");
|
||||||
|
|
||||||
let applied = job(&tmp)
|
let applied = job(&tmp)
|
||||||
.apply(&version("0.2.2"), CHANNEL)
|
.apply(&version("0.2.2"), Channel::Stable)
|
||||||
.expect("应用成功");
|
.expect("应用成功");
|
||||||
let Applied::Installed { receipt, version } = applied else {
|
let Applied::Installed { receipt, version } = applied else {
|
||||||
panic!("应当装上");
|
panic!("应当装上");
|
||||||
|
|||||||
@@ -81,21 +81,32 @@ fn dismissing_without_a_candidate_does_nothing() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 关掉「启动时检查」之后:状态上是 no-op、启动路径上根本不派发。
|
/// 关掉「启动时检查」之后:状态上是 no-op、启动路径上根本不派发。
|
||||||
|
///
|
||||||
|
/// 开关的落点是**主配置**(`update.auto_check`),这里顺带把 M161 的迁移走一遍:
|
||||||
|
/// 旧 `cache/update/prefs.json` 里的 `false` 必须是新会话看到的 `false`(不是默认的 `true`)。
|
||||||
#[test]
|
#[test]
|
||||||
fn auto_check_off_means_no_request_on_start() {
|
fn auto_check_off_means_no_request_on_start() {
|
||||||
let tmp = TempDir::new("autocheck");
|
let tmp = TempDir::new("autocheck");
|
||||||
let env = HostEnv::at_root(tmp.join("root"));
|
let env = HostEnv::at_root(tmp.join("root"));
|
||||||
let prefs_path = env.layout.cache().join("update").join("prefs.json");
|
let prefs_path = env.layout.cache().join("update").join("prefs.json");
|
||||||
Prefs { auto_check: false }
|
std::fs::create_dir_all(prefs_path.parent().expect("父目录")).expect("建 cache");
|
||||||
.save(&prefs_path)
|
std::fs::write(&prefs_path, r#"{"auto_check": false}"#).expect("写旧偏好");
|
||||||
.expect("写偏好");
|
|
||||||
|
|
||||||
let runtime = Runtime::with_factory(&env, factory(&tmp));
|
let runtime = Runtime::with_factory(&env, factory(&tmp));
|
||||||
assert!(!runtime.view().auto_check, "开关要读进快照");
|
assert!(!runtime.view().auto_check, "开关要读进快照");
|
||||||
|
assert!(!runtime.auto_check(), "关掉的值一路迁进主配置");
|
||||||
assert!(
|
assert!(
|
||||||
!runtime.should_check_on_start(),
|
!runtime.should_check_on_start(),
|
||||||
"关掉开关后启动不许派发检查"
|
"关掉开关后启动不许派发检查"
|
||||||
);
|
);
|
||||||
|
// 迁移确实发生了:旧文件退役 + 收据落盘(收据可查)。
|
||||||
|
assert!(!prefs_path.exists(), "旧文件必须退役");
|
||||||
|
let receipt = runtime
|
||||||
|
.migration()
|
||||||
|
.receipt()
|
||||||
|
.expect("收据路径")
|
||||||
|
.to_path_buf();
|
||||||
|
assert!(receipt.exists(), "收据要落盘:{}", receipt.display());
|
||||||
|
|
||||||
// 结构性判据:`refresh_all` 里那次静默检查必须挂在 `should_check_on_start` 上,
|
// 结构性判据:`refresh_all` 里那次静默检查必须挂在 `should_check_on_start` 上,
|
||||||
// 否则单测绿着而启动仍然联网(这条读源码,理由同 `instances/tests.rs` 的源码断言)。
|
// 否则单测绿着而启动仍然联网(这条读源码,理由同 `instances/tests.rs` 的源码断言)。
|
||||||
@@ -106,18 +117,30 @@ fn auto_check_off_means_no_request_on_start() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 偏好能落盘、能读回(换一份 Runtime 仍记得上次的选择)。
|
/// 开关能写进主配置、能读回(换一份 Runtime 仍记得上次的选择)。
|
||||||
#[test]
|
#[test]
|
||||||
fn the_auto_check_preference_survives_a_restart() {
|
fn the_auto_check_preference_survives_a_restart() {
|
||||||
let tmp = TempDir::new("prefs");
|
let tmp = TempDir::new("prefs");
|
||||||
let env = HostEnv::at_root(tmp.join("root"));
|
let env = HostEnv::at_root(tmp.join("root"));
|
||||||
let mut runtime = Runtime::with_factory(&env, factory(&tmp));
|
let mut runtime = Runtime::with_factory(&env, factory(&tmp));
|
||||||
assert!(runtime.prefs().auto_check, "缺省开");
|
assert!(runtime.auto_check(), "缺省开");
|
||||||
|
|
||||||
runtime.set_auto_check(false).expect("落盘");
|
runtime.set_auto_check(false).expect("落盘");
|
||||||
let restarted = Runtime::with_factory(&env, factory(&tmp));
|
let restarted = Runtime::with_factory(&env, factory(&tmp));
|
||||||
assert!(!restarted.prefs().auto_check, "重启后仍记得关掉");
|
assert!(!restarted.auto_check(), "重启后仍记得关掉");
|
||||||
assert!(!restarted.view().auto_check);
|
assert!(!restarted.view().auto_check);
|
||||||
|
|
||||||
|
// 落点是主配置的 schema 键(CLI 读到的就是这一份),不是更新域自己的文件。
|
||||||
|
let text = std::fs::read_to_string(env.launcher_config()).expect("读主配置");
|
||||||
|
assert!(text.contains("\"auto_check\": false"), "{text}");
|
||||||
|
assert!(
|
||||||
|
!env.layout
|
||||||
|
.cache()
|
||||||
|
.join("update")
|
||||||
|
.join("prefs.json")
|
||||||
|
.exists(),
|
||||||
|
"新写入不许再落到旧文件"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GUI 与 CLI 共用**同一个** `Job::apply`(mission:不许两套逻辑)。
|
/// GUI 与 CLI 共用**同一个** `Job::apply`(mission:不许两套逻辑)。
|
||||||
@@ -145,7 +168,33 @@ fn the_factory_names_its_source_and_target() {
|
|||||||
let factory = factory(&tmp);
|
let factory = factory(&tmp);
|
||||||
assert_eq!(factory.dir(), Some(tmp.join("dist").as_path()));
|
assert_eq!(factory.dir(), Some(tmp.join("dist").as_path()));
|
||||||
assert_eq!(factory.dest(), tmp.join("bin/unlucky-launcher.exe"));
|
assert_eq!(factory.dest(), tmp.join("bin/unlucky-launcher.exe"));
|
||||||
assert_eq!(CHANNEL.as_str(), "stable", "D50:先只发 stable");
|
}
|
||||||
|
|
||||||
|
/// 渠道来自主配置的 `update.channel`(M161:不再是编译期常量)。
|
||||||
|
///
|
||||||
|
/// 抓得住:把渠道写回硬编码常量而键里是别的值;也抓得住「认不出的渠道 panic 而不是回出厂值」。
|
||||||
|
#[test]
|
||||||
|
fn the_channel_is_read_from_the_schema_key() {
|
||||||
|
let tmp = TempDir::new("channel-key");
|
||||||
|
let env = HostEnv::at_root(tmp.join("root"));
|
||||||
|
let runtime = Runtime::with_factory(&env, factory(&tmp));
|
||||||
|
assert_eq!(
|
||||||
|
runtime.channel().as_str(),
|
||||||
|
unlucky_core::setting::update_prefs::DEFAULT_CHANNEL,
|
||||||
|
"缺键 ⇒ 出厂渠道"
|
||||||
|
);
|
||||||
|
assert_eq!(runtime.view().channel, "stable", "快照里的渠道同源");
|
||||||
|
|
||||||
|
// 手改成一个 schema 不允许的值(越过 `config set` 的校验)⇒ 回出厂渠道,不许 panic。
|
||||||
|
std::fs::create_dir_all(env.layout.config()).expect("建 config");
|
||||||
|
std::fs::write(
|
||||||
|
env.launcher_config(),
|
||||||
|
r#"{"$schema":"https://schemas.unlucky-launcher.dev/ull/launcher/1.6.0","schemaVersion":1,
|
||||||
|
"update":{"channel":"hand-edited"}}"#,
|
||||||
|
)
|
||||||
|
.expect("写配置");
|
||||||
|
let runtime = Runtime::with_factory(&env, factory(&tmp));
|
||||||
|
assert_eq!(runtime.channel().as_str(), "stable", "认不出的渠道回出厂值");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CLI 与 GUI 用的是**同一个** core Job:源码锚点,防止有人另起一条流程。
|
/// CLI 与 GUI 用的是**同一个** core Job:源码锚点,防止有人另起一条流程。
|
||||||
|
|||||||
@@ -1,32 +1,41 @@
|
|||||||
//! 更新状态的**会话层**:一份 `UpdateView`、更新域偏好、以及把 `CoreHost` 接上后台线程。
|
//! 更新状态的**会话层**:一份 `UpdateView`、主配置里的两个开关、以及把 `CoreHost` 接上后台线程。
|
||||||
//!
|
//!
|
||||||
//! 与 [`super::update`] 的分工:那边是「一次检查/应用的纯做法」(选源、调 core、映射 DTO),
|
//! 与 [`super::update`] 的分工:那边是「一次检查/应用的纯做法」(选源、调 core、映射 DTO),
|
||||||
//! 这边是「谁来记着它、谁来派发」。拆开是因为两边都各有一批判据要写清(静默检查的开关、
|
//! 这边是「谁来记着它、谁来派发」。拆开是因为两边都各有一批判据要写清(静默检查的开关、
|
||||||
//! 拒绝后不再打扰、应用失败不许丢掉候选),塞在一个文件里会越过 400 行硬线。
|
//! 拒绝后不再打扰、应用失败不许丢掉候选),塞在一个文件里会越过 400 行硬线。
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use unlucky_core::update::Version;
|
use serde_json::json;
|
||||||
|
use unlucky_core::setting::keys::launcher_schema;
|
||||||
|
use unlucky_core::setting::store::SettingStore;
|
||||||
|
use unlucky_core::setting::update_prefs::{self, Migration};
|
||||||
|
use unlucky_core::update::{Channel, Version};
|
||||||
|
|
||||||
use crate::dto::{CoreEvent, ReadyUpdate, UpdateOutcome, UpdateView};
|
use crate::dto::{CoreEvent, ReadyUpdate, UpdateOutcome, UpdateView};
|
||||||
|
|
||||||
use super::env::HostEnv;
|
use super::env::HostEnv;
|
||||||
use super::update::{current_version, outcome_of, ApplyReport, Factory, Prefs, Report, CHANNEL};
|
use super::update::{current_version, outcome_of, ApplyReport, Factory, Paths, Report};
|
||||||
use super::CoreHost;
|
use super::CoreHost;
|
||||||
|
|
||||||
/// 会话级更新状态(提示条 / 设置页共用一份)。
|
/// 会话级更新状态(提示条 / 设置页共用一份)。
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Runtime {
|
pub struct Runtime {
|
||||||
view: UpdateView,
|
view: UpdateView,
|
||||||
prefs: Prefs,
|
/// `update.auto_check` 的生效值(缓存:`should_check_on_start` 在 `refresh_all` 里被调)。
|
||||||
|
auto_check: bool,
|
||||||
factory: Factory,
|
factory: Factory,
|
||||||
prefs_path: PathBuf,
|
/// `config/launcher.json`(两个开关的真相源;写它就走 `core::setting`)。
|
||||||
|
launcher: PathBuf,
|
||||||
|
/// 建会话时那次遗留文件迁移的结局(`Nothing` = 没有旧文件)。
|
||||||
|
migration: Migration,
|
||||||
/// 有一条检查/应用正在后台跑(防重复派发;进程内独占)。
|
/// 有一条检查/应用正在后台跑(防重复派发;进程内独占)。
|
||||||
inflight: bool,
|
inflight: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Runtime {
|
impl Runtime {
|
||||||
/// 按数据根装配。
|
/// 按数据根装配(顺带把 M159 的 `cache/update/prefs.json` 迁进主配置)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn for_env(env: &HostEnv) -> Self {
|
pub fn for_env(env: &HostEnv) -> Self {
|
||||||
Self::with_factory(env, Factory::for_env(env))
|
Self::with_factory(env, Factory::for_env(env))
|
||||||
@@ -35,13 +44,18 @@ impl Runtime {
|
|||||||
/// 用给定装配(单测:临时目录 + 本地目录源)。
|
/// 用给定装配(单测:临时目录 + 本地目录源)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_factory(env: &HostEnv, factory: Factory) -> Self {
|
pub fn with_factory(env: &HostEnv, factory: Factory) -> Self {
|
||||||
let prefs_path = env.layout.cache().join("update").join("prefs.json");
|
let paths = Paths::for_env(env);
|
||||||
let prefs = Prefs::load(&prefs_path);
|
// 迁移在建会话时跑一次:**幂等**(源指纹闸门),没有旧文件时是纯 stat 一次。
|
||||||
|
let migration = update_prefs::migrate(paths.targets(), now_ms());
|
||||||
|
let store = SettingStore::load(&paths.launcher, launcher_schema());
|
||||||
|
let auto_check = update_prefs::auto_check(store.doc());
|
||||||
|
let channel = update_prefs::channel(store.doc()).to_string();
|
||||||
Self {
|
Self {
|
||||||
view: UpdateView::new(current_version(), CHANNEL.as_str(), prefs.auto_check),
|
view: UpdateView::new(current_version(), &channel, auto_check),
|
||||||
prefs,
|
auto_check,
|
||||||
factory,
|
factory,
|
||||||
prefs_path,
|
launcher: paths.launcher,
|
||||||
|
migration,
|
||||||
inflight: false,
|
inflight: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,10 +72,16 @@ impl Runtime {
|
|||||||
self.view.clone()
|
self.view.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 偏好。
|
/// 「启动时静默检查」的生效值(来自 `launcher.json` 的 `update.auto_check`)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn prefs(&self) -> Prefs {
|
pub const fn auto_check(&self) -> bool {
|
||||||
self.prefs
|
self.auto_check
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 建会话时那次迁移的结局(`Nothing` = 没有遗留文件;收据路径见 [`Migration::receipt`])。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn migration(&self) -> &Migration {
|
||||||
|
&self.migration
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 后台有没有一条在跑。
|
/// 后台有没有一条在跑。
|
||||||
@@ -79,18 +99,37 @@ impl Runtime {
|
|||||||
/// 启动时要不要静默检查(开关关掉 ⇒ 启动**一次网络都不发**)。
|
/// 启动时要不要静默检查(开关关掉 ⇒ 启动**一次网络都不发**)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn should_check_on_start(&self) -> bool {
|
pub fn should_check_on_start(&self) -> bool {
|
||||||
self.prefs.auto_check && !self.inflight
|
self.auto_check && !self.inflight
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 开关「启动时静默检查」并落盘。
|
/// 检查/应用用的渠道:**每次都从主配置读**(用户在 CLI 里改了 `update.channel` 之后,
|
||||||
|
/// 下一次检查就按新渠道走,不用重启)。
|
||||||
|
///
|
||||||
|
/// 认不出的值(手改文件 / 未来的渠道)回出厂渠道:schema 已经把写入侧卡在允许值上,
|
||||||
|
/// 读侧只兜底、不报错。
|
||||||
|
#[must_use]
|
||||||
|
pub fn channel(&self) -> Channel {
|
||||||
|
let store = SettingStore::load(&self.launcher, launcher_schema());
|
||||||
|
Channel::parse(update_prefs::channel(store.doc())).unwrap_or(Channel::Stable)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开关「启动时静默检查」并写进主配置(`update.auto_check`)。
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// 落盘失败返回人读文案(本会话的取值仍然生效——不因为写不进去就把开关弹回去)。
|
/// 落盘失败返回人读文案(本会话的取值仍然生效——不因为写不进去就把开关弹回去)。
|
||||||
pub fn set_auto_check(&mut self, on: bool) -> Result<(), String> {
|
pub fn set_auto_check(&mut self, on: bool) -> Result<(), String> {
|
||||||
self.prefs.auto_check = on;
|
self.auto_check = on;
|
||||||
self.view.auto_check = on;
|
self.view.auto_check = on;
|
||||||
self.prefs.save(&self.prefs_path)
|
let mut store = SettingStore::load(&self.launcher, launcher_schema());
|
||||||
|
store
|
||||||
|
.set(update_prefs::KEY_AUTO_CHECK, json!(on))
|
||||||
|
.map_err(|error| format!("写 update.auto_check 被拒:{error}"))?;
|
||||||
|
match store.save() {
|
||||||
|
Ok(saved) if saved.wrote() || !store.is_dirty() => Ok(()),
|
||||||
|
Ok(_) => Err("update.auto_check 没有落盘(配置只读或文件不可信)".to_string()),
|
||||||
|
Err(error) => Err(format!("写 {} 失败:{error}", self.launcher.display())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 开始一次检查:置「正在查」并返回要交给后台线程的 [`unlucky_core::update::Job`]
|
/// 开始一次检查:置「正在查」并返回要交给后台线程的 [`unlucky_core::update::Job`]
|
||||||
@@ -105,6 +144,7 @@ impl Runtime {
|
|||||||
self.view.outcome = UpdateOutcome::Checking;
|
self.view.outcome = UpdateOutcome::Checking;
|
||||||
self.view.silent = !manual;
|
self.view.silent = !manual;
|
||||||
self.view.applying = false;
|
self.view.applying = false;
|
||||||
|
self.view.channel = self.channel().as_str().to_string();
|
||||||
if manual {
|
if manual {
|
||||||
// 手动检查 = 用户要看真结果,把「忽略这个版本」清掉。
|
// 手动检查 = 用户要看真结果,把「忽略这个版本」清掉。
|
||||||
self.view.skipped = None;
|
self.view.skipped = None;
|
||||||
@@ -190,9 +230,10 @@ impl CoreHost {
|
|||||||
let Some(job) = self.update.begin_check(manual) else {
|
let Some(job) = self.update.begin_check(manual) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let channel = self.update.channel();
|
||||||
// 先把「正在查」发出去:设置页的按钮据此转圈,而不是看起来没反应。
|
// 先把「正在查」发出去:设置页的按钮据此转圈,而不是看起来没反应。
|
||||||
self.publish_update();
|
self.publish_update();
|
||||||
spawn_check(job, current, self.tx.clone());
|
spawn_check(job, current, channel, self.tx.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 应用更新(下载 → 长度/SHA256 双检 → 替换;全部在后台线程)。
|
/// 应用更新(下载 → 长度/SHA256 双检 → 替换;全部在后台线程)。
|
||||||
@@ -203,27 +244,46 @@ impl CoreHost {
|
|||||||
let Some(job) = self.update.begin_apply() else {
|
let Some(job) = self.update.begin_apply() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let channel = self.update.channel();
|
||||||
self.publish_update();
|
self.publish_update();
|
||||||
spawn_apply(job, current, self.tx.clone());
|
spawn_apply(job, current, channel, self.tx.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 起一条检查线程(结果经 [`super::HostTask::Update`] 回来)。
|
/// 起一条检查线程(结果经 [`super::HostTask::Update`] 回来)。
|
||||||
pub fn spawn_check(job: unlucky_core::update::Job, current: Version, tx: super::JobSender) {
|
pub fn spawn_check(
|
||||||
|
job: unlucky_core::update::Job,
|
||||||
|
current: Version,
|
||||||
|
channel: Channel,
|
||||||
|
tx: super::JobSender,
|
||||||
|
) {
|
||||||
super::job::spawn("update-check", move || {
|
super::job::spawn("update-check", move || {
|
||||||
let report = Report::Checked(outcome_of(job.check(¤t, CHANNEL)));
|
let report = Report::Checked(outcome_of(job.check(¤t, channel)));
|
||||||
let _ = tx.send(super::HostTask::Update(Box::new(report)));
|
let _ = tx.send(super::HostTask::Update(Box::new(report)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 起一条应用线程(下载 + 双检 + 替换都在里面)。
|
/// 起一条应用线程(下载 + 双检 + 替换都在里面)。
|
||||||
pub fn spawn_apply(job: unlucky_core::update::Job, current: Version, tx: super::JobSender) {
|
pub fn spawn_apply(
|
||||||
|
job: unlucky_core::update::Job,
|
||||||
|
current: Version,
|
||||||
|
channel: Channel,
|
||||||
|
tx: super::JobSender,
|
||||||
|
) {
|
||||||
super::job::spawn("update-apply", move || {
|
super::job::spawn("update-apply", move || {
|
||||||
let report = Report::applied(job.apply(¤t, CHANNEL));
|
let report = Report::applied(job.apply(¤t, channel));
|
||||||
let _ = tx.send(super::HostTask::Update(Box::new(report)));
|
let _ = tx.send(super::HostTask::Update(Box::new(report)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前时刻(毫秒 since UNIX_EPOCH;收据用)。
|
||||||
|
fn now_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX))
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "update_tests.rs"]
|
#[path = "update_tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -19,12 +19,12 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||||||
|
|
||||||
use unlucky_core::download::verify::digest_bytes;
|
use unlucky_core::download::verify::digest_bytes;
|
||||||
use unlucky_core::download::HashAlgo;
|
use unlucky_core::download::HashAlgo;
|
||||||
use unlucky_core::update::{Applied, Target, Version};
|
use unlucky_core::update::{Applied, Channel, Target, Version};
|
||||||
|
|
||||||
use crate::dto::{UpdateBanner, UpdateOutcome};
|
use crate::dto::{UpdateBanner, UpdateOutcome};
|
||||||
|
|
||||||
use super::super::env::HostEnv;
|
use super::super::env::HostEnv;
|
||||||
use super::super::update::{outcome_of, ApplyReport, Factory, Prefs, Report, CHANNEL};
|
use super::super::update::{outcome_of, ApplyReport, Factory, Report};
|
||||||
use super::Runtime;
|
use super::Runtime;
|
||||||
|
|
||||||
/// 一次性临时目录。
|
/// 一次性临时目录。
|
||||||
@@ -134,7 +134,7 @@ fn version_matrix_decides_who_gets_prompted() {
|
|||||||
let tmp = TempDir::new("matrix");
|
let tmp = TempDir::new("matrix");
|
||||||
let payload = b"new";
|
let payload = b"new";
|
||||||
dir_source(&tmp, &manifest_for(listed, payload, ""), payload);
|
dir_source(&tmp, &manifest_for(listed, payload, ""), payload);
|
||||||
let outcome = outcome_of(job(&tmp).check(&version(current), CHANNEL));
|
let outcome = outcome_of(job(&tmp).check(&version(current), Channel::Stable));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
outcome.candidate().is_some(),
|
outcome.candidate().is_some(),
|
||||||
expected,
|
expected,
|
||||||
@@ -180,7 +180,7 @@ fn a_broken_manifest_degrades_to_failure_not_up_to_date() {
|
|||||||
] {
|
] {
|
||||||
let tmp = TempDir::new("badmanifest");
|
let tmp = TempDir::new("badmanifest");
|
||||||
tmp.write("dist/stable.json", manifest.as_bytes());
|
tmp.write("dist/stable.json", manifest.as_bytes());
|
||||||
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), CHANNEL));
|
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), Channel::Stable));
|
||||||
assert!(
|
assert!(
|
||||||
matches!(outcome, UpdateOutcome::Failed(_)),
|
matches!(outcome, UpdateOutcome::Failed(_)),
|
||||||
"{label}:坏清单必须降级成「检查失败」,实际 {outcome:?}"
|
"{label}:坏清单必须降级成「检查失败」,实际 {outcome:?}"
|
||||||
@@ -206,7 +206,7 @@ fn an_empty_manifest_means_nothing_published_yet() {
|
|||||||
] {
|
] {
|
||||||
let tmp = TempDir::new("empty-manifest");
|
let tmp = TempDir::new("empty-manifest");
|
||||||
tmp.write("dist/stable.json", manifest.as_bytes());
|
tmp.write("dist/stable.json", manifest.as_bytes());
|
||||||
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), CHANNEL));
|
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), Channel::Stable));
|
||||||
assert!(
|
assert!(
|
||||||
matches!(&outcome, UpdateOutcome::UpToDate { latest: None }),
|
matches!(&outcome, UpdateOutcome::UpToDate { latest: None }),
|
||||||
"空清单 = 还没发过版,实际 {outcome:?}"
|
"空清单 = 还没发过版,实际 {outcome:?}"
|
||||||
@@ -228,7 +228,7 @@ fn a_higher_version_without_our_platform_is_not_up_to_date() {
|
|||||||
)
|
)
|
||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
);
|
);
|
||||||
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), CHANNEL));
|
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), Channel::Stable));
|
||||||
assert!(
|
assert!(
|
||||||
matches!(&outcome, UpdateOutcome::MissingArtifact { version } if version == "0.9.0"),
|
matches!(&outcome, UpdateOutcome::MissingArtifact { version } if version == "0.9.0"),
|
||||||
"缺本平台产物要单独报出来,实际 {outcome:?}"
|
"缺本平台产物要单独报出来,实际 {outcome:?}"
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
|
|||||||
},
|
},
|
||||||
last_played: Some("2026-10-27T12:40:00Z".to_string()),
|
last_played: Some("2026-10-27T12:40:00Z".to_string()),
|
||||||
runtime: InstanceRuntime::Failed {
|
runtime: InstanceRuntime::Failed {
|
||||||
reason: "exit0 静默失败".to_string(),
|
stage: crate::dto::LaunchFailureStage::Spawn,
|
||||||
|
detail: "exit 0 但游戏没起来(静默失败)".to_string(),
|
||||||
},
|
},
|
||||||
icon: None,
|
icon: None,
|
||||||
isolation: "isolated".to_string(),
|
isolation: "isolated".to_string(),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use crate::dto::{
|
|||||||
InstanceRuntime,
|
InstanceRuntime,
|
||||||
InstanceSummary,
|
InstanceSummary,
|
||||||
JavaPageView,
|
JavaPageView,
|
||||||
|
LaunchFinished,
|
||||||
LoaderKind,
|
LoaderKind,
|
||||||
LogLine,
|
LogLine,
|
||||||
LogSession,
|
LogSession,
|
||||||
@@ -32,7 +33,7 @@ use crate::prefs::UiPrefs;
|
|||||||
// 内容库的视图态与其 DTO 放一起(`dto/content.rs`),路径 `state::ContentState` 不变。
|
// 内容库的视图态与其 DTO 放一起(`dto/content.rs`),路径 `state::ContentState` 不变。
|
||||||
pub use crate::dto::ContentState;
|
pub use crate::dto::ContentState;
|
||||||
use crate::router::{AiPanelMode, Route, Router};
|
use crate::router::{AiPanelMode, Route, Router};
|
||||||
use crate::toast::{ToastCenter, ToastKind};
|
use crate::toast::ToastCenter;
|
||||||
use crate::wizard::WizardState;
|
use crate::wizard::WizardState;
|
||||||
|
|
||||||
// 账户/登录状态(倒计时、上屏前二次脱敏)拆在 `state/accounts.rs`,守 400 行硬线。
|
// 账户/登录状态(倒计时、上屏前二次脱敏)拆在 `state/accounts.rs`,守 400 行硬线。
|
||||||
@@ -100,6 +101,11 @@ pub struct AppState {
|
|||||||
/// 只是信号,不带文案:app 层比对前后值决定「要不要弹一条 i18n Toast」,
|
/// 只是信号,不带文案:app 层比对前后值决定「要不要弹一条 i18n Toast」,
|
||||||
/// 于是取消提示和页面用同一种语言(复审 P2-2)。
|
/// 于是取消提示和页面用同一种语言(复审 P2-2)。
|
||||||
pub install_cancelled: u64,
|
pub install_cancelled: u64,
|
||||||
|
/// 启动收尾次数(M161,每次 `LaunchFinished` +1):与 `install_cancelled` 同一条纪律——
|
||||||
|
/// presenter 只发信号,文案由 app 层按 i18n key 现求。
|
||||||
|
pub launch_ends: u64,
|
||||||
|
/// 最近一次启动收尾的结构(结局 + 退出码 + 失败阶段;文案在 app 层求)。
|
||||||
|
pub last_launch_end: Option<Box<LaunchFinished>>,
|
||||||
/// 加载器版本清单(下载页「安装选项」的下拉;M105)
|
/// 加载器版本清单(下载页「安装选项」的下拉;M105)
|
||||||
///
|
///
|
||||||
/// 两段键:外层是加载器的**稳定 id**(`LoaderKind::as_str`,`LoaderKind` 没派生 `Hash`,
|
/// 两段键:外层是加载器的**稳定 id**(`LoaderKind::as_str`,`LoaderKind` 没派生 `Hash`,
|
||||||
@@ -160,6 +166,8 @@ impl AppState {
|
|||||||
java: JavaPageView::new(),
|
java: JavaPageView::new(),
|
||||||
install: None,
|
install: None,
|
||||||
install_cancelled: 0,
|
install_cancelled: 0,
|
||||||
|
launch_ends: 0,
|
||||||
|
last_launch_end: None,
|
||||||
loader_versions: std::collections::HashMap::new(),
|
loader_versions: std::collections::HashMap::new(),
|
||||||
settings_groups: Vec::new(),
|
settings_groups: Vec::new(),
|
||||||
settings_tab: "download".to_string(),
|
settings_tab: "download".to_string(),
|
||||||
@@ -246,12 +254,11 @@ impl AppState {
|
|||||||
CoreEvent::LaunchFinished(finished) => {
|
CoreEvent::LaunchFinished(finished) => {
|
||||||
self.live_session = None;
|
self.live_session = None;
|
||||||
self.selected_session = Some(finished.session.clone());
|
self.selected_session = Some(finished.session.clone());
|
||||||
let kind = if finished.outcome == crate::dto::LogOutcome::Ok {
|
// **不在这里拼文案**(M161):presenter 不依赖 `unlucky-i18n`,写死的中文会在
|
||||||
ToastKind::Success
|
// 英文界面上弹出来。这里只记「又收尾了一场」,Toast 由 app 层按 key 现求
|
||||||
} else {
|
// (与 `install_cancelled` 同一条纪律,见 `app/state/refresh.rs`)。
|
||||||
ToastKind::Error
|
self.launch_ends = self.launch_ends.saturating_add(1);
|
||||||
};
|
self.last_launch_end = Some(Box::new(finished));
|
||||||
self.toasts.push(kind, finished.summary);
|
|
||||||
}
|
}
|
||||||
CoreEvent::DiagnosisReady(report) => self.diagnosis = Some(report),
|
CoreEvent::DiagnosisReady(report) => self.diagnosis = Some(report),
|
||||||
CoreEvent::AccountsChanged(accounts) => self.accounts = accounts,
|
CoreEvent::AccountsChanged(accounts) => self.accounts = accounts,
|
||||||
|
|||||||
Reference in new issue
Block a user