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

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

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

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

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

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

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

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

No files matched your search

+23 -4
View File
@@ -26,6 +26,7 @@ use crate::dto::{
InstanceSummary,
JavaPageView,
LaunchArgsPatch,
LaunchFailureView,
LoaderKind,
LogLine,
LogOutcome,
@@ -54,6 +55,11 @@ pub struct LiveLogBatch {
}
/// 一次启动收尾(磁贴状态回写 + 日志页会话列表刷新)。
///
/// **不带人读散文**(M161):早先这里有一个 `summary: String`,内容是 presenter 拼的中文
/// (`正常退出` / `崩溃(exit 1)` / `启动失败:找不到 Java`)。于是英文界面上弹出的是中文 Toast,
/// 磁贴那行还叠成「Launch failed · 启动失败:…」。现在只给**结构**,文案由 app 层按 key 现求
/// (与 `InstallCancelled` 同一条纪律:presenter 不依赖 `unlucky-i18n`)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchFinished {
/// 实例 id
@@ -62,8 +68,13 @@ pub struct LaunchFinished {
pub session: SessionId,
/// 结局
pub outcome: LogOutcome,
/// 一句人读结论(`正常退出` / `崩溃(exit 1)` / `启动失败:…`)
pub summary: String,
/// 进程退出码(`None` = 进程没起来 / 平台没给)
pub exit_code: Option<i32>,
/// 从 spawn 到收尾的毫秒数(没起来时是 0)
pub uptime_ms: u64,
/// 失败发生在哪一步(`None` = 这一场没有装配/启动阶段的失败;
/// 「跑起来又崩了」看 `outcome`)
pub failure: Option<LaunchFailureView>,
}
/// core 侧事件(不可变快照发布 + 事件流,01c §5 的三条通道之二)。
@@ -248,9 +259,17 @@ pub enum AppCommand {
CheckUpdate,
/// 把当前候选下载下来、校验、替换本程序(结果经 [`CoreEvent::Update`] 回来)
ApplyUpdate,
/// 忽略这个版本(本次会话不再弹提示条;候选仍留在快照里,设置页可手动装)
/// 忽略**这一个候选版本**(记的是版本号,不是「别再问我」)。
///
/// 语义三条(`UpdateView::skipped` 的契约,`dto::update` 的模块文档与用例守着):
/// 同一次运行里同一个版本不再弹提示条;**更高的版本照弹**(0.2.3 被忽略不影响 0.2.4);
/// 设置页的「立即检查」会清掉它。候选本身仍留在快照里,设置页可手动装。
DismissUpdate,
/// 开关「启动时静默检查」(写进更新域自己的偏好文件)
/// 开关「启动时静默检查」。
///
/// 落点是主配置 `config/launcher.json` 的 `update.auto_check`(M161 从更新域自己的
/// `cache/update/prefs.json` 迁过来的 schema 键)——`ull config get update.auto_check`
/// 读到的是同一个值。
SetUpdateAutoCheck(bool),
}
+10 -2
View File
@@ -2,6 +2,8 @@
use serde::{Deserialize, Serialize};
pub use super::launch_failure::{LaunchFailureStage, LaunchFailureView};
use std::path::PathBuf;
use unlucky_core::game::LoaderKind as CoreLoaderKind;
@@ -208,9 +210,15 @@ pub enum InstanceRuntime {
pid: u32,
},
/// 上次启动失败(磁贴显示「重试」)
///
/// M161 之前这里只有一个 `reason: String`,而且里面已经写着「启动失败:…」——
/// 界面再包一层 `InstancesFailed`(`Launch failed · {reason}`)就成了「英文前缀 + 中文句子」,
/// 中文界面则是「启动失败 · 启动失败:…」。现在拆成「阶段(可本地化)+ 原文(不翻译)」。
Failed {
/// 失败原因(用户可见文案)
reason: String,
/// 失败阶段
stage: LaunchFailureStage,
/// 原始错误文本(不翻译)
detail: String,
},
}
@@ -0,0 +1,74 @@
//! 启动失败的**结构**(M161:presenter 只给阶段 + 原文,文案由界面按 i18n key 现求)。
//!
//! 从 `dto/instances.rs` 拆出:那边贴着 400 行硬线,而这两个类型是「启动」这条线上的
//! 独立一块(磁贴状态行、收尾事件、Toast 三处都读它)。
use serde::{Deserialize, Serialize};
/// 启动失败发生在**哪一步**(M161:界面文案按它选 i18n key,presenter 不写散文)。
///
/// 分档只在「用户该去做什么」不同的地方切,不按代码结构切:
///
/// | 档 | 意思 | 用户下一步 |
/// |---|---|---|
/// | [`Self::VersionManifest`] | `versions/<id>.json` 读不出来 / 解析不了 | 去版本管理重装这个版本 |
/// | [`Self::Inheritance`] | 继承链断了(父版本缺失) | 同上(父版本缺了) |
/// | [`Self::InstanceConfig`] | 实例设置文件读不出来 | 去实例页看一眼那个实例 |
/// | [`Self::Isolation`] | 隔离目录派生失败(路径/权限) | 换隔离档位或修目录权限 |
/// | [`Self::Identity`] | 账户装配失败(找不到账户 / 凭据过期) | 去账户页重新登录 |
/// | [`Self::Spawn`] | 命令行装配完了但进程起不来 | 看 Java 与日志 |
///
/// 「跑起来了但崩了」不在这张表里:那是**结局**,由 `LogOutcome` 表达(三个变体各有 i18n key)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LaunchFailureStage {
/// 版本 JSON 读不出来 / 解析不了
VersionManifest,
/// 继承链断裂
Inheritance,
/// 实例设置文件读不出来
InstanceConfig,
/// 隔离目录派生失败
Isolation,
/// 账户装配失败
Identity,
/// 进程起不来
Spawn,
}
impl LaunchFailureStage {
/// 稳定 slug(日志 / 事件流 / 测试用;**不要改名**)。
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::VersionManifest => "version_manifest",
Self::Inheritance => "inheritance",
Self::InstanceConfig => "instance_config",
Self::Isolation => "isolation",
Self::Identity => "identity",
Self::Spawn => "spawn",
}
}
}
/// 一次启动失败:**哪一步** + 那一步的原文。
///
/// `detail` 是当场的原始错误文本(core / io / 账户层给的),**故意不翻译**:里面有路径、
/// 版本 id、HTTP 状态这类机器细节,翻成人话就丢了取证价值。要本地化的是「哪一步」
/// ([`LaunchFailureStage`]),界面按它选词。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchFailureView {
/// 失败阶段
pub stage: LaunchFailureStage,
/// 原始错误文本(不翻译)
pub detail: String,
}
impl LaunchFailureView {
/// 建一条。
pub fn new(stage: LaunchFailureStage, detail: impl Into<String>) -> Self {
Self {
stage,
detail: detail.into(),
}
}
}
+7 -6
View File
@@ -23,6 +23,7 @@ mod instances;
mod java;
// M157: 实例启动参数(启动参数区的读取视图 + 写回补丁)
mod launch_args;
mod launch_failure;
mod logs;
mod notices;
// M113: import/export —— 整合包导入/导出的 DTO(M111/M112 管线的 IPC 面)
@@ -45,8 +46,8 @@ pub use content::{
pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch};
pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage};
pub use instances::{
CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo,
LoaderKind, Progress,
CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchFailureStage,
LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, Progress,
};
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
pub use launch_args::{LaunchArgsPatch, LaunchArgsView};
@@ -57,10 +58,10 @@ pub use pack::{
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView,
ConflictReasonView, ConflictSourceView, ExportClassView, ExportDoneView, ExportFileView,
ExportPreviewView, ExportReasonView, ExportRequestView, ExportState, FileActionView,
ImportConflictView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView,
ImportScanView, ImportStageView, ImportState, ImportStatusView, ImportedFileView,
MissingFileView, MissingReasonView, PackCommand, PackEvent, PackFormatView, PackState,
ProbeView,
ImportConflictView, ImportErrorView, ImportFailureStageView, ImportProgressView,
ImportReportView, ImportRequestView, ImportScanView, ImportStageView, ImportState,
ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView, PackCommand, PackEvent,
PackFormatView, PackState, ProbeView,
};
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
// M159: 自更新
+5 -4
View File
@@ -13,7 +13,8 @@
//! 1. **三态**([`ImportStatusView`]):`Partial` 不是 `Success`。把「缺了 3 个文件」说成成功,
//! 用户会在启动后才发现缺依赖;
//! 2. **容忍 / 致命的分野**:[`MissingFileView`] 是「搞不到某个文件」(实例可用),
//! [`ImportErrorView`] 是「已整包回滚」(实例零残留)。UI 必须让这两类**视觉可区分**;
//! [`ImportErrorView`] 是致命失败。UI 必须让这两类**视觉可区分**——并且致命那一类还要按
//! [`ImportReportView::failure_stage`] 再分三档说(预检没过 / 已回滚 / 回滚没做完,M161);
//! 3. **导出的三分类**([`ExportClassView`]):`Inline` 是「证明不了可被远程替代,原字节进
//! `overrides/`」,`Skipped` 是「压根没进包」。`skipped > 0` 意味着包**不完整**。
//!
@@ -56,7 +57,7 @@ pub use export::{
};
pub use format::PackFormatView;
pub use import::{
FileActionView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView,
ImportStageView, ImportState, ImportStatusView, ImportedFileView, MissingFileView,
MissingReasonView, ProbeView,
FileActionView, ImportErrorView, ImportFailureStageView, ImportProgressView, ImportReportView,
ImportRequestView, ImportStageView, ImportState, ImportStatusView, ImportedFileView,
MissingFileView, MissingReasonView, ProbeView,
};
+49 -2
View File
@@ -62,6 +62,43 @@ impl ImportStageView {
}
}
/// 致命失败的**阶段**(M161:`rolled_back` 一个人说不了两件事)。
///
/// core 的 `ImportReport::rolled_back` 语义是「目标目录已确认与导入前一致」——它同时覆盖
/// 「回滚完整完成」和「**从未写过**目标目录」两种情形(见 `core::addon::import::pipeline` 的
/// `record_rollback` / `abandoned`)。UI 拿一个 bool 判不出该说哪一句,于是预检失败也会被
/// 说成「已回滚」,而实际上一个字节都没写。这个枚举把两种情形重新分开,并补上第三种
/// (回滚有失败项,目标目录可能与导入前不同)。
///
/// 只在 `status == Failed` 时有值:成功/部分成功的报告没有「失败阶段」可言。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ImportFailureStageView {
/// 预检/装配阶段就失败了:**目标目录一个字节都没写**(用户该去修包/修配置再试)。
NotStarted,
/// 已经写过目标目录,回滚**完整**完成(零残留)。
RolledBack,
/// 写过了,但回滚有失败项:目标目录可能与导入前不同(`warnings` 里逐条列了)。
RollbackIncomplete,
}
impl ImportFailureStageView {
/// 稳定 slug(日志 / 测试用;**不要改名**)。
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::NotStarted => "not_started",
Self::RolledBack => "rolled_back",
Self::RollbackIncomplete => "rollback_incomplete",
}
}
/// 本次失败到底动没动目标目录。
#[must_use]
pub const fn touched_target(self) -> bool {
!matches!(self, Self::NotStarted)
}
}
/// 导入三态(`Partial` **不是** `Success`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ImportStatusView {
@@ -69,7 +106,11 @@ pub enum ImportStatusView {
Success,
/// 有缺文件或有冲突(内容不完整,但实例可用)。
Partial,
/// 致命失败,**已整包回滚**。
/// 致命失败。
///
/// **「动没动过目标目录」不在这里**(M161):同一个 `Failed` 既可能是预检没过(一个字节都没写),
/// 也可能是写完又回滚干净,还可能是回滚没做完——三种由
/// [`ImportReportView::failure_stage`] 分开说,界面按它选标题与图标。
Failed,
}
@@ -243,7 +284,7 @@ pub struct MissingFileView {
pub detail: String,
}
/// 一次**已整包回滚**的致命失败。
/// 一次致命失败(是不是「已整包回滚」看 [`ImportReportView::failure_stage`])。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImportErrorView {
/// 稳定码(`pack_traversal` / `import_integrity` …,进日志与 `--json`)。
@@ -292,7 +333,13 @@ pub struct ImportReportView {
/// 本次写入的字节数。
pub bytes_written: u64,
/// 回滚是否完整(`false` = 目标目录可能与导入前不一致,逐条在 `warnings` 里)。
///
/// **注意**:`true` 有两种来源——「回滚完整完成」与「从未写过目标目录」(core 的
/// `abandoned`)。要区分它们看 [`ImportReportView::failure_stage`];界面上的措辞
/// **必须**按那个字段选(M161)。
pub rolled_back: bool,
/// 失败发生在哪一步(`None` = 这次不是致命失败)。
pub failure_stage: Option<ImportFailureStageView>,
}
impl ImportReportView {
@@ -11,6 +11,7 @@
use std::path::{Path, PathBuf};
use unlucky_core::addon::export::{ExportReport, FileClass, FileReason};
use unlucky_core::addon::import::report::ImportStatus;
use unlucky_core::addon::import::{
ConflictChoice, ConflictDecisions, ConflictFile, ConflictReason, FileAction, FileSource,
ImportError, ImportProgress, ImportReport, ImportScan, LoaderTarget, MissingReason,
@@ -19,9 +20,9 @@ use unlucky_core::addon::import::{
use crate::dto::{
ConflictChoiceView, ConflictDecisionsView, ConflictFileView, ConflictReasonView,
ConflictSourceView, ExportClassView, ExportDoneView, ExportReasonView, FileActionView,
ImportConflictView, ImportErrorView, ImportProgressView, ImportReportView, ImportScanView,
ImportStageView, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView,
PackFormatView,
ImportConflictView, ImportErrorView, ImportFailureStageView, ImportProgressView,
ImportReportView, ImportScanView, ImportStageView, ImportStatusView, ImportedFileView,
MissingFileView, MissingReasonView, PackFormatView,
};
/// 一次导入**预检** → DTO(M160:冲突清单)。
@@ -150,9 +151,29 @@ pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportV
.map(|registered| registered.name.clone()),
bytes_written: report.bytes_written,
rolled_back: report.rolled_back,
failure_stage: failure_stage(report),
}
}
/// 致命失败发生在哪一步(M161)。
///
/// 判据只用 core 的两个机器位,**不看 warnings 的措辞**:
/// - `rollback: Some(outcome)` ⇒ 真的跑过回滚(写过了),完整与否看 `is_complete()`;
/// - `rollback: None` ⇒ 没跑过回滚。core 的约定是「失败发生在碰目标目录之前」(`abandoned`),
/// 此时 `rolled_back == true`;若 `rolled_back` 也是 `false`(既没回滚也没宣称干净),
/// 那就如实报「可能不一致」,不替它圆场。
fn failure_stage(report: &ImportReport) -> Option<ImportFailureStageView> {
if report.status != ImportStatus::Failed {
return None;
}
Some(match &report.rollback {
Some(outcome) if outcome.is_complete() => ImportFailureStageView::RolledBack,
Some(_) => ImportFailureStageView::RollbackIncomplete,
None if report.rolled_back => ImportFailureStageView::NotStarted,
None => ImportFailureStageView::RollbackIncomplete,
})
}
/// 一次导出结果 → DTO。
pub(super) fn done_view(report: &ExportReport) -> ExportDoneView {
ExportDoneView {
@@ -296,6 +317,8 @@ fn failed_report_with(target: PathBuf, pack: &Path, error: ImportErrorView) -> I
registered: None,
bytes_written: 0,
rolled_back: true,
// 预检/装配阶段就失败了:目标目录一个字节都没写(不是「写完又回滚」)。
failure_stage: Some(ImportFailureStageView::NotStarted),
}
}
+15 -5
View File
@@ -2,7 +2,10 @@
//!
//! 父模块的私有字段对子模块可见,所以这里直接读写 `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 super::{addon, logs, CoreHost, HostTask, LiveLaunch};
@@ -160,12 +163,19 @@ impl CoreHost {
self.failed.remove(&finished.id);
InstanceRuntime::Idle
} else {
// 记住失败原因:随后的 refresh_instances 会重建快照,
// 记住失败(阶段 + 原文):随后的 refresh_instances 会重建快照,
// 不记的话磁贴的「失败/重试」外观会被 Idle 盖掉。
self.failed
.insert(finished.id.clone(), finished.summary.clone());
let failure = finished.failure.clone().unwrap_or_else(|| {
// 跑起来再崩:没有「装配阶段」可言,用结局本身当阶段外的说明。
LaunchFailureView::new(
LaunchFailureStage::Spawn,
format!("exit {:?}", finished.exit_code),
)
});
self.failed.insert(finished.id.clone(), failure.clone());
InstanceRuntime::Failed {
reason: finished.summary.clone(),
stage: failure.stage,
detail: failure.detail,
}
};
self.send(CoreEvent::InstanceRuntimeChanged {
+28 -15
View File
@@ -11,7 +11,7 @@ use unlucky_core::download::blocking::block_on;
use unlucky_core::game::ProfileId;
use unlucky_core::launch::request::AuthPlaceholders;
use crate::dto::LogOutcome;
use crate::dto::{LaunchFailureStage, LaunchFailureView, LogOutcome};
use super::env::HostEnv;
@@ -22,13 +22,16 @@ use super::env::HostEnv;
/// # Errors
///
/// 账户不存在/凭据类型不符/令牌过期且刷新失败时返回用户可读文案。
pub fn resolve(env: &HostEnv, account: Option<&str>) -> Result<(AuthPlaceholders, String), String> {
pub fn resolve(
env: &HostEnv,
account: Option<&str>,
) -> Result<(AuthPlaceholders, String), LaunchFailureView> {
let store = AccountStore::open(env.layout.auth_data());
let chosen = match account {
Some(key) => store
.find(key)
.cloned()
.ok_or_else(|| format!("找不到账户 `{key}`"))?,
.ok_or_else(|| identity_failure(format!("找不到账户 `{key}`")))?,
None => match store.accounts() {
[] => {
let uuid = ProfileId::generate().as_str().replace('-', "");
@@ -44,27 +47,32 @@ pub fn resolve(env: &HostEnv, account: Option<&str>) -> Result<(AuthPlaceholders
));
}
[only] => only.clone(),
many => return Err(format!("有 {} 个账户,请在账户页选一个再启动", many.len())),
many => {
return Err(identity_failure(format!(
"有 {} 个账户,请在账户页选一个再启动",
many.len()
)))
}
},
};
let line = format!("账户 {}({})", chosen.display_name(), chosen.kind.label());
match chosen.kind {
AccountKind::Offline => {
let placeholders =
offline_login(&chosen).map_err(|error| format!("离线账户解析失败:{error}"))?;
let placeholders = offline_login(&chosen)
.map_err(|error| identity_failure(format!("离线账户解析失败:{error}")))?;
Ok((placeholders, line))
}
AccountKind::Microsoft => {
let Some(Credentials::Microsoft(session)) = store.credentials(&chosen.id) else {
return Err(format!(
return Err(identity_failure(format!(
"账户 {} 的微软凭据缺失或类型不符,请重新登录",
chosen.display_name()
));
)));
};
let auth =
MicrosoftAuth::from_env().map_err(|error| format!("微软登录不可用:{error}"))?;
let auth = MicrosoftAuth::from_env()
.map_err(|error| identity_failure(format!("微软登录不可用:{error}")))?;
let (fresh, _) = block_on(auth.ensure_fresh(session))
.map_err(|error| format!("微软令牌刷新失败:{error}"))?;
.map_err(|error| identity_failure(format!("微软令牌刷新失败:{error}")))?;
if &fresh != session {
// refresh_token 已轮换:立刻回写,旧值作废。
let mut store = AccountStore::open(env.layout.auth_data());
@@ -73,24 +81,29 @@ pub fn resolve(env: &HostEnv, account: Option<&str>) -> Result<(AuthPlaceholders
}
let placeholders = fresh
.to_placeholders()
.map_err(|error| format!("微软身份转换失败:{error}"))?;
.map_err(|error| identity_failure(format!("微软身份转换失败:{error}")))?;
Ok((placeholders, line))
}
AccountKind::AuthlibInjector => {
let Some(Credentials::AuthlibInjector(session)) = store.credentials(&chosen.id) else {
return Err(format!(
return Err(identity_failure(format!(
"账户 {} 的第三方凭据缺失或类型不符",
chosen.display_name()
));
)));
};
let placeholders = session
.to_placeholders()
.map_err(|error| format!("第三方身份转换失败:{error}"))?;
.map_err(|error| identity_failure(format!("第三方身份转换失败:{error}")))?;
Ok((placeholders, line))
}
}
}
/// 账户/身份这一步的失败(阶段固定 `Identity`;`detail` 保留原始文案与本机错误)。
fn identity_failure(detail: String) -> LaunchFailureView {
LaunchFailureView::new(LaunchFailureStage::Identity, detail)
}
/// `SessionHandle` → 日志页的三种结局。
pub fn outcome_of(handle: &unlucky_core::launch::SessionHandle) -> LogOutcome {
if handle.exit.silent_failure.is_some() {
-23
View File
@@ -21,7 +21,6 @@ use crate::dto::{
LiveLogBatch,
LoaderKind,
LogLine,
LogOutcome,
LogSession,
NoticeView,
PackEvent,
@@ -155,18 +154,6 @@ pub fn spawn(name: &str, job: impl FnOnce() + Send + 'static) -> std::thread::Jo
.unwrap_or_else(|error| panic!("起不来后台线程 {name}: {error}"))
}
/// 人读一行「启动结束」。
pub fn launch_summary(outcome: LogOutcome, exit_code: Option<i32>) -> String {
match outcome {
LogOutcome::Ok => "正常退出".to_string(),
LogOutcome::Crashed => match exit_code {
Some(code) => format!("崩溃(exit {code})"),
None => "崩溃".to_string(),
},
LogOutcome::SilentFail => "静默失败(exit 0 但游戏没起来)".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -187,14 +174,4 @@ mod tests {
let task = rx.recv().expect("收到");
assert!(matches!(task, HostTask::Toast(toast) if toast.text == "hi"));
}
#[test]
fn launch_summary_names_the_outcome() {
assert_eq!(launch_summary(LogOutcome::Ok, Some(0)), "正常退出");
assert_eq!(
launch_summary(LogOutcome::Crashed, Some(1)),
"崩溃(exit 1)"
);
assert!(launch_summary(LogOutcome::SilentFail, Some(0)).contains("静默"));
}
}
+55 -102
View File
@@ -17,16 +17,22 @@ use unlucky_core::game::{self, VersionJson};
use unlucky_core::launch::adapter::{
self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput,
};
use unlucky_core::launch::request::{JavaSelection, LaunchRequest, MemoryOptions};
use unlucky_core::launch::request::{LaunchRequest, MemoryOptions};
use unlucky_core::launch::time::{format_rfc3339_millis, now};
use unlucky_core::launch::tree_kill;
use unlucky_core::launch::{Launcher, LauncherConfig};
use unlucky_core::platform::paths::ProcessEnv;
use crate::dto::{InstanceId, InstanceRuntime, LaunchFinished, LaunchStage, LogOutcome, Progress};
use crate::dto::{
InstanceId, InstanceRuntime, LaunchFailureStage, LaunchFailureView, LaunchFinished,
LaunchStage, LogOutcome, Progress,
};
use super::env::HostEnv;
use super::job::{launch_summary, HostTask, JobSender};
mod java;
use super::job::{HostTask, JobSender};
pub use java::pick_java;
/// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。
pub const LAUNCHER_NAME: &str = "Unlucky Launcher";
@@ -44,104 +50,38 @@ pub struct LaunchTarget {
pub account: Option<String>,
}
/// 选中并用起来的 Java(`summary` 给 UI 一行提示)。
pub struct JavaPick {
/// 交给 adapter 的选择
pub selection: JavaSelection,
/// 人读一行
pub summary: String,
}
/// 选一个能用的 Java:显式路径(实例 → 全局 `java.selected_path`)> 托管 > JAVA_HOME > PATH。
///
/// 显式路径**先探测**(`java -version`):探测失败不报错,按版本要求的基准主版本兜底——
/// 与自动挑的退路同一口径(启动路径不该因为探测不到就拒绝启动)。
pub fn pick_java(env: &HostEnv, version_id: &str, explicit: Option<&std::path::Path>) -> JavaPick {
let store = env.store();
let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id);
if let Some(path) = explicit {
let runtime = unlucky_core::game::java::probe::probe(
path,
unlucky_core::game::java::probe::JavaOrigin::Instance,
);
let major = runtime.major.unwrap_or(required.minimum);
return JavaPick {
selection: JavaSelection::new(runtime.binary.clone(), major, runtime.bits),
summary: format!(
"实例指定 Java {}({})",
runtime.version.clone().unwrap_or_else(|| major.to_string()),
path.display()
),
};
}
let install_root = store
.doc()
.effective_str("java.installRoot")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(PathBuf::from);
let roots = unlucky_core::game::java::location::scan_roots(
env.mode(),
&env.layout,
install_root.as_deref(),
env.platform,
&ProcessEnv,
);
let candidates = unlucky_core::game::java::candidates_in_roots(&roots);
let picked = unlucky_core::game::java::pick(&candidates, &required).cloned();
match picked {
Some(runtime) => {
let major = runtime
.major
.unwrap_or(unlucky_core::game::java::DEFAULT_MAJOR);
let summary = format!(
"Java {}({}{})",
runtime.version.clone().unwrap_or_else(|| major.to_string()),
runtime.origin.category(),
runtime
.vendor
.as_ref()
.map(|vendor| format!(" · {vendor}"))
.unwrap_or_default()
);
JavaPick {
selection: JavaSelection::new(runtime.binary, major, runtime.bits),
summary,
}
}
None => {
let binary = PathBuf::from(unlucky_core::game::java::java_binary_name());
let major = required.minimum;
JavaPick {
selection: JavaSelection::new(binary, major, 64),
summary: format!("PATH 上的 Java(未探测到版本,按 Java {major} 启动)"),
}
}
}
}
/// 装配 [`LaunchRequest`](与 CLI `snapshot::prepare` 同序)。
///
/// # Errors
///
/// 版本 JSON 缺失/非法、继承链断裂、隔离派生失败时返回用户可读文案。
/// 任一步失败都返回 [`LaunchFailureView`]:**阶段**(版本 JSON / 继承链 / 实例设置 / 隔离目录 /
/// 账户)+ 那一步的原文。文案不在这一层拼(presenter 不依赖 `unlucky-i18n`)。
pub fn build_request(
env: &HostEnv,
target: &LaunchTarget,
) -> Result<(LaunchRequest, Vec<String>), String> {
) -> Result<(LaunchRequest, Vec<String>), LaunchFailureView> {
let version_id = target.version_id.clone();
let manifest_path = env.game.version_json(&version_id);
let text = std::fs::read_to_string(&manifest_path)
.map_err(|error| format!("读版本 JSON 失败 {}:{error}", manifest_path.display()))?;
let manifest =
VersionJson::from_json_str(&text).map_err(|error| format!("版本 JSON 非法:{error}"))?;
let text = std::fs::read_to_string(&manifest_path).map_err(|error| {
LaunchFailureView::new(
LaunchFailureStage::VersionManifest,
format!("{}:{error}", manifest_path.display()),
)
})?;
let manifest = VersionJson::from_json_str(&text).map_err(|error| {
LaunchFailureView::new(
LaunchFailureStage::VersionManifest,
format!("{}:{error}", manifest_path.display()),
)
})?;
let source = |id: &str| -> Option<VersionJson> {
std::fs::read_to_string(env.game.version_json(id))
.ok()
.and_then(|text| VersionJson::from_json_str(&text).ok())
};
let resolved =
game::resolve(&source, &manifest).map_err(|error| format!("解析继承链失败:{error}"))?;
let resolved = game::resolve(&source, &manifest).map_err(|error| {
LaunchFailureView::new(LaunchFailureStage::Inheritance, error.to_string())
})?;
let environment = Environment::host();
let expanded = resolved
@@ -252,12 +192,15 @@ pub fn build_request(
}
/// 派生运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result<PathBuf, String> {
fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result<PathBuf, LaunchFailureView> {
let global = super::game::global_mode(env);
let config = read_instance_config(&env.layout, &target.id)
.map_err(|error| format!("读实例设置失败:{error}"))?;
let config = read_instance_config(&env.layout, &target.id).map_err(|error| {
LaunchFailureView::new(LaunchFailureStage::InstanceConfig, error.to_string())
})?;
let derivation = derive_game_dir(&env.game, &target.version_id, global, config.as_ref(), None)
.map_err(|error| format!("隔离目录派生失败:{error}"))?;
.map_err(|error| {
LaunchFailureView::new(LaunchFailureStage::Isolation, error.to_string())
})?;
if let Some(mut config) = config {
let at = format_rfc3339_millis(now());
if observe_mode(&mut config, &env.game, &at, derivation.isolation).unwrap_or(false) {
@@ -282,12 +225,14 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
});
let (request, warnings) = match build_request(&env, &target) {
Ok(ok) => ok,
Err(reason) => {
Err(failure) => {
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id,
session: String::new(),
outcome: LogOutcome::Crashed,
summary: format!("启动失败:{reason}"),
exit_code: None,
uptime_ms: 0,
failure: Some(failure),
})));
return;
}
@@ -309,7 +254,12 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
id,
session: String::new(),
outcome: LogOutcome::Crashed,
summary: format!("启动失败:{error}"),
exit_code: None,
uptime_ms: 0,
failure: Some(LaunchFailureView::new(
LaunchFailureStage::Spawn,
error.to_string(),
)),
})));
return;
}
@@ -332,12 +282,10 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
id,
session: session_id,
outcome,
summary: format!(
"{}({},{:.1}s)",
launch_summary(outcome, Some(handle.exit.code)),
handle.exit.exit_type.as_str(),
handle.uptime_ms as f64 / 1000.0
),
exit_code: Some(handle.exit.code),
uptime_ms: handle.uptime_ms,
// 跑起来再崩不算「装配失败」:结局由 `outcome` 表达,这里留 `None`。
failure: None,
})));
}
Err(error) => {
@@ -345,7 +293,12 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
id,
session: session_id,
outcome: LogOutcome::SilentFail,
summary: format!("启动收尾失败:{error}"),
exit_code: None,
uptime_ms: 0,
failure: Some(LaunchFailureView::new(
LaunchFailureStage::Spawn,
error.to_string(),
)),
})));
}
}
+88
View File
@@ -0,0 +1,88 @@
//! 「用哪个 Java」:显式路径(实例 → 全局)> 托管 > JAVA_HOME > PATH(M161 从 `host/launch.rs` 拆出)。
//!
//! 拆出的理由与其它 400 行拆分一致:`host/launch.rs` 到线了,而「挑 Java」本来就是独立一段。
//! 口径没变(见原文件与 `core::game::java` 的文档):显式路径**先探测**,探测失败按版本要求的
//! 基准主版本兜底——启动路径不该因为探测不到就拒绝启动。
use std::path::PathBuf;
use unlucky_core::launch::request::JavaSelection;
use unlucky_core::platform::paths::ProcessEnv;
use super::super::env::HostEnv;
/// 选中并用起来的 Java(`summary` 给 UI 一行提示)。
pub struct JavaPick {
/// 交给 adapter 的选择
pub selection: JavaSelection,
/// 人读一行
pub summary: String,
}
/// 选一个能用的 Java:显式路径(实例 → 全局 `java.selected_path`)> 托管 > JAVA_HOME > PATH。
///
/// 显式路径**先探测**(`java -version`):探测失败不报错,按版本要求的基准主版本兜底——
/// 与自动挑的退路同一口径(启动路径不该因为探测不到就拒绝启动)。
pub fn pick_java(env: &HostEnv, version_id: &str, explicit: Option<&std::path::Path>) -> JavaPick {
let store = env.store();
let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id);
if let Some(path) = explicit {
let runtime = unlucky_core::game::java::probe::probe(
path,
unlucky_core::game::java::probe::JavaOrigin::Instance,
);
let major = runtime.major.unwrap_or(required.minimum);
return JavaPick {
selection: JavaSelection::new(runtime.binary.clone(), major, runtime.bits),
summary: format!(
"实例指定 Java {}({})",
runtime.version.clone().unwrap_or_else(|| major.to_string()),
path.display()
),
};
}
let install_root = store
.doc()
.effective_str("java.installRoot")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(PathBuf::from);
let roots = unlucky_core::game::java::location::scan_roots(
env.mode(),
&env.layout,
install_root.as_deref(),
env.platform,
&ProcessEnv,
);
let candidates = unlucky_core::game::java::candidates_in_roots(&roots);
let picked = unlucky_core::game::java::pick(&candidates, &required).cloned();
match picked {
Some(runtime) => {
let major = runtime
.major
.unwrap_or(unlucky_core::game::java::DEFAULT_MAJOR);
let summary = format!(
"Java {}({}{})",
runtime.version.clone().unwrap_or_else(|| major.to_string()),
runtime.origin.category(),
runtime
.vendor
.as_ref()
.map(|vendor| format!(" · {vendor}"))
.unwrap_or_default()
);
JavaPick {
selection: JavaSelection::new(runtime.binary, major, runtime.bits),
summary,
}
}
None => {
let binary = PathBuf::from(unlucky_core::game::java::java_binary_name());
let major = required.minimum;
JavaPick {
selection: JavaSelection::new(binary, major, 64),
summary: format!("PATH 上的 Java(未探测到版本,按 Java {major} 启动)"),
}
}
}
}
@@ -145,7 +145,9 @@ fn launch_started_registers_and_launch_end_clears() {
id: id.clone(),
session: "20261031T120000-abcd12".to_string(),
outcome: LogOutcome::Ok,
summary: "正常退出".to_string(),
exit_code: Some(0),
uptime_ms: 12_500,
failure: None,
})));
assert!(host.launches.is_empty(), "收尾后启动表要清空");
assert!(!host.failed.contains_key(&id));
@@ -197,7 +199,9 @@ fn refresh_keeps_pending_as_busy_and_failed_as_failed() {
id: id.clone(),
session: "20261031T120000-dead01".to_string(),
outcome: LogOutcome::Crashed,
summary: "崩溃(exit 1)".to_string(),
exit_code: Some(1),
uptime_ms: 3_000,
failure: None,
})));
assert!(!host.pending.contains(&id), "LaunchEnd 必须清 pending");
assert!(!host.in_flight(&id), "收尾后该实例要能被重新启动");
@@ -212,7 +216,8 @@ fn refresh_keeps_pending_as_busy_and_failed_as_failed() {
assert_eq!(
failed.runtime,
InstanceRuntime::Failed {
reason: "崩溃(exit 1)".to_string()
stage: crate::dto::LaunchFailureStage::Spawn,
detail: "exit Some(1)".to_string()
},
"失败原因要经得住一次刷新(否则磁贴的「重试」外观会被 Idle 盖掉)"
);
@@ -229,7 +234,12 @@ fn launch_end_before_launch_started_still_releases_the_instance() {
id: id.clone(),
session: String::new(),
outcome: LogOutcome::Crashed,
summary: "启动失败:找不到 Java".to_string(),
exit_code: None,
uptime_ms: 0,
failure: Some(crate::dto::LaunchFailureView::new(
crate::dto::LaunchFailureStage::Identity,
"找不到账户 `steve`",
)),
})));
assert!(
@@ -248,7 +258,8 @@ fn launch_end_before_launch_started_still_releases_the_instance() {
assert_eq!(
failed.runtime,
InstanceRuntime::Failed {
reason: "启动失败:找不到 Java".to_string()
stage: crate::dto::LaunchFailureStage::Identity,
detail: "找不到账户 `steve`".to_string()
},
"早失败也要在磁贴上留下「失败/重试」,不能被 Idle 盖掉"
);
+6 -4
View File
@@ -41,7 +41,7 @@ use std::time::Duration;
use crate::dto::{
AccountView, AppCommand, AppEvent, Backend, CoreEvent, DiagnosisReport, InstallProgressView,
InstanceRuntime, JavaChainStep, NoticeView, SkinImage,
InstanceRuntime, JavaChainStep, LaunchFailureView, NoticeView, SkinImage,
};
use crate::mock::MockBackend;
use crate::toast::{Toast, ToastKind};
@@ -92,7 +92,8 @@ pub struct CoreHost {
/// 已受理但后台线程还没回报 `LaunchStarted` 的实例(防「启动中重复点启动」起第二个进程)
pending: BTreeSet<String>,
/// 上次启动失败的实例与原因(刷新快照时保住磁贴的「失败/重试」外观)
failed: BTreeMap<String, String>,
/// 上次启动失败(阶段 + 原文;磁贴的「失败/重试」外观靠它重建)
failed: BTreeMap<String, LaunchFailureView>,
/// 实时日志尾随器
tail: logs::Tail,
/// AI 对话的临时后端(agent GUI host 落地前的占位)
@@ -228,9 +229,10 @@ impl CoreHost {
stage: crate::dto::LaunchStage::Launch,
progress: crate::dto::Progress { done: 0, total: 1 },
};
} else if let Some(reason) = self.failed.get(&instance.id) {
} else if let Some(failure) = self.failed.get(&instance.id) {
instance.runtime = InstanceRuntime::Failed {
reason: reason.clone(),
stage: failure.stage,
detail: failure.detail.clone(),
};
}
}
+41 -51
View File
@@ -5,10 +5,10 @@
//! ```text
//! core::update::Job ← 检查/应用的做法(版本比较、渠道准入、双检、原子替换全在 core)
//! ▲
//! │ 本模块只做三件事
//! │ 本模块只做四件事
//! Factory ← 用哪个源、暂存放哪、替换谁(生产:GitHub + 本进程 exe)
//! outcome_of ← core 的结论 → DTO(GUI 与 CLI 共用同一条判据)
//! Prefs ← 更新域自己的开关(启动时静默检查)
//! Paths / Migration ← 两个开关的落点,以及旧 `cache/update/prefs.json` 的迁移(M161)
//! ```
//!
//! 「GUI 与 CLI 行为一致」由结构保证:两边都只构造 `core::update::Job` 并调用 `check` / `apply`,
@@ -16,83 +16,73 @@
//!
//! # 静默检查的边界
//!
//! 启动时的检查能被关掉([`Prefs::auto_check`]):关掉之后 host 的装配路径**根本不派发**
//! 启动时的检查能被关掉(`update.auto_check`):关掉之后 host 的装配路径**根本不派发**
//! (判据在 `CoreHost::refresh_all` 的那一行 `if self.update.should_check_on_start()`)。
//!
//! # 偏好文件为什么不在 `launcher.json` 里
//! # 两个开关住在主配置里(M161)
//!
//! `launcher.json` 的每个键都归 `core::setting::keys` 的 schema 管,而本 mission 的 scope 不含
//! `crates/core/src/setting/**`。所以这一条先落在更新域自己的文件
//! `<数据根>/cache/update/prefs.json`(与 `cache/region.json` 同族:域内状态,坏了就回默认)。
//! **加 `update.auto_check` schema 键之后应当迁过去**(已作为 finding 上报)。
//! `update.auto_check` / `update.channel` 是 `crates/core/src/setting/keys.rs` 的 schema 键,
//! 落在 `<数据根>/config/launcher.json`——与「所有配置走 `core::setting`」的约定一致,
//! 于是 `ull config get update.auto_check` 读到的是**同一个值**。
//!
//! M159 曾把这两项临时放进更新域自己的文件 `<数据根>/cache/update/prefs.json`(当时
//! `crates/core/src/setting/**` 不在那条 mission 的 scope),代价是「GUI 里关掉、CLI 仍报 true」。
//! 存量值的迁移在 [`unlucky_core::setting::update_prefs`]:幂等(源指纹闸门)、不丢 `false`、
//! 新值胜出、旧文件改名为 `prefs.json.migrated` 后不再有任何读路径。`Runtime::with_factory`
//! 在建会话时跑一次并记住结局([`Runtime::migration`],`null` = 没有遗留文件)。
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::dto::{UpdateCandidateView, UpdateFailureView, UpdateOutcome};
use unlucky_core::download::DownloadEngine;
use unlucky_core::launch::time::{format_rfc3339_millis, now};
use unlucky_core::setting::update_prefs;
use unlucky_core::update::{
default_source, staging_dir, Applied, Channel, CheckOutcome, DirSource, Job, Target,
UpdateError, UpdateSource, Version,
default_source, staging_dir, Applied, CheckOutcome, DirSource, Job, Target, UpdateError,
UpdateSource, Version,
};
use crate::dto::{UpdateCandidateView, UpdateFailureView, UpdateOutcome};
use super::engine;
use super::env::HostEnv;
use super::logs::display_time;
/// 渠道(D50:先只发 stable)。
pub const CHANNEL: Channel = Channel::Stable;
/// 本进程版本。整个 workspace 同版本(根 `Cargo.toml` 的 `[workspace.package]`)。
#[must_use]
pub fn current_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
/// 更新域偏好(现在只有一项)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Prefs {
/// 启动时静默检查。缺省开:检查本身不打扰用户,发现新版本才有提示条。
#[serde(default = "default_auto_check")]
pub auto_check: bool,
/// 更新域的三个落点(都由数据根派生,core 不猜布局)。
#[derive(Debug, Clone)]
pub struct Paths {
/// `<数据根>/config/launcher.json`(两个开关的真相源)
pub launcher: PathBuf,
/// `<数据根>/cache/update/prefs.json`(M159 的旧文件;迁移后改名 `.migrated`)
pub legacy: PathBuf,
/// `<数据根>/config/update-prefs-migration.json`(迁移收据)
pub receipt: PathBuf,
}
fn default_auto_check() -> bool {
true
}
impl Default for Prefs {
fn default() -> Self {
Self { auto_check: true }
}
}
impl Prefs {
/// 读偏好:文件不存在 / 坏 JSON 一律回默认(偏好坏了不该让启动器起不来)。
impl Paths {
/// 按数据根装配。
#[must_use]
pub fn load(path: &Path) -> Self {
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
pub fn for_env(env: &HostEnv) -> Self {
Self {
launcher: env.launcher_config(),
legacy: env.layout.cache().join("update").join("prefs.json"),
receipt: env.layout.config().join(update_prefs::RECEIPT_FILE),
}
}
/// 写偏好。
///
/// # Errors
///
/// 目录建不出来或写盘失败时返回人读文案。
pub fn save(&self, path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("建更新偏好目录失败:{error}"))?;
/// 迁移要的三个路径。
#[must_use]
pub fn targets(&self) -> update_prefs::Targets<'_> {
update_prefs::Targets {
launcher: &self.launcher,
legacy: &self.legacy,
receipt: &self.receipt,
}
let text = serde_json::to_string_pretty(self)
.map_err(|error| format!("序列化更新偏好失败:{error}"))?;
std::fs::write(path, text).map_err(|error| format!("写更新偏好失败:{error}"))
}
}
@@ -20,7 +20,7 @@ fn sha256_mismatch_refuses_the_apply_and_leaves_no_temp_file() {
std::fs::create_dir_all(dest.parent().expect("父目录")).expect("bin");
std::fs::write(&dest, b"old-binary-v1").expect("旧版本");
let report = Report::applied(job(&tmp).apply(&version("0.2.2"), CHANNEL));
let report = Report::applied(job(&tmp).apply(&version("0.2.2"), Channel::Stable));
match report {
Report::Applied(ApplyReport::Failed(failure)) => {
assert_eq!(failure.kind, "digest_mismatch", "失败分类要能区分摘要不符");
@@ -67,7 +67,7 @@ fn a_successful_apply_leaves_the_new_file_and_a_backup() {
std::fs::write(&dest, b"old-binary-v1").expect("旧版本");
let applied = job(&tmp)
.apply(&version("0.2.2"), CHANNEL)
.apply(&version("0.2.2"), Channel::Stable)
.expect("应用成功");
let Applied::Installed { receipt, version } = applied else {
panic!("应当装上");
@@ -81,21 +81,32 @@ fn dismissing_without_a_candidate_does_nothing() {
}
/// 关掉「启动时检查」之后:状态上是 no-op、启动路径上根本不派发。
///
/// 开关的落点是**主配置**(`update.auto_check`),这里顺带把 M161 的迁移走一遍:
/// 旧 `cache/update/prefs.json` 里的 `false` 必须是新会话看到的 `false`(不是默认的 `true`)。
#[test]
fn auto_check_off_means_no_request_on_start() {
let tmp = TempDir::new("autocheck");
let env = HostEnv::at_root(tmp.join("root"));
let prefs_path = env.layout.cache().join("update").join("prefs.json");
Prefs { auto_check: false }
.save(&prefs_path)
.expect("写偏好");
std::fs::create_dir_all(prefs_path.parent().expect("父目录")).expect("建 cache");
std::fs::write(&prefs_path, r#"{"auto_check": false}"#).expect("写旧偏好");
let runtime = Runtime::with_factory(&env, factory(&tmp));
assert!(!runtime.view().auto_check, "开关要读进快照");
assert!(!runtime.auto_check(), "关掉的值一路迁进主配置");
assert!(
!runtime.should_check_on_start(),
"关掉开关后启动不许派发检查"
);
// 迁移确实发生了:旧文件退役 + 收据落盘(收据可查)。
assert!(!prefs_path.exists(), "旧文件必须退役");
let receipt = runtime
.migration()
.receipt()
.expect("收据路径")
.to_path_buf();
assert!(receipt.exists(), "收据要落盘:{}", receipt.display());
// 结构性判据:`refresh_all` 里那次静默检查必须挂在 `should_check_on_start` 上,
// 否则单测绿着而启动仍然联网(这条读源码,理由同 `instances/tests.rs` 的源码断言)。
@@ -106,18 +117,30 @@ fn auto_check_off_means_no_request_on_start() {
);
}
/// 偏好能落盘、能读回(换一份 Runtime 仍记得上次的选择)。
/// 开关能写进主配置、能读回(换一份 Runtime 仍记得上次的选择)。
#[test]
fn the_auto_check_preference_survives_a_restart() {
let tmp = TempDir::new("prefs");
let env = HostEnv::at_root(tmp.join("root"));
let mut runtime = Runtime::with_factory(&env, factory(&tmp));
assert!(runtime.prefs().auto_check, "缺省开");
assert!(runtime.auto_check(), "缺省开");
runtime.set_auto_check(false).expect("落盘");
let restarted = Runtime::with_factory(&env, factory(&tmp));
assert!(!restarted.prefs().auto_check, "重启后仍记得关掉");
assert!(!restarted.auto_check(), "重启后仍记得关掉");
assert!(!restarted.view().auto_check);
// 落点是主配置的 schema 键(CLI 读到的就是这一份),不是更新域自己的文件。
let text = std::fs::read_to_string(env.launcher_config()).expect("读主配置");
assert!(text.contains("\"auto_check\": false"), "{text}");
assert!(
!env.layout
.cache()
.join("update")
.join("prefs.json")
.exists(),
"新写入不许再落到旧文件"
);
}
/// GUI 与 CLI 共用**同一个** `Job::apply`(mission:不许两套逻辑)。
@@ -145,7 +168,33 @@ fn the_factory_names_its_source_and_target() {
let factory = factory(&tmp);
assert_eq!(factory.dir(), Some(tmp.join("dist").as_path()));
assert_eq!(factory.dest(), tmp.join("bin/unlucky-launcher.exe"));
assert_eq!(CHANNEL.as_str(), "stable", "D50:先只发 stable");
}
/// 渠道来自主配置的 `update.channel`(M161:不再是编译期常量)。
///
/// 抓得住:把渠道写回硬编码常量而键里是别的值;也抓得住「认不出的渠道 panic 而不是回出厂值」。
#[test]
fn the_channel_is_read_from_the_schema_key() {
let tmp = TempDir::new("channel-key");
let env = HostEnv::at_root(tmp.join("root"));
let runtime = Runtime::with_factory(&env, factory(&tmp));
assert_eq!(
runtime.channel().as_str(),
unlucky_core::setting::update_prefs::DEFAULT_CHANNEL,
"缺键 ⇒ 出厂渠道"
);
assert_eq!(runtime.view().channel, "stable", "快照里的渠道同源");
// 手改成一个 schema 不允许的值(越过 `config set` 的校验)⇒ 回出厂渠道,不许 panic。
std::fs::create_dir_all(env.layout.config()).expect("建 config");
std::fs::write(
env.launcher_config(),
r#"{"$schema":"https://schemas.unlucky-launcher.dev/ull/launcher/1.6.0","schemaVersion":1,
"update":{"channel":"hand-edited"}}"#,
)
.expect("写配置");
let runtime = Runtime::with_factory(&env, factory(&tmp));
assert_eq!(runtime.channel().as_str(), "stable", "认不出的渠道回出厂值");
}
/// CLI 与 GUI 用的是**同一个** core Job:源码锚点,防止有人另起一条流程。
+84 -24
View File
@@ -1,32 +1,41 @@
//! 更新状态的**会话层**:一份 `UpdateView`、更新域偏好、以及把 `CoreHost` 接上后台线程。
//! 更新状态的**会话层**:一份 `UpdateView`、主配置里的两个开关、以及把 `CoreHost` 接上后台线程。
//!
//! 与 [`super::update`] 的分工:那边是「一次检查/应用的纯做法」(选源、调 core、映射 DTO),
//! 这边是「谁来记着它、谁来派发」。拆开是因为两边都各有一批判据要写清(静默检查的开关、
//! 拒绝后不再打扰、应用失败不许丢掉候选),塞在一个文件里会越过 400 行硬线。
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use unlucky_core::update::Version;
use serde_json::json;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use unlucky_core::setting::update_prefs::{self, Migration};
use unlucky_core::update::{Channel, Version};
use crate::dto::{CoreEvent, ReadyUpdate, UpdateOutcome, UpdateView};
use super::env::HostEnv;
use super::update::{current_version, outcome_of, ApplyReport, Factory, Prefs, Report, CHANNEL};
use super::update::{current_version, outcome_of, ApplyReport, Factory, Paths, Report};
use super::CoreHost;
/// 会话级更新状态(提示条 / 设置页共用一份)。
#[derive(Debug)]
pub struct Runtime {
view: UpdateView,
prefs: Prefs,
/// `update.auto_check` 的生效值(缓存:`should_check_on_start` 在 `refresh_all` 里被调)。
auto_check: bool,
factory: Factory,
prefs_path: PathBuf,
/// `config/launcher.json`(两个开关的真相源;写它就走 `core::setting`)。
launcher: PathBuf,
/// 建会话时那次遗留文件迁移的结局(`Nothing` = 没有旧文件)。
migration: Migration,
/// 有一条检查/应用正在后台跑(防重复派发;进程内独占)。
inflight: bool,
}
impl Runtime {
/// 按数据根装配。
/// 按数据根装配(顺带把 M159 的 `cache/update/prefs.json` 迁进主配置)。
#[must_use]
pub fn for_env(env: &HostEnv) -> Self {
Self::with_factory(env, Factory::for_env(env))
@@ -35,13 +44,18 @@ impl Runtime {
/// 用给定装配(单测:临时目录 + 本地目录源)。
#[must_use]
pub fn with_factory(env: &HostEnv, factory: Factory) -> Self {
let prefs_path = env.layout.cache().join("update").join("prefs.json");
let prefs = Prefs::load(&prefs_path);
let paths = Paths::for_env(env);
// 迁移在建会话时跑一次:**幂等**(源指纹闸门),没有旧文件时是纯 stat 一次。
let migration = update_prefs::migrate(paths.targets(), now_ms());
let store = SettingStore::load(&paths.launcher, launcher_schema());
let auto_check = update_prefs::auto_check(store.doc());
let channel = update_prefs::channel(store.doc()).to_string();
Self {
view: UpdateView::new(current_version(), CHANNEL.as_str(), prefs.auto_check),
prefs,
view: UpdateView::new(current_version(), &channel, auto_check),
auto_check,
factory,
prefs_path,
launcher: paths.launcher,
migration,
inflight: false,
}
}
@@ -58,10 +72,16 @@ impl Runtime {
self.view.clone()
}
/// 偏好。
/// 「启动时静默检查」的生效值(来自 `launcher.json` 的 `update.auto_check`)。
#[must_use]
pub const fn prefs(&self) -> Prefs {
self.prefs
pub const fn auto_check(&self) -> bool {
self.auto_check
}
/// 建会话时那次迁移的结局(`Nothing` = 没有遗留文件;收据路径见 [`Migration::receipt`])。
#[must_use]
pub const fn migration(&self) -> &Migration {
&self.migration
}
/// 后台有没有一条在跑。
@@ -79,18 +99,37 @@ impl Runtime {
/// 启动时要不要静默检查(开关关掉 ⇒ 启动**一次网络都不发**)。
#[must_use]
pub fn should_check_on_start(&self) -> bool {
self.prefs.auto_check && !self.inflight
self.auto_check && !self.inflight
}
/// 开关「启动时静默检查」并落盘。
/// 检查/应用用的渠道:**每次都从主配置读**(用户在 CLI 里改了 `update.channel` 之后,
/// 下一次检查就按新渠道走,不用重启)。
///
/// 认不出的值(手改文件 / 未来的渠道)回出厂渠道:schema 已经把写入侧卡在允许值上,
/// 读侧只兜底、不报错。
#[must_use]
pub fn channel(&self) -> Channel {
let store = SettingStore::load(&self.launcher, launcher_schema());
Channel::parse(update_prefs::channel(store.doc())).unwrap_or(Channel::Stable)
}
/// 开关「启动时静默检查」并写进主配置(`update.auto_check`)。
///
/// # Errors
///
/// 落盘失败返回人读文案(本会话的取值仍然生效——不因为写不进去就把开关弹回去)。
pub fn set_auto_check(&mut self, on: bool) -> Result<(), String> {
self.prefs.auto_check = on;
self.auto_check = on;
self.view.auto_check = on;
self.prefs.save(&self.prefs_path)
let mut store = SettingStore::load(&self.launcher, launcher_schema());
store
.set(update_prefs::KEY_AUTO_CHECK, json!(on))
.map_err(|error| format!("写 update.auto_check 被拒:{error}"))?;
match store.save() {
Ok(saved) if saved.wrote() || !store.is_dirty() => Ok(()),
Ok(_) => Err("update.auto_check 没有落盘(配置只读或文件不可信)".to_string()),
Err(error) => Err(format!("写 {} 失败:{error}", self.launcher.display())),
}
}
/// 开始一次检查:置「正在查」并返回要交给后台线程的 [`unlucky_core::update::Job`]
@@ -105,6 +144,7 @@ impl Runtime {
self.view.outcome = UpdateOutcome::Checking;
self.view.silent = !manual;
self.view.applying = false;
self.view.channel = self.channel().as_str().to_string();
if manual {
// 手动检查 = 用户要看真结果,把「忽略这个版本」清掉。
self.view.skipped = None;
@@ -190,9 +230,10 @@ impl CoreHost {
let Some(job) = self.update.begin_check(manual) else {
return;
};
let channel = self.update.channel();
// 先把「正在查」发出去:设置页的按钮据此转圈,而不是看起来没反应。
self.publish_update();
spawn_check(job, current, self.tx.clone());
spawn_check(job, current, channel, self.tx.clone());
}
/// 应用更新(下载 → 长度/SHA256 双检 → 替换;全部在后台线程)。
@@ -203,27 +244,46 @@ impl CoreHost {
let Some(job) = self.update.begin_apply() else {
return;
};
let channel = self.update.channel();
self.publish_update();
spawn_apply(job, current, self.tx.clone());
spawn_apply(job, current, channel, self.tx.clone());
}
}
/// 起一条检查线程(结果经 [`super::HostTask::Update`] 回来)。
pub fn spawn_check(job: unlucky_core::update::Job, current: Version, tx: super::JobSender) {
pub fn spawn_check(
job: unlucky_core::update::Job,
current: Version,
channel: Channel,
tx: super::JobSender,
) {
super::job::spawn("update-check", move || {
let report = Report::Checked(outcome_of(job.check(&current, CHANNEL)));
let report = Report::Checked(outcome_of(job.check(&current, channel)));
let _ = tx.send(super::HostTask::Update(Box::new(report)));
});
}
/// 起一条应用线程(下载 + 双检 + 替换都在里面)。
pub fn spawn_apply(job: unlucky_core::update::Job, current: Version, tx: super::JobSender) {
pub fn spawn_apply(
job: unlucky_core::update::Job,
current: Version,
channel: Channel,
tx: super::JobSender,
) {
super::job::spawn("update-apply", move || {
let report = Report::applied(job.apply(&current, CHANNEL));
let report = Report::applied(job.apply(&current, channel));
let _ = tx.send(super::HostTask::Update(Box::new(report)));
});
}
/// 当前时刻(毫秒 since UNIX_EPOCH;收据用)。
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX))
.unwrap_or(0)
}
#[cfg(test)]
#[path = "update_tests.rs"]
mod tests;
+6 -6
View File
@@ -19,12 +19,12 @@ use std::sync::atomic::{AtomicU64, Ordering};
use unlucky_core::download::verify::digest_bytes;
use unlucky_core::download::HashAlgo;
use unlucky_core::update::{Applied, Target, Version};
use unlucky_core::update::{Applied, Channel, Target, Version};
use crate::dto::{UpdateBanner, UpdateOutcome};
use super::super::env::HostEnv;
use super::super::update::{outcome_of, ApplyReport, Factory, Prefs, Report, CHANNEL};
use super::super::update::{outcome_of, ApplyReport, Factory, Report};
use super::Runtime;
/// 一次性临时目录。
@@ -134,7 +134,7 @@ fn version_matrix_decides_who_gets_prompted() {
let tmp = TempDir::new("matrix");
let payload = b"new";
dir_source(&tmp, &manifest_for(listed, payload, ""), payload);
let outcome = outcome_of(job(&tmp).check(&version(current), CHANNEL));
let outcome = outcome_of(job(&tmp).check(&version(current), Channel::Stable));
assert_eq!(
outcome.candidate().is_some(),
expected,
@@ -180,7 +180,7 @@ fn a_broken_manifest_degrades_to_failure_not_up_to_date() {
] {
let tmp = TempDir::new("badmanifest");
tmp.write("dist/stable.json", manifest.as_bytes());
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), CHANNEL));
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), Channel::Stable));
assert!(
matches!(outcome, UpdateOutcome::Failed(_)),
"{label}:坏清单必须降级成「检查失败」,实际 {outcome:?}"
@@ -206,7 +206,7 @@ fn an_empty_manifest_means_nothing_published_yet() {
] {
let tmp = TempDir::new("empty-manifest");
tmp.write("dist/stable.json", manifest.as_bytes());
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), CHANNEL));
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), Channel::Stable));
assert!(
matches!(&outcome, UpdateOutcome::UpToDate { latest: None }),
"空清单 = 还没发过版,实际 {outcome:?}"
@@ -228,7 +228,7 @@ fn a_higher_version_without_our_platform_is_not_up_to_date() {
)
.as_bytes(),
);
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), CHANNEL));
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), Channel::Stable));
assert!(
matches!(&outcome, UpdateOutcome::MissingArtifact { version } if version == "0.9.0"),
"缺本平台产物要单独报出来,实际 {outcome:?}"
+2 -1
View File
@@ -69,7 +69,8 @@ pub fn mock_instances() -> Vec<InstanceSummary> {
},
last_played: Some("2026-10-27T12:40:00Z".to_string()),
runtime: InstanceRuntime::Failed {
reason: "exit0 静默失败".to_string(),
stage: crate::dto::LaunchFailureStage::Spawn,
detail: "exit 0 但游戏没起来(静默失败)".to_string(),
},
icon: None,
isolation: "isolated".to_string(),
+14 -7
View File
@@ -18,6 +18,7 @@ use crate::dto::{
InstanceRuntime,
InstanceSummary,
JavaPageView,
LaunchFinished,
LoaderKind,
LogLine,
LogSession,
@@ -32,7 +33,7 @@ use crate::prefs::UiPrefs;
// 内容库的视图态与其 DTO 放一起(`dto/content.rs`),路径 `state::ContentState` 不变。
pub use crate::dto::ContentState;
use crate::router::{AiPanelMode, Route, Router};
use crate::toast::{ToastCenter, ToastKind};
use crate::toast::ToastCenter;
use crate::wizard::WizardState;
// 账户/登录状态(倒计时、上屏前二次脱敏)拆在 `state/accounts.rs`,守 400 行硬线。
@@ -100,6 +101,11 @@ pub struct AppState {
/// 只是信号,不带文案:app 层比对前后值决定「要不要弹一条 i18n Toast」,
/// 于是取消提示和页面用同一种语言(复审 P2-2)。
pub install_cancelled: u64,
/// 启动收尾次数(M161,每次 `LaunchFinished` +1):与 `install_cancelled` 同一条纪律——
/// presenter 只发信号,文案由 app 层按 i18n key 现求。
pub launch_ends: u64,
/// 最近一次启动收尾的结构(结局 + 退出码 + 失败阶段;文案在 app 层求)。
pub last_launch_end: Option<Box<LaunchFinished>>,
/// 加载器版本清单(下载页「安装选项」的下拉;M105)
///
/// 两段键:外层是加载器的**稳定 id**(`LoaderKind::as_str`,`LoaderKind` 没派生 `Hash`,
@@ -160,6 +166,8 @@ impl AppState {
java: JavaPageView::new(),
install: None,
install_cancelled: 0,
launch_ends: 0,
last_launch_end: None,
loader_versions: std::collections::HashMap::new(),
settings_groups: Vec::new(),
settings_tab: "download".to_string(),
@@ -246,12 +254,11 @@ impl AppState {
CoreEvent::LaunchFinished(finished) => {
self.live_session = None;
self.selected_session = Some(finished.session.clone());
let kind = if finished.outcome == crate::dto::LogOutcome::Ok {
ToastKind::Success
} else {
ToastKind::Error
};
self.toasts.push(kind, finished.summary);
// **不在这里拼文案**(M161):presenter 不依赖 `unlucky-i18n`,写死的中文会在
// 英文界面上弹出来。这里只记「又收尾了一场」,Toast 由 app 层按 key 现求
// (与 `install_cancelled` 同一条纪律,见 `app/state/refresh.rs`)。
self.launch_ends = self.launch_ends.saturating_add(1);
self.last_launch_end = Some(Box::new(finished));
}
CoreEvent::DiagnosisReady(report) => self.diagnosis = Some(report),
CoreEvent::AccountsChanged(accounts) => self.accounts = accounts,