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