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

+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;