按独立复审 `reviewer-updategui-r1` 的 p1-1 / fix-then-merge 返工。
P1-1(挡门)
- `crates/cli/src/cli/parse.rs` 406 → **382** 行:`fn parse_update` 搬到
`crates/cli/src/cli/parse/update.rs`(与 `parse/{accounts,agent,instances}.rs` 同形状)。
全仓 `crates/**/*.rs` 最大仍是 400(`design/tests/card_neutral.rs` 等三处,未越线)。
上一轮报告里「所有改动文件都在 400 行内」那句是错的,已在 r2 报告里更正。
P3-6 `Job::apply` 不查包形态
- 新增 `self_replaceable(format)`(只有 `PortableExe` 允许)+ `ensure_self_replaceable`,
**挡在取包之前**;错误分类 `InvalidManifest`(发布侧挂错,换源/重试无用)。
比复审建议的 `needs_installer()` 更严:`portable-zip`/`appimage` 同样不能原地当 exe。
- 用例:`crates/core/src/update/tests/job_cases.rs`(msi/zip 被拒 + 目标零改动 + 暂存目录
没被建 + 便携 exe 整环成功 + Nothing 不碰目标 + 闸门的源码锚点)。
P3-7 `ULL_UPDATE_DIR` 生产可用
- `default_source` 改走 `local_dir_override()`:`if !cfg!(debug_assertions) { return None }`,
发布构建里整段被常量折叠掉(这条路径在 release 二进制里不存在)。判据与三档取舍写在
`job.rs` 的文档表里(为什么不用 CLI 参数、为什么不用运行期开关)。
P3-1「忽略这个版本」语义
- `UpdateView.dismissed: bool` → `skipped: Option<String>`(记**版本号**):忽略 0.2.3 之后
同一次运行里冒出 0.2.4 **照常提示**;手动检查清空(`begin_check(manual)`),静默检查不清。
文案保持「忽略这个版本」。presenter / app 侧视图模型 / 两处用例同步。
P3-3 CLI `--json` 失败信封缺结构化分类
- `CliError` 加 `detail`(`Option<Box<Value>>`,装箱避免 `result_large_err`)+ `with_detail`,
`to_json` 输出 `error.detail`;`commands/update.rs` 的 failure 填
`{"kind": core 分类 id, "retryable": 可重试位}`。
- 上一轮报告里引的 `{"kind":"digest_mismatch"}` 形状确实不存在(那是 CLI 的 user/internal),
r2 报告换成了实机 dump。
P3-4 update 命令没有解析测试
- `crates/cli/tests/parse_test.rs`:`update_subcommands`(check / apply / `--dest` 两种写法)
+ `update_usage_errors`(无子命令 / 未知子命令 / 多余位置参数 / 缺少取值 / 未知选项);
`help_text_covers_every_command` 的 topic 列表补 `"update"`。
P3-5 `install.rs` 模块文档与实测矛盾
- 改成「可以改名、不能覆盖/删除」,写明 M159 在 Windows 真机验过运行中的 exe 自替换,
并提醒别再照旧文档去设计「退出自身 → 另一个进程搬运」的双进程方案。
顺手(复审 P3-2/P3-8 里低成本的两条)
- 设置页补「下载中」态(`SettingsUpdateApplying` 不再是死 key,下载期间不画「下载并安装」);
渠道显示改读 `update.channel`(`stable` 走 key,其它原样);删掉从未使用的
`UpdateBannerRestartLater`(catalog + 金表同步,29 条 key 全部有引用)。
- `adopt` 只让**检查**刷新 `checked_at`(应用回报不再冒充「上次检查」);
`UpdateView::default()` 的 `current_version` 取本进程版本号(mock 下不再空白)。
门禁(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-159`):fmt 绿、clippy -D warnings 绿、
`cargo test --workspace --all-targets --no-fail-fast -j 4` **exit 0**(51 个测试二进制全 ok,
`core::addon::pack` 未被 skip)。该族用例复现了复审记的泄漏:一次全量在 `/tmp` 留 72 个
`ull-pack-*`(`/tmp` 149M → 8853M),跑完已 `rm -rf` 清回 150M。
230 lines
8.6 KiB
Rust
230 lines
8.6 KiB
Rust
//! 更新状态的**会话层**:一份 `UpdateView`、更新域偏好、以及把 `CoreHost` 接上后台线程。
|
||
//!
|
||
//! 与 [`super::update`] 的分工:那边是「一次检查/应用的纯做法」(选源、调 core、映射 DTO),
|
||
//! 这边是「谁来记着它、谁来派发」。拆开是因为两边都各有一批判据要写清(静默检查的开关、
|
||
//! 拒绝后不再打扰、应用失败不许丢掉候选),塞在一个文件里会越过 400 行硬线。
|
||
|
||
use std::path::PathBuf;
|
||
|
||
use unlucky_core::update::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::CoreHost;
|
||
|
||
/// 会话级更新状态(提示条 / 设置页共用一份)。
|
||
#[derive(Debug)]
|
||
pub struct Runtime {
|
||
view: UpdateView,
|
||
prefs: Prefs,
|
||
factory: Factory,
|
||
prefs_path: PathBuf,
|
||
/// 有一条检查/应用正在后台跑(防重复派发;进程内独占)。
|
||
inflight: bool,
|
||
}
|
||
|
||
impl Runtime {
|
||
/// 按数据根装配。
|
||
#[must_use]
|
||
pub fn for_env(env: &HostEnv) -> Self {
|
||
Self::with_factory(env, Factory::for_env(env))
|
||
}
|
||
|
||
/// 用给定装配(单测:临时目录 + 本地目录源)。
|
||
#[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);
|
||
Self {
|
||
view: UpdateView::new(current_version(), CHANNEL.as_str(), prefs.auto_check),
|
||
prefs,
|
||
factory,
|
||
prefs_path,
|
||
inflight: false,
|
||
}
|
||
}
|
||
|
||
/// 当前快照。
|
||
#[must_use]
|
||
pub fn view(&self) -> &UpdateView {
|
||
&self.view
|
||
}
|
||
|
||
/// 当前快照(克隆给事件/线程)。
|
||
#[must_use]
|
||
pub fn snapshot(&self) -> UpdateView {
|
||
self.view.clone()
|
||
}
|
||
|
||
/// 偏好。
|
||
#[must_use]
|
||
pub const fn prefs(&self) -> Prefs {
|
||
self.prefs
|
||
}
|
||
|
||
/// 后台有没有一条在跑。
|
||
#[must_use]
|
||
pub const fn inflight(&self) -> bool {
|
||
self.inflight
|
||
}
|
||
|
||
/// 本进程版本解析(失败时 `None`:版本号是编译进来的常量,理论上不可能失败)。
|
||
#[must_use]
|
||
pub fn current(&self) -> Option<Version> {
|
||
Version::parse(current_version()).ok()
|
||
}
|
||
|
||
/// 启动时要不要静默检查(开关关掉 ⇒ 启动**一次网络都不发**)。
|
||
#[must_use]
|
||
pub fn should_check_on_start(&self) -> bool {
|
||
self.prefs.auto_check && !self.inflight
|
||
}
|
||
|
||
/// 开关「启动时静默检查」并落盘。
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// 落盘失败返回人读文案(本会话的取值仍然生效——不因为写不进去就把开关弹回去)。
|
||
pub fn set_auto_check(&mut self, on: bool) -> Result<(), String> {
|
||
self.prefs.auto_check = on;
|
||
self.view.auto_check = on;
|
||
self.prefs.save(&self.prefs_path)
|
||
}
|
||
|
||
/// 开始一次检查:置「正在查」并返回要交给后台线程的 [`unlucky_core::update::Job`]
|
||
/// (`None` = 已经有一条在跑)。
|
||
///
|
||
/// `manual` = 用户点的「立即检查」:会清掉「忽略这个版本」,并让失败也弹提示条。
|
||
pub fn begin_check(&mut self, manual: bool) -> Option<unlucky_core::update::Job> {
|
||
if self.inflight {
|
||
return None;
|
||
}
|
||
self.inflight = true;
|
||
self.view.outcome = UpdateOutcome::Checking;
|
||
self.view.silent = !manual;
|
||
self.view.applying = false;
|
||
if manual {
|
||
// 手动检查 = 用户要看真结果,把「忽略这个版本」清掉。
|
||
self.view.skipped = None;
|
||
}
|
||
Some(self.factory.job())
|
||
}
|
||
|
||
/// 开始应用:`None` = 没有在跑的必要(正在忙 / 没有候选 / 已经就位)。
|
||
pub fn begin_apply(&mut self) -> Option<unlucky_core::update::Job> {
|
||
if self.inflight || self.view.ready.is_some() || self.view.outcome.candidate().is_none() {
|
||
return None;
|
||
}
|
||
self.inflight = true;
|
||
self.view.applying = true;
|
||
Some(self.factory.job())
|
||
}
|
||
|
||
/// 后台回报进快照(**唯一**的写入口:检查与应用都走这里)。
|
||
pub fn adopt(&mut self, report: Report) {
|
||
self.inflight = false;
|
||
match report {
|
||
Report::Checked(outcome) => {
|
||
// 「上次检查」只该被**检查**刷新:应用回来的那一刻不是一次检查。
|
||
self.view.checked_at = super::update::stamp();
|
||
// 「忽略这个版本」记的是版本号,所以**这里不需要动它**:
|
||
// 同一版本回来仍被压住,更高版本自然就提示了(见 `UpdateView::skipped`)。
|
||
// 手动检查那一支的清空在 `begin_check(manual)` 里。
|
||
self.view.outcome = outcome;
|
||
}
|
||
Report::Applied(ApplyReport::Installed {
|
||
version,
|
||
path,
|
||
backup,
|
||
}) => {
|
||
self.view.applying = false;
|
||
self.view.outcome = UpdateOutcome::UpToDate {
|
||
latest: Some(version.clone()),
|
||
};
|
||
self.view.ready = Some(ReadyUpdate {
|
||
version,
|
||
path,
|
||
backup,
|
||
});
|
||
}
|
||
Report::Applied(ApplyReport::Nothing(outcome)) => {
|
||
self.view.applying = false;
|
||
self.view.outcome = outcome;
|
||
}
|
||
Report::Applied(ApplyReport::Failed(failure)) => {
|
||
// 应用失败要说清是哪一步(校验不符 / 替换被占用)。候选随 `outcome` 一起换成
|
||
// `Failed`,**重试走「立即检查」重新复查**(清单只有几 KB,apply 本来也要复查一次):
|
||
// 这样重试拿到的永远是最新那一份,而不是几分钟前的旧候选。失败也不再算「静默」,
|
||
// 提示条要把原因摆出来。
|
||
self.view.applying = false;
|
||
self.view.outcome = UpdateOutcome::Failed(failure);
|
||
self.view.silent = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 用户忽略**当前这个候选版本**:记下版本号(候选仍留在快照里,设置页可手动装)。
|
||
///
|
||
/// 没有候选时是空操作(`skipped` 保持原样):没版本可忽略就不该写进一个「你别问」的状态。
|
||
pub fn dismiss(&mut self) {
|
||
if let Some(candidate) = self.view.outcome.candidate() {
|
||
self.view.skipped = Some(candidate.version.clone());
|
||
}
|
||
}
|
||
}
|
||
|
||
impl CoreHost {
|
||
/// 发布更新快照(唯一的出口;`CoreEvent::Update` 是整份替换)。
|
||
pub(super) fn publish_update(&mut self) {
|
||
let view = self.update.snapshot();
|
||
self.send(CoreEvent::Update(Box::new(view)));
|
||
}
|
||
|
||
/// 派一次检查(`manual` = 设置页点的「立即检查」;`false` = 启动时的静默检查)。
|
||
pub(super) fn start_update_check(&mut self, manual: bool) {
|
||
let Some(current) = self.update.current() else {
|
||
return;
|
||
};
|
||
let Some(job) = self.update.begin_check(manual) else {
|
||
return;
|
||
};
|
||
// 先把「正在查」发出去:设置页的按钮据此转圈,而不是看起来没反应。
|
||
self.publish_update();
|
||
spawn_check(job, current, self.tx.clone());
|
||
}
|
||
|
||
/// 应用更新(下载 → 长度/SHA256 双检 → 替换;全部在后台线程)。
|
||
pub(super) fn start_update_apply(&mut self) {
|
||
let Some(current) = self.update.current() else {
|
||
return;
|
||
};
|
||
let Some(job) = self.update.begin_apply() else {
|
||
return;
|
||
};
|
||
self.publish_update();
|
||
spawn_apply(job, current, self.tx.clone());
|
||
}
|
||
}
|
||
|
||
/// 起一条检查线程(结果经 [`super::HostTask::Update`] 回来)。
|
||
pub fn spawn_check(job: unlucky_core::update::Job, current: Version, tx: super::JobSender) {
|
||
super::job::spawn("update-check", move || {
|
||
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) {
|
||
super::job::spawn("update-apply", move || {
|
||
let report = Report::applied(job.apply(¤t, CHANNEL));
|
||
let _ = tx.send(super::HostTask::Update(Box::new(report)));
|
||
});
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[path = "update_tests.rs"]
|
||
mod tests;
|