feat(m159): 自更新 GUI 客户端接线 + CLI update check/apply 同一内核

D50 的「检查 → 提示 → 可拒绝 → 下载校验 → 下一次启动生效」整环接线,GUI 与
CLI 共用 core 的一条内核(`update::Job`),并顺带修掉 M157 最后一轮复审的三处 P3。

内核(core::update)
- 新增 job.rs:`Job { source, target, staging, dest }` + `check` / `apply`(复查 →
  取包 → 长度/SHA256 双检 → 原子替换),`default_source`(GitHub,`ULL_UPDATE_DIR`
  可切本地目录夹具)、`process_dest`、`staging_dir`。
- `mod.rs` 导出 job 一族;`source.rs` 为 `Box<dyn UpdateSource>` 补 trait 转发
  (壳层要运行期选源,`UpdateChecker` 是泛型的)。

presenter(宿主通道)
- `dto/update.rs`:`UpdateView` / `UpdateOutcome`(Idle/Checking/UpToDate/Available/
  MissingArtifact/Failed 六态,**失败与最新不折叠**)/ `UpdateBanner` 优先级判据。
- `host/update.rs`:Factory(生产 GitHub / 单测本地目录)+ core 结论 → DTO 的唯一映射
  + 更新域偏好(`cache/update/prefs.json`)。
- `host/update_state.rs`:Runtime(会话状态机:begin_check/adopt/begin_apply/dismiss/
  set_auto_check)+ `CoreHost` 派发与后台线程。
- `AppCommand::{CheckUpdate, ApplyUpdate, DismissUpdate, SetUpdateAutoCheck}`、
  `CoreEvent::Update`、`AppState.update`;`refresh_all` 按开关派发静默检查。

app-iced
- 设置页新增第 5 组「更新」(当前版本 / 渠道 / 自动检查开关 / 立即检查 / 上次检查与
  结果 / 下载并安装 / 就位后立即重启),走 `settings::view` 的 `update` 形参 + 按下标
  分发(与 M106 语言行同一取舍)。
- 顶部提示条(每页可见):有新版本(版本号 + 体积 + 说明摘要,可安装/可忽略)、下载中、
  已就位(立即重启);静默检查失败**不**弹条(原因进设置页)。
- `pages::Message::Update(UpdateAction)` → `app/update/update_action.rs`;「立即重启」
  拉同参数的新进程后退出(截图通道不重启)。

CLI
- `ull update check` / `ull update apply [--dest <path>]`:复用同一 `Job`;四种结论分开
  报(`--json` 的 `status`),失败退出码 1(`update_check_failed` / `update_apply_failed`)。

i18n
- 新增 `catalog/update.rs`(30 条,链尾追加)+ 金表对应 30 行。

M157 复审 P3 三件
- `app/update/instances.rs` 模块文档改成「清脏的唯一时机是回程快照等于表单」。
- `pages/instances/demo.rs` 的 `demo_form` 注释改成现状(两个参数框早已是单行输入)。
- `pages/instances/launch_args.rs`:门限不满足时只灰 `opengl`/`vulkan`,`default` 仍可点
  (它是「不下发」的安全档);判据抽成 `backend_chip_enabled` 并补 3 条用例。浅色 + 灰化
  两张图:`C:/Temp/m159-shots/instances-launch-args-{light,gated}.png`。

顺带:harness 支持 `ULL_SETTINGS_GROUP=<下标>`(拍第 5 组的验收图需要它)。

验证:cargo fmt --check 绿;cargo clippy --workspace --all-targets -D warnings 绿;
cargo test --workspace --all-targets 51 个测试二进制全绿(唯一例外:core::addon::pack
一族要造 4 GiB 稀疏文件,本机磁盘 99% 满 ⇒ StorageFull,与本次改动无关);CLI 用本地
目录夹具跑通「发现 9.9.9 → 下载 → 校验 → 替换运行中的 exe → 备份留旧文件」,并验证
摘要不符时拒绝应用、目标文件零改动、暂存目录零残留。
This commit is contained in:
AskaEth committed 2026-11-07 12:09:42 +08:00
1 parent a80362915a
commit 1c8f6aa1b3
44 files changed
+3341 -22

No files matched your search

+1
View File
@@ -51,6 +51,7 @@ mod download;
mod instances;
mod page_messages;
mod settings;
mod update_action;
impl App {
/// 一条消息进来,改状态并返回后续任务。
+3 -2
View File
@@ -6,8 +6,9 @@
//! `InstancesChanged` 快照刷新页面(不做乐观回显,与 M90 的启动/停止同一条纪律);
//! 3. **恢复跟随全局**只清表单(仍需用户点保存)——「点一下就写盘」会让误触直接生效。
//!
//! 脏标记的清理点在保存之后:`refresh_view` 的 `LaunchForm::sync` 看到「同实例 + 脏」不会覆盖,
//! 所以保存后要显式清脏,让下一次快照(host 写完文件回来的那份)重播种。
//! **清脏的唯一时机是「回程快照等于表单」**:`LaunchForm::sync` 在「同实例 + 脏」时只在
//! `matches(&view)` 成立时才清脏并重播种,其余情况原样保留草稿。保存本身**不清脏**——落盘成败
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
use iced::Task;
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch};
@@ -54,6 +54,8 @@ impl App {
}
}
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
// M159:自更新动作(检查 / 下载安装 / 忽略 / 立即重启 / 开关自动检查)
pages::Message::Update(action) => return self.on_update_action(action),
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
pages::Message::Appearance(change) => self.on_appearance(change),
// M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发
@@ -0,0 +1,158 @@
//! 自更新动作的落点(M159):`pages::Message::Update(UpdateAction)` → presenter 指令 / 重启。
//!
//! 三条纪律:
//!
//! 1. **不做乐观回显**:点了「下载并安装」不改本地状态,等 `CoreEvent::Update` 回来才变
//! (与启动/安装同一条:本地状态与事件流打架时,界面会自说一套);
//! 2. 更新的**判断**(要不要提示、失败算什么、版本比较)全在 core/presenter,这里只转发;
//! 3. 「立即重启」是本文件唯一碰进程的地方:先按同样的命令行拉起新进程,再退出自己——
//! 正因为替换已经完成(新 exe 就在原路径上),拉起来的就是新版本。
use iced::Task;
use unlucky_presenter::dto::AppCommand;
use crate::pages::UpdateAction;
use super::super::message::Message;
use super::super::state::App;
impl App {
/// 一条更新动作。
pub(super) fn on_update_action(&mut self, action: UpdateAction) -> Task<Message> {
match action {
UpdateAction::CheckNow => self.dispatch(AppCommand::CheckUpdate),
UpdateAction::Apply => self.dispatch(AppCommand::ApplyUpdate),
UpdateAction::Dismiss => self.dispatch(AppCommand::DismissUpdate),
UpdateAction::SetAutoCheck(on) => self.dispatch(AppCommand::SetUpdateAutoCheck(on)),
UpdateAction::RestartNow => return self.restart_now(),
}
Task::none()
}
/// 立即重启:按同样的命令行拉起自己,然后退出。
///
/// 截图通道(`--screenshot`)**不重启**:那条路每跑一次就该落一张图然后退出,重启会覆盖同一
/// 文件、并把取证进程拖成死循环。
fn restart_now(&mut self) -> Task<Message> {
if self.options.screenshot.is_some() {
eprintln!("[update] 截图通道不重启(忽略「立即重启」)");
return Task::none();
}
match std::env::current_exe() {
Ok(exe) => {
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
match std::process::Command::new(&exe).args(&args).spawn() {
Ok(child) => eprintln!(
"[update] 已拉起新进程 pid={}:{}",
child.id(),
exe.display()
),
Err(error) => {
eprintln!("[update] 拉起新进程失败:{error}(请手动重启启动器)");
return Task::none();
}
}
}
Err(error) => {
eprintln!("[update] 取 current_exe 失败:{error}");
return Task::none();
}
}
iced::exit()
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use iced::Task;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{AppCommand, AppEvent, Backend};
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages::UpdateAction;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 记录命令流的假后端:把 `dispatch` 收到的每一条抄进共享表(动作接线断没断靠它判)。
struct Recorder {
seen: Arc<Mutex<Vec<AppCommand>>>,
}
impl Backend for Recorder {
fn drain_events(&mut self) -> Vec<AppEvent> {
Vec::new()
}
fn dispatch(&mut self, command: AppCommand) {
self.seen
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(command);
}
fn tick(&mut self, _elapsed: std::time::Duration) {}
}
fn app() -> (App, Arc<Mutex<Vec<AppCommand>>>) {
let seen = Arc::new(Mutex::new(Vec::new()));
let app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(Recorder {
seen: Arc::clone(&seen),
}),
SettingsBridge::memory_only(),
);
(app, seen)
}
fn last(seen: &Arc<Mutex<Vec<AppCommand>>>) -> AppCommand {
seen.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.last()
.cloned()
.expect("应当派发过一条命令")
}
/// 四个转发动作各自交给后端**对应的**指令(串线会红)。
#[test]
fn every_update_action_reaches_the_backend() {
let (mut app, seen) = app();
let _: Task<_> = app.on_update_action(UpdateAction::CheckNow);
assert_eq!(last(&seen), AppCommand::CheckUpdate);
let _ = app.on_update_action(UpdateAction::Apply);
assert_eq!(last(&seen), AppCommand::ApplyUpdate);
let _ = app.on_update_action(UpdateAction::Dismiss);
assert_eq!(last(&seen), AppCommand::DismissUpdate);
let _ = app.on_update_action(UpdateAction::SetAutoCheck(false));
assert_eq!(last(&seen), AppCommand::SetUpdateAutoCheck(false));
}
/// 截图通道不重启:取证进程不许把自己重启成死循环。
#[test]
fn the_screenshot_channel_never_restarts() {
let (mut app, seen) = app();
app.options.screenshot = Some(std::path::PathBuf::from("C:/Temp/shot.png"));
let _ = app.on_update_action(UpdateAction::RestartNow);
assert!(
seen.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.is_empty(),
"重启不是一条后端指令(不该派发任何命令)"
);
}
}
+4
View File
@@ -24,9 +24,11 @@ mod account_page;
mod instances;
mod logs;
mod rail;
mod update;
use instances::{detail_of, runtime_status, supporting_line};
use logs::logs_model;
use update::update_of;
impl App {
/// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。
@@ -178,6 +180,8 @@ pub(super) fn model(
// M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content`
content: pages::content::Content::from_snapshot(state, content, lang.catalog()),
logs: logs_model(state).with_status(lang),
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
update: update_of(&state.update),
lang,
forced: forced.map(|state| match state {
// `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。
+157
View File
@@ -0,0 +1,157 @@
//! presenter 的更新快照 → 页面视图模型(M159 的**唯一**展示转换点)。
//!
//! 与 `view/instances.rs` 同一条分工:判断(要不要提示、是不是失败)在 presenter / core 已经做完,
//! 这里只把字段摆成「要画什么」。三条映射必须与 DTO 的判据保持一致:
//!
//! | DTO | 页面视图 |
//! |---|---|
//! | `UpdateOutcome::UpToDate` | [`UpdateStatus::UpToDate`](**不是**失败) |
//! | `UpdateOutcome::Failed` | [`UpdateStatus::Failed`](带原因,**不许**画成「已是最新」) |
//! | `UpdateOutcome::MissingArtifact` | [`UpdateStatus::MissingArtifact`](发布侧漏项,单独一句话) |
use unlucky_presenter::dto::{UpdateOutcome, UpdateView};
use crate::pages::{Update, UpdateStatus};
/// 快照 → 视图模型。
pub(super) fn update_of(view: &UpdateView) -> Update {
Update {
current_version: view.current_version.clone(),
channel: view.channel.clone(),
auto_check: view.auto_check,
status: status_of(&view.outcome),
checked_at: view.checked_at.clone(),
applying: view.applying,
ready: view.ready.as_ref().map(|ready| ready.version.clone()),
dismissed: view.dismissed,
silent: view.silent,
}
}
/// 结论 → 页面状态(四种结论一一对应,**不折叠**)。
fn status_of(outcome: &UpdateOutcome) -> UpdateStatus {
match outcome {
UpdateOutcome::Idle => UpdateStatus::Idle,
UpdateOutcome::Checking => UpdateStatus::Checking,
UpdateOutcome::UpToDate { latest } => UpdateStatus::UpToDate {
latest: latest.clone(),
},
UpdateOutcome::Available(candidate) => UpdateStatus::Available {
version: candidate.version.clone(),
size: candidate.size,
notes: candidate.notes.clone(),
forced: candidate.forced,
},
UpdateOutcome::MissingArtifact { version } => UpdateStatus::MissingArtifact {
version: version.clone(),
},
UpdateOutcome::Failed(failure) => UpdateStatus::Failed {
reason: failure.message.clone(),
retryable: failure.retryable,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_presenter::dto::{ReadyUpdate, UpdateFailureView};
fn view(outcome: UpdateOutcome) -> UpdateView {
let mut view = UpdateView::new("0.2.2", "stable", true);
view.outcome = outcome;
view
}
#[test]
fn failure_is_never_mapped_to_up_to_date() {
let mapped = update_of(&view(UpdateOutcome::Failed(UpdateFailureView::new(
"network",
"取更新清单失败:连接被重置",
true,
))));
assert!(
matches!(&mapped.status, UpdateStatus::Failed { reason, retryable } if *retryable && !reason.is_empty()),
"失败必须原样带原因,实际 {:?}",
mapped.status
);
assert!(mapped.banner().is_some(), "主动查出来的失败要弹提示条");
// 静默检查的失败:快照里仍是失败,但不弹条(离线是常态)。
let mut silent = view(UpdateOutcome::Failed(UpdateFailureView::new(
"network",
"连不上",
true,
)));
silent.silent = true;
let mapped = update_of(&silent);
assert!(matches!(mapped.status, UpdateStatus::Failed { .. }));
assert!(mapped.banner().is_none());
}
#[test]
fn the_four_outcomes_stay_distinct() {
let available = update_of(&view(UpdateOutcome::Available(Box::new(
unlucky_presenter::dto::UpdateCandidateView {
version: "0.2.3".to_string(),
size: 21_000_000,
notes: "修掉启动崩溃".to_string(),
forced: false,
},
))));
assert!(available.available().is_some());
assert!(matches!(
available.banner(),
Some(crate::pages::Banner::Available { .. })
));
let up_to_date = update_of(&view(UpdateOutcome::UpToDate {
latest: Some("0.2.2".to_string()),
}));
assert!(matches!(up_to_date.status, UpdateStatus::UpToDate { .. }));
assert!(up_to_date.banner().is_none());
let missing = update_of(&view(UpdateOutcome::MissingArtifact {
version: "0.2.3".to_string(),
}));
assert!(matches!(
missing.status,
UpdateStatus::MissingArtifact { .. }
));
}
#[test]
fn a_ready_update_turns_the_banner_into_the_restart_prompt() {
let mut view = view(UpdateOutcome::UpToDate {
latest: Some("0.2.3".to_string()),
});
view.ready = Some(ReadyUpdate {
version: "0.2.3".to_string(),
path: "C:/ULL/unlucky-launcher.exe".to_string(),
backup: None,
});
let mapped = update_of(&view);
assert_eq!(mapped.ready.as_deref(), Some("0.2.3"));
assert!(matches!(
mapped.banner(),
Some(crate::pages::Banner::Ready { .. })
));
}
#[test]
fn a_dismissed_update_keeps_the_snapshot_but_hides_the_banner() {
let mut view = view(UpdateOutcome::Available(Box::new(
unlucky_presenter::dto::UpdateCandidateView {
version: "0.2.3".to_string(),
size: 1,
notes: String::new(),
forced: false,
},
)));
view.dismissed = true;
let mapped = update_of(&view);
assert!(mapped.dismissed);
assert!(mapped.available().is_some(), "候选要留着给设置页");
assert!(mapped.banner().is_none());
}
}
+8
View File
@@ -106,6 +106,14 @@ pub fn run(request: Request) -> Result<(), String> {
request.flavor,
);
state.apply_env();
// M159:`ULL_SETTINGS_GROUP=<下标>` 在 harness 里也生效(产品路径走
// `app/state/construct.rs` 的同一条判据)。没有它就只能拍默认的「General」组,
// 而设置页有五组——「更新」组的验收图正是靠它拍出来的。
if let Some(index) =
crate::ui::lang::screenshot_settings_group(request.screenshot.is_some())
{
state.selected_group = index;
}
eprintln!(
"[harness] 外观真状态:window={:.2} panel={:.2}(生效 {:.2})bg={} fill={:?} dim={:.2} \
配色来源={:?} 选中分组={}",
+3 -2
View File
@@ -92,8 +92,9 @@ pub(crate) fn launch_facts() -> LaunchFacts {
/// 演示用的表单初值(页面状态的所有者是 `App`;harness 没有 `App`,自己持有一份)。
///
/// 返回**拥有**的表单而不是 `&'static`:`text_editor::Content` 内含 `RefCell`,不满足
/// `Sync`,做不成 `static`;harness/测试各自持有即可(一帧借用一次)。
/// 返回**拥有**的表单而不是 `&'static`:出图/单测各自持有一份即可(一帧借用一次),
/// 而 `static` 会让所有出图路径共享同一份可编辑状态(两个参数的控件现在都是单行
/// `field::text_field` + `String`,不再是早先的多行编辑器)。
pub(crate) fn demo_form() -> LaunchForm {
LaunchForm::from_view(Some(DEMO[SELECTED].name), &demo_view())
}
@@ -6,7 +6,7 @@
//! | 游戏参数(多行) | 实例层,追加在命令行最后 | [`Message::Game`] |
//! | 最大内存(单行数字) | 实例层;留空 = 跟随全局默认 | [`Message::Memory`] |
//! | Java 可执行文件(单行) | 实例层;留空 = 跟随全局 → 自动挑 | [`Message::Java`] |
//! | 图形后端(三态 chips) | `default` / `opengl` / `vulkan`;**版本不够时整体灰化 + 提示** | [`Message::Backend`] |
//! | 图形后端(三态 chips) | `default` / `opengl` / `vulkan`;**版本不够时只灰化后两档**(`default` 始终可点)+ 提示 | [`Message::Backend`] |
//!
//! 两条判据刻意不在这里重算:
//! 1. **能不能用 `--graphicsBackend`** 来自 [`LaunchFacts::allowed`](core 的 `graphics_backend_allowed`,
@@ -150,7 +150,26 @@ fn text_field_with_hint<'a>(
.into()
}
/// 三态后端:版本不够时整体禁用(chips 不带 `on_press`)+ 一行门槛提示。
/// 三态后端的「不下发」档(`GraphicsBackend::Default` 的稳定 id)。
///
/// UI 侧不依赖 core(分层:UI → presenter → core),所以在页面里落一个常量;
/// 词表本身仍来自 presenter 快照([`LaunchFacts::options`]),这里只认「哪一个是安全档」。
const BACKEND_DEFAULT: &str = "default";
/// 某一档 chip 在门限不满足时**是否仍可点**。
///
/// 抽成纯函数是为了让它可断言(`Element` 没有内省接口,画出来的禁用态测不到;
/// 见 `instances/tests/launch_args.rs`)。
pub(crate) fn backend_chip_enabled(allowed: bool, id: &str) -> bool {
allowed || id == BACKEND_DEFAULT
}
/// 三态后端:**版本不够时只灰掉 `opengl` / `vulkan`**,`default` 始终可点。
///
/// 为什么 `default` 不许一起灰:它是「不下发 `--graphicsBackend`」的安全档,写它永远不会被
/// core 的版本门限拒绝。一起灰掉的话,用户手改实例文件、或在 CLI 里给旧版本实例写下
/// `graphicsBackend: vulkan` 之后,UI 上就**回不到 `default`**——唯一的出路是「恢复跟随全局」,
/// 而那会连带清空 JVM / 游戏参数 / 内存 / Java 四项(M157 复审 P3)。
fn backend_row<'a>(
ui: &'a Ui,
facts: &'a LaunchFacts,
@@ -162,10 +181,11 @@ fn backend_row<'a>(
for id in &facts.options {
let label = texts.backend_label(id).unwrap_or(id.as_str());
let selected = form.backend == *id;
let spec = if allowed {
Chip::filter(label, selected, page(Message::Backend(id.clone())))
let spec = Chip::filter(label, selected, page(Message::Backend(id.clone())));
let spec = if backend_chip_enabled(allowed, id) {
spec
} else {
Chip::filter(label, selected, page(Message::Backend(id.clone()))).disabled()
spec.disabled()
};
chips = chips.push(chip(ui, spec));
}
@@ -4,6 +4,9 @@ use super::*;
/// M120 的文案守门(中文无英文词 / 切语言真的变 / 页面里不许写死英文案·白名单)。
mod i18n;
/// M157 的门限判据(三态 chips 在版本不够时谁可点)。
mod launch_args;
use super::super::layout::source;
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
@@ -0,0 +1,55 @@
//! 「启动参数」区(M157)的**门限**判据:三态 chips 在版本不够时谁可点。
//!
//! 为什么只测一个纯函数:iced 0.14 的 `Element` 没有内省接口,画出来的「禁用态」在测试里
//! 拿不到(`Chip::disabled` 只是把 `on_press` 清成 `None`)。所以把判据抽成
//! [`backend_chip_enabled`] 并在这里钉住——页面那一行必须调它(否则这条绿着也没意义,
//! 守门形状与 `instances/tests.rs` 的源码断言同一取舍)。
use super::super::launch_args::backend_chip_enabled;
use crate::pages::LaunchFacts;
/// 门限满足:三档全可点。
#[test]
fn every_backend_chip_is_clickable_when_the_gate_is_open() {
for id in ["default", "opengl", "vulkan"] {
assert!(backend_chip_enabled(true, id), "门限满足时 {id} 必须可点");
}
}
/// 门限不满足:只有 `default` 可点(它是「不下发」的安全档)。
///
/// 抓得住:把 `default` 一起灰掉(M157 复审 P3 的原状)——那时旧版本实例上写了
/// `graphicsBackend: vulkan` 的用户在 UI 里回不到 `default`,只能「恢复跟随全局」
/// (连带清空 JVM / 游戏参数 / 内存 / Java 四项)。
#[test]
fn the_default_chip_stays_clickable_when_the_gate_is_closed() {
assert!(
backend_chip_enabled(false, "default"),
"门限不满足时 default 也必须可点:它是唯一永远合法的档"
);
for id in ["opengl", "vulkan"] {
assert!(
!backend_chip_enabled(false, id),
"门限不满足时 {id} 必须灰掉(下发会被 core 拒绝)"
);
}
}
/// 词表与判据不许脱节:`default` 必须真在这份快照的 options 里,否则「安全档」无从点起。
#[test]
fn the_gate_rule_covers_the_demo_option_table() {
let facts = LaunchFacts {
options: ["default", "opengl", "vulkan"]
.iter()
.map(|id| (*id).to_string())
.collect(),
..LaunchFacts::default()
};
assert!(
facts
.options
.iter()
.any(|id| backend_chip_enabled(false, id)),
"门限关闭时至少有一档可点(否则三态整体锁死)"
);
}
+22
View File
@@ -93,12 +93,34 @@ pub enum Message {
Content(content::Message),
/// 实例页「启动参数」区的消息(**M157**):编辑写进页面状态,保存才 dispatch。
Instances(super::instances::InstanceMessage),
/// 自更新动作(**M159**):提示条与设置页「更新」区共用一支。
///
/// 为什么是顶层变体而不是又一个 `GroupMessage`:更新动作是**跨页的一次性意图**
/// (检查 / 下载安装 / 忽略 / 立即重启),不是某一组的内部状态;设置页的「更新」组也不持有
/// 任何字段(快照在 presenter 里)。与 M106 的 [`Self::Language`] 同一取舍,偏离已登记在
/// `pages/settings/update.rs` 的模块文档里。
Update(UpdateAction),
/// 账户页消息(**M114**:三轨登录 / 列表动作 / 表单输入;app 层翻成 `AppCommand`)。
///
/// 与 `Settings` 同一种做法:账户页自己的消息枚举在 `pages/account.rs`,这里只加一支入口。
Account(crate::pages::account::Message),
}
/// 自更新的用户动作(M159)。落点是 `app/update/update_action.rs`。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateAction {
/// 立即检查(手动;会清掉「忽略这个版本」)
CheckNow,
/// 下载 → 校验 → 替换(提示条与设置页共用一个动作)
Apply,
/// 忽略这个版本(本次会话不再弹提示条;设置页仍可手动装)
Dismiss,
/// 立即重启(新版本已就位,重启后生效)
RestartNow,
/// 开关「启动时检查更新」
SetAutoCheck(bool),
}
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
+22 -4
View File
@@ -108,6 +108,7 @@ pub mod frame;
pub mod harness;
pub mod layout;
pub mod model;
pub mod update;
pub mod account;
pub mod content;
@@ -130,15 +131,17 @@ pub(crate) use instances::demo_form as demo_launch_form;
pub use instances::{InstanceMessage, LaunchForm};
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
pub use message::{Action, Message};
pub use message::{Action, Message, UpdateAction};
pub use model::{Detail, Instances, LaunchFacts, Meta, Model, Row};
pub use screen::Screen;
pub use settings::Language;
pub use update::{Banner, Update, UpdateStatus};
use iced::widget::{column, container, row, space};
use iced::{Element, Length};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::spacing::Space;
use crate::theme::Ui;
@@ -222,14 +225,27 @@ fn render<'a>(
) -> Element<'a, Message> {
let metrics = layout::frame_metrics(model.screen.kit(), model.window_width);
match model.screen {
let page = match model.screen {
Screen::Instances => instances::view(ui, metrics, model, launch_form),
// M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。
Screen::Download => download::view(ui, metrics, &model.download, model.lang),
// M109:内容库页同样跟 `model.lang`
Screen::Content => content::view(ui, metrics, &model.content, model.lang),
Screen::Settings => settings::view(ui, metrics, states, language),
// M159:设置页「更新」组要画 presenter 的更新快照(不在 `States` 里,理由见该组文档)。
Screen::Settings => settings::view(ui, metrics, states, language, &model.update),
Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang),
};
// M159:自更新提示条挂在**每一页**的页头之上(有新版 / 正在下载 / 已就位时才出现)。
// 为什么在这里而不是外壳里:外壳(`shell`)只认窗口装饰与导航,页面层才能按快照决定画什么;
// 而「每一页都要能看见」这条要求又决定了它不能只挂在实例页上。
match update::banner(ui, &model.update, model.lang.catalog()) {
Some(banner) => column![banner, page]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.height(Length::Fill)
.into(),
None => page,
}
}
@@ -295,7 +311,9 @@ pub fn demo_model(screen: Screen, window_width: f32) -> Model {
Screen::Download => model.download = download::demo(unlucky_i18n::Lang::En.catalog()),
Screen::Content => model.content = content::demo(content::ContentKind::Mod, model.lang),
Screen::Logs => model.logs = logs::demo(model.lang),
Screen::Settings => {}
// M159:设置页「更新」组的出图夹具(一个可装的新版本 + 一次成功检查)。
// 只给这一页:其它页的基线图(尤其实例页的启动参数区)不该因为更新提示条而整体下移。
Screen::Settings => model.update = update::demo(),
}
model
}
+4
View File
@@ -21,6 +21,7 @@ use crate::app::components::interaction::Interaction;
use super::content::Content;
use super::logs::Logs;
use super::update::Update;
use super::{Download, Screen};
/// 实例列表的一行(`ListItem` 的槽位:overline = 运行状态,headline = 名字,supporting = 版本)。
@@ -116,6 +117,8 @@ pub struct Model {
pub logs: Logs,
/// 内容库页的数据(M109:检索条件 / 命中项 / 装入闸门,见 [`super::content`])。
pub content: Content,
/// 自更新的展示状态(M159:设置页「更新」区 + 顶部提示条,见 [`super::update`])。
pub update: Update,
/// 界面语言(M106):页面文案走 i18n key,语言由 app 层决定后灌进来。
///
/// 谁填:`app/state.rs` 的 `App::refresh_view`(`LangState::lang()` → `view::model(..)`),
@@ -141,6 +144,7 @@ impl Model {
logs: Logs::default(),
// 演示模型也从这个空壳起步(`demo_model` 再按页面填数据)
content: super::content::demo(unlucky_presenter::dto::ContentKind::Mod, Lang::En),
update: Update::default(),
lang: Lang::En,
forced: None,
}
+31 -4
View File
@@ -50,6 +50,12 @@
//! 字段、[`message::GroupMessage`] 加一个变体、`app/update/settings.rs` 加一条臂。**不需要**动 `frame.rs`、
//! 别的分组文件或 `pages/mod.rs`。
//!
//! **M159 的第五组(更新)是另一条形状**:它不持有组状态、也不走 `GroupMessage`,而是读
//! `settings::view` 的 `update` 形参(presenter 快照),由 [`UPDATE_GROUP`] 按下标分发——与 M106 的
//! 语言行同一取舍(那两样都不是「某一组的内部状态」)。所以加它只动了 ① `pages/settings/update.rs`、
//! ② 本文件的 [`GROUPS`] / [`GROUP_LABELS`] / `render` 的形参、③ `pages/mod.rs` 与 `pages/model.rs`
//! (模型多一格 `update`),**没有**碰 `state::Groups` 与 `message::GroupMessage`。
//!
//! ## 消息:一个投递口,四条线各写各的(M101 `SETTINGSMSG`)
//!
//! 四条填页线都要往 `pages::Message` 里加变体,而那是**一个**枚举加上
@@ -135,6 +141,7 @@ pub(crate) mod java;
pub(crate) mod language_appearance;
mod message;
mod state;
pub(crate) mod update;
#[cfg(test)]
mod tests;
@@ -154,6 +161,7 @@ use unlucky_i18n::{Lang, LangChoice};
use crate::theme::Ui;
use super::layout::{FrameMetrics, PageLayout};
use super::update::Update;
use super::Message;
/// 设置页 General 分组要的两件(M106):用户选的档位 + 这一次生效的语言。
@@ -207,11 +215,14 @@ struct Group {
/// 分组注册表:**顺序 = 左列自上而下**,也是 `crate::pages::appearance::APPEARANCE_GROUP` 的下标来源。
///
/// 每一项都指向它自己文件里的 `GROUP`(M99 之前是四份字面量挤在本文件里)。
const GROUPS: [Group; 4] = [
const GROUPS: [Group; 5] = [
general::GROUP,
appearance::GROUP,
java::GROUP,
download::GROUP,
// M159:自更新只**追加**在末尾(插在中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP` 两个下标
// 全部失效,`tests/mod.rs` 的「左列顺序」那条也会红)。
update::GROUP,
];
/// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。
@@ -230,13 +241,21 @@ const GENERAL_GROUP: usize = 0;
///
/// 为什么不放进 [`Group`]:那会让另外三组的 `GROUP` 字面量(各自文件、各自填页线)编译不过——
/// 与 [`GENERAL_GROUP`] 同一条理由。`Group.name` 因此退化为**id**(测试按它认组;显示走这张表)。
pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 4] = [
pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 5] = [
unlucky_i18n::Key::SettingsGroupGeneral,
unlucky_i18n::Key::SettingsGroupAppearance,
unlucky_i18n::Key::SettingsGroupJava,
unlucky_i18n::Key::SettingsGroupDownloads,
// M159
unlucky_i18n::Key::SettingsGroupUpdate,
];
/// 「更新」分组在 [`GROUPS`] 里的下标(真源在 `settings/update.rs`,这里只转发)。
///
/// 与 [`GENERAL_GROUP`] 同一种做法:这一组要画的是 **presenter 快照**(不在 [`States`] 里),
/// 所以由 `view` 的 `update` 形参一路递到下标的 [`form`] 分发点,`GROUP.body` 保持 `None`。
const UPDATE_GROUP: usize = update::UPDATE_GROUP;
/// 整页。
///
/// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态
@@ -246,6 +265,7 @@ pub fn view<'a>(
metrics: FrameMetrics,
states: States<'a>,
language: Language,
update: &'a Update,
) -> Element<'a, Message> {
// 选中分组:有真实外观状态时看状态(点左列可切),否则退回演示值 0。
let selected = states
@@ -256,9 +276,12 @@ pub fn view<'a>(
PageLayout::SupportingPane => frame::panes(
metrics,
frame::group_nav(ui, selected, language.effective),
frame::left_aligned(metrics.column, form(ui, Some(selected), states, language)),
frame::left_aligned(
metrics.column,
form(ui, Some(selected), states, language, update),
),
),
_ => frame::single_column(metrics, form(ui, None, states, language)),
_ => frame::single_column(metrics, form(ui, None, states, language, update)),
};
frame::page(ui, metrics, frame::header(ui, language.effective), body)
@@ -277,6 +300,7 @@ fn form<'a>(
only: Option<usize>,
states: States<'a>,
language: Language,
update: &'a Update,
) -> Element<'a, Message> {
let catalog = language.effective.catalog();
let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill);
@@ -294,6 +318,9 @@ fn form<'a>(
// 无关(harness 那条路径也画得出真控件)。
let card = if index == GENERAL_GROUP {
general::body(ui, language)
} else if index == UPDATE_GROUP {
// M159:更新组读 presenter 快照(`view` 的 `update` 形参),与 General 同一条按下标分发。
update::body(ui, update, language.effective)
} else {
group
.body
@@ -40,6 +40,7 @@ mod appearance_section;
mod download_section;
mod general_section;
mod java_section;
mod update_section;
use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH};
use super::*;
@@ -128,7 +129,10 @@ fn the_appearance_group_is_where_the_module_says_it_is() {
#[test]
fn the_split_kept_the_left_column_order() {
let names: Vec<&str> = GROUPS.iter().map(|group| group.name).collect();
assert_eq!(names, vec!["General", "Appearance", "Java", "Downloads"]);
assert_eq!(
names,
vec!["General", "Appearance", "Java", "Downloads", "Updates"]
);
}
/// M101 r2:`settings/mod.rs` 里**不许出现任何一组的 `State` 类型**(r1 复审 P1 的判据钉在这里)。
@@ -193,6 +197,6 @@ fn the_settings_module_does_not_name_any_group_state() {
fn every_line_can_be_wired_alone() {
// 判据是「每条线只改自己的文件」,不是任何一条具体断言:这里只钉住架子本身的两件事——
// ① 每组都有 `GROUP`(成员齐);② 演示下标合法。真正的 per-group 断言在各自文件里。
assert_eq!(GROUPS.len(), 4, "四条线各一个分组");
assert_eq!(GROUPS.len(), 5, "四条填页线 + M159 的更新组");
assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内");
}
@@ -0,0 +1,123 @@
//! 设置页「更新」分组(M159)的断言:注册表位置、四条状态各有各的句子。
//!
//! 为什么只测「词表 + 判据」而不测控件:iced 的 `Element` 没有内省接口(`tests/mod.rs` 顶部那条
//! 写过理由),上屏文本只能从**取词的那一个入口**取。这里守的是「四种结论的句子不是同一句」
//! ——正是 mission 里「检查失败不等于已是最新」那条硬要求的最小可测形态。
use unlucky_i18n::{Catalog, Key, Lang};
use super::super::update::{GROUP as UPDATE_GROUP_META, UPDATE_GROUP};
use super::super::{GROUPS, SELECTED};
use crate::pages::update::UpdateStatus;
use crate::pages::{Screen, Update};
/// 更新组**必须是最后一组**:加组只追加不重排,插到中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP`
/// 两个下标一起失效。
#[test]
fn the_update_group_sits_at_the_end_of_the_registry() {
assert_eq!(GROUPS.len(), UPDATE_GROUP + 1, "更新组之后不该还有别的组");
assert_eq!(GROUPS[UPDATE_GROUP].name, UPDATE_GROUP_META.name);
assert_eq!(GROUPS[UPDATE_GROUP].name, "Updates");
assert!(SELECTED < GROUPS.len());
}
/// 四种结论的句子**互不相同**,且「失败」那一句必须把原因带出来。
///
/// 抓得住:把「检查失败」写成「已是最新」(那是 mission 明令禁止的撒谎:断网时会说「你是最新」)。
/// 抓不住:句子翻得顺不顺、颜色对不对(那是人读与截图的事)。
#[test]
fn the_four_outcomes_read_differently() {
let catalog = Lang::ZhCn.catalog();
let up_to_date = catalog.render(Key::SettingsUpdateUpToDate, &[("version", "0.2.2")]);
let available = catalog.render(
Key::SettingsUpdateAvailable,
&[("version", "0.2.3"), ("size", "21.0 MB")],
);
let missing = catalog.render(Key::SettingsUpdateMissingArtifact, &[("version", "0.2.3")]);
let failed = catalog.render(
Key::SettingsUpdateFailed,
&[("reason", "取更新清单失败:连接被重置")],
);
let all = [&up_to_date, &available, &missing, &failed];
for (index, left) in all.iter().enumerate() {
for right in all.iter().skip(index + 1) {
assert_ne!(left, right, "两条结论说的是同一句话:{left} / {right}");
}
}
assert!(
failed.contains("连接被重置"),
"失败那一句必须带出原因,实际:{failed}"
);
assert!(
!failed.contains("0.2.2"),
"失败那一句不该冒出当前版本号(读起来像「已是最新」):{failed}"
);
}
/// 页面视图模型与 presenter 的判据同序:就位 > 进行中 > 失败(仅主动查) > 有新版本。
///
/// 两处各算一次(presenter 判据 + 页面视图),顺序必须一致,否则同一份状态在提示条与设置页上
/// 会各说一套。这里只钉页面这一侧的顺序;presenter 那一侧在 `presenter` 的 `dto::update` 单测里。
#[test]
fn the_banner_priority_is_ready_then_applying_then_failure_then_available() {
let mut update = Update {
current_version: "0.2.2".to_string(),
channel: "stable".to_string(),
auto_check: true,
status: UpdateStatus::Failed {
reason: "连不上".to_string(),
retryable: true,
},
checked_at: String::new(),
applying: true,
ready: None,
dismissed: false,
silent: false,
};
assert!(matches!(
update.banner(),
Some(crate::pages::Banner::Applying)
));
update.applying = false;
assert!(matches!(
update.banner(),
Some(crate::pages::Banner::Failed { .. })
));
update.status = UpdateStatus::Available {
version: "0.2.3".to_string(),
size: 1024,
notes: String::new(),
forced: false,
};
assert!(matches!(
update.banner(),
Some(crate::pages::Banner::Available { .. })
));
update.ready = Some("0.2.3".to_string());
assert!(matches!(
update.banner(),
Some(crate::pages::Banner::Ready { .. })
));
}
/// 演示模型里更新组能画(`pages_demo` 的 harness 路径):`Model.update` 有默认值,不是 `None` 才画。
#[test]
fn the_demo_model_carries_an_update_snapshot() {
let model = crate::pages::demo_model(Screen::Settings, 1180.0);
assert_eq!(model.update, crate::pages::update::demo());
assert!(
model.update.available().is_some(),
"演示快照要带一个可装的版本"
);
// 其它页不掺演示更新状态(实例页/下载页的基线图不该多出一条提示条)。
assert_eq!(
crate::pages::demo_model(Screen::Instances, 1180.0).update,
Update::default()
);
let catalog: Catalog = Lang::En.catalog();
assert!(!catalog.text(Key::SettingsGroupUpdate).is_empty());
}
@@ -0,0 +1,257 @@
//! 设置页「更新」分组(M159):当前版本 / 渠道 / 自动检查开关 / 立即检查 / 上次结果。
//!
//! ## 为什么这一组不走 `Group.body`
//!
//! `Group.body` 的签名是 `fn(&Ui, States) -> Option<Element>`,而 `States` 只有「外观状态 + 各组
//! 自己的状态」两格。更新状态是 **presenter 快照**(不是某一组的字段),与 M106 的语言行同一种
//! 情形:`settings/mod.rs` 按下标([`UPDATE_GROUP`])把 `settings::view` 的 `update` 形参交给
//! [`body`],`GROUP.body` 仍是 `None`。
//!
//! 消息也**不**走 `GroupMessage`:更新动作是「跨页的一次性意图」(检查 / 下载安装 / 忽略 /
//! 重启),落点是 `app/update/update_action.rs`,`pages::Message::Update(UpdateAction)` 一支就够。
//! 偏离已登记在本文件与 mission 报告里(与 M106 的 `Message::Language` 同一取舍:这两个都不是
//! 「某一组的内部状态」,折进 `GroupMessage` 只会多一层没人用的转运)。
//!
//! ## 四条状态行必须分开
//!
//! 「已是最新」「有新版本」「没有本平台产物」「检查失败」是四种不同的结论(mission 硬要求:
//! 失败不许说成最新)。第四种一定要把原因摆出来(连不上 / 清单坏了 / 渠道不符),
//! 否则用户只会看到一句「检查失败」而无从下手。
use iced::widget::{column, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::human_bytes;
use crate::app::components::buttons::{self, Button};
use crate::app::components::selection::{chip, Chip};
use crate::pages::frame::text_role_owned;
use crate::pages::update::{Update, UpdateStatus};
use crate::pages::Message;
use crate::pages::UpdateAction;
use crate::theme::Ui;
use super::frame::{card, divider};
use super::Group;
/// 「更新」分组在 [`super::GROUPS`] 里的下标(**必须是最后一个**:加组只追加不重排)。
pub(super) const UPDATE_GROUP: usize = 4;
/// 更新分组(左列名固定英文:左列是导航,与导航轨同源,不进 catalog)。
pub(super) const GROUP: Group = Group {
name: "Updates",
icon: IconKind::Download,
// 只给 harness / 窄窗「按组堆叠」路径用的演示行:产品路径走 [`body`]。
rows: &[
("Current version", "0.2.2"),
("Channel", "Stable"),
("Check for updates on startup", "On"),
],
body: None,
};
/// 渲染整组正文。
pub(super) fn body<'a>(ui: &'a Ui, update: &'a Update, lang: Lang) -> Element<'a, Message> {
let catalog = lang.catalog();
let mut stacked = column![].spacing(0.0).width(Length::Fill);
stacked = stacked.push(info_row(
ui,
catalog.text(Key::SettingsUpdateCurrent),
update.current_version.clone(),
));
stacked = stacked.push(divider(ui));
stacked = stacked.push(info_row(
ui,
catalog.text(Key::SettingsUpdateChannel),
catalog.text(Key::SettingsUpdateChannelStable).to_string(),
));
stacked = stacked.push(divider(ui));
stacked = stacked.push(auto_check_row(ui, catalog, update.auto_check));
stacked = stacked.push(divider(ui));
stacked = stacked.push(status_block(ui, catalog, update));
card(ui, stacked)
}
/// 「标签 + 值」一行(与 `frame::setting_row` 同一套排版,但值要在调用处现算)。
fn info_row<'a>(ui: &'a Ui, label: &'a str, value: String) -> Element<'a, Message> {
let height = ui.metrics().control_height + Space::X2.dp();
row![
iced::widget::container(text_role_owned(
ui,
TypeRole::BodyMedium,
false,
label.to_string(),
ui.color(Role::OnSurface),
))
.width(Length::Fill)
.align_y(Alignment::Center),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
value,
ui.color(Role::OnSurfaceVariant),
),
]
.height(height)
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
/// 自动检查开关 + 「立即检查」按钮。
fn auto_check_row<'a>(ui: &'a Ui, catalog: Catalog, auto_check: bool) -> Element<'a, Message> {
row![
chip(
ui,
Chip::filter(
catalog.text(Key::SettingsUpdateAutoCheck),
auto_check,
Message::Update(UpdateAction::SetAutoCheck(!auto_check)),
),
),
buttons::text(
ui,
Button::new(
catalog.text(Key::SettingsUpdateCheckNow),
Message::Update(UpdateAction::CheckNow),
),
),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_y(Alignment::Center)
.padding(iced::Padding::from([0.0_f32, 0.0]))
.into()
}
/// 上次检查时间 + 结论 + (有候选时)下载按钮 + 说明摘要 + 一句校验提示。
fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element<'a, Message> {
let mut block = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
let checked = if update.checked_at.trim().is_empty() {
catalog.text(Key::SettingsUpdateNever).to_string()
} else {
update.checked_at.clone()
};
block = block.push(info_row(
ui,
catalog.text(Key::SettingsUpdateLastChecked),
checked,
));
let (text, error) = status_line(catalog, update);
let color = ui.color(if error {
Role::Error
} else {
Role::OnSurfaceVariant
});
block = block.push(text_role_owned(ui, TypeRole::BodySmall, false, text, color));
if let Some((_version, _size, notes, forced)) = update.available() {
let mut actions = row![buttons::filled(
ui,
Button::new(
catalog.text(Key::SettingsUpdateApply),
Message::Update(UpdateAction::Apply),
),
)]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
if forced {
actions = actions.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::SettingsUpdateForced).to_string(),
ui.color(Role::Error),
));
}
block = block.push(actions);
if !notes.trim().is_empty() {
block = block.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(Key::SettingsUpdateNotes, &[("notes", notes.trim())]),
ui.color(Role::OnSurfaceVariant),
));
}
}
if let Some(version) = &update.ready {
block = block.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(Key::SettingsUpdateReady, &[("version", version)]),
ui.color(Role::OnSurface),
));
block = block.push(buttons::tonal(
ui,
Button::new(
catalog.text(Key::UpdateBannerRestartNow),
Message::Update(UpdateAction::RestartNow),
),
));
}
block = block.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::SettingsUpdateHint).to_string(),
ui.color(Role::OnSurfaceVariant),
));
block.into()
}
/// 一条状态行(文案 + 是不是「错」那一类)。
///
/// 四种结论各写各的句子,**不共用**「已是最新」那句:失败/缺产物时那句就是撒谎。
fn status_line(catalog: Catalog, update: &Update) -> (String, bool) {
let (key, args, error) = match &update.status {
UpdateStatus::Idle => (Key::SettingsUpdateNever, Vec::new(), false),
UpdateStatus::Checking => (Key::SettingsUpdateChecking, Vec::new(), false),
UpdateStatus::UpToDate { latest } => {
let version = latest
.clone()
.unwrap_or_else(|| update.current_version.clone());
(
Key::SettingsUpdateUpToDate,
vec![("version", version)],
false,
)
}
UpdateStatus::Available { version, size, .. } => (
Key::SettingsUpdateAvailable,
vec![("version", version.clone()), ("size", human_bytes(*size))],
false,
),
UpdateStatus::MissingArtifact { version } => (
Key::SettingsUpdateMissingArtifact,
vec![("version", version.clone())],
true,
),
UpdateStatus::Failed { reason, .. } => (
Key::SettingsUpdateFailed,
vec![("reason", reason.clone())],
true,
),
};
let args: Vec<(&str, &str)> = args
.iter()
.map(|(name, value)| (*name, value.as_str()))
.collect();
(catalog.render(key, &args), error)
}
+379
View File
@@ -0,0 +1,379 @@
//! 自更新(M159)的**页面视图模型**:只放「要画什么」。
//!
//! 与 `presenter::dto::UpdateView` 的分工和本目录其它页一致(见 `pages/model.rs` 模块文档):
//! DTO 是 IPC 契约(含 core 语义字段),这里是纯展示字段,于是页面可以用演示数据喂同一个渲染函数。
//! 转换点只有一个:`app/view/update.rs`。
//!
//! ## 为什么状态是「一个有载荷的枚举」
//!
//! 「检查失败」与「已是最新」必须是两个变体(mission 硬要求):把失败折叠成
//! `UpToDate` 会让界面在断网时说「已是最新」,那是撒谎。`MissingArtifact`(有新版但没本平台产物)
//! 同理——它既不是「有新版本可装」,也不是「已是最新」。
/// 一次检查的结论(页面视图)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum UpdateStatus {
/// 还没查过
#[default]
Idle,
/// 正在查
Checking,
/// 已是最新
UpToDate {
/// 该渠道内的最高版本(`None` = 还没有任何发布)
latest: Option<String>,
},
/// 有可装的更新
Available {
/// 新版本号
version: String,
/// 包体字节数
size: u64,
/// 更新说明(可能为空)
notes: String,
/// 是否强制
forced: bool,
},
/// 有更高版本,但没有本平台的产物
MissingArtifact {
/// 那个版本
version: String,
},
/// 检查/应用失败(**不是**已是最新)
Failed {
/// 人话原因
reason: String,
/// 换个时间再试是否可能变好
retryable: bool,
},
}
/// 顶部提示条此刻的形态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Banner<'a> {
/// 有新版本
Available {
/// 新版本号
version: &'a str,
/// 包体字节数
size: u64,
/// 说明摘要
notes: &'a str,
},
/// 正在下载/校验
Applying,
/// 已就位,等重启
Ready {
/// 新版本号
version: &'a str,
},
/// 失败(只在用户主动查之后出现)
Failed {
/// 人话原因
reason: &'a str,
/// 是否可重试
retryable: bool,
},
}
/// 更新页 / 提示条的全部展示状态。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Update {
/// 当前版本
pub current_version: String,
/// 渠道(`stable`)
pub channel: String,
/// 启动时是否静默检查
pub auto_check: bool,
/// 检查结论
pub status: UpdateStatus,
/// 上次检查时间(空串 = 没查过)
pub checked_at: String,
/// 正在下载/校验/替换
pub applying: bool,
/// 已就位的新版本号(`None` = 没有)
pub ready: Option<String>,
/// 用户是否已忽略这个版本(本次会话)
pub dismissed: bool,
/// 这一份结论来自静默检查(启动时/自动):失败时**不**弹提示条
pub silent: bool,
}
impl Update {
/// 有没有可装的候选(有的话设置页给「下载并安装」)。
#[must_use]
pub fn available(&self) -> Option<(&str, u64, &str, bool)> {
match &self.status {
UpdateStatus::Available {
version,
size,
notes,
forced,
} => Some((version, *size, notes, *forced)),
_ => None,
}
}
/// 提示条此刻要显示什么(`None` = 不显示)。
///
/// 优先级与 presenter 侧同一条(就位 > 进行中 > 失败(仅主动查) > 有新版本(未被忽略)),
/// 这里再算一次是因为页面层拿不到 DTO 的那份判据——两处判据的顺序必须一致,
/// `pages/tests.rs` 有对拍用例守着。
#[must_use]
pub fn banner(&self) -> Option<Banner<'_>> {
if let Some(version) = &self.ready {
return Some(Banner::Ready { version });
}
if self.applying {
return Some(Banner::Applying);
}
if let UpdateStatus::Failed { reason, retryable } = &self.status {
return (!self.silent).then_some(Banner::Failed {
reason,
retryable: *retryable,
});
}
if self.dismissed {
return None;
}
match &self.status {
UpdateStatus::Available {
version,
size,
notes,
..
} => Some(Banner::Available {
version,
size: *size,
notes,
}),
_ => None,
}
}
}
/// 出图 / 单测用的演示快照(一个可装的新版本 + 一次成功的检查)。
///
/// **不是产品数据**:产品路径的更新状态只来自 presenter(`app/view/update.rs` 转换)。
#[must_use]
pub fn demo() -> Update {
Update {
current_version: "0.2.2".to_string(),
channel: "stable".to_string(),
auto_check: true,
status: UpdateStatus::Available {
version: "0.2.3".to_string(),
size: 21_500_000,
notes: "修掉启动崩溃;实例页加载更快".to_string(),
forced: false,
},
checked_at: "11-08 12:00".to_string(),
applying: false,
ready: None,
dismissed: false,
silent: false,
}
}
/// 「有新版本」那条提示条的正文:**体积与更新说明摘要都要给**(mission 明列的三件之二)。
///
/// 拆成函数是为了可断言(`Element` 没有内省接口):条数与措辞由 `tests` 的两条用例钉住。
#[must_use]
pub fn available_body(catalog: Catalog, size: u64, notes: &str) -> String {
let size = human_bytes(size);
let body = catalog.render(Key::UpdateBannerBodyAvailable, &[("size", &size)]);
if notes.trim().is_empty() {
body
} else {
format!("{body} · {}", notes.trim())
}
}
// --------------------------------------------------------------- 提示条
use iced::widget::{column, container, row};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::human_bytes;
use crate::app::components::buttons::{self, Button};
use crate::pages::frame::text_role_owned;
use crate::pages::message::UpdateAction;
use crate::pages::Message;
use crate::theme::Ui;
/// 顶部提示条(每一页都在页头之上;`None` = 现在没有要说的)。
///
/// 形态三种:有新版本(可下载 / 可忽略)、正在下载、已就位(可立即重启)。**失败不在这里**
/// ——静默检查失败是常态,弹一条红条等于每次开机先骂网络一次;失败原因在设置页「更新」区看。
pub fn banner<'a>(
ui: &'a Ui,
update: &'a Update,
catalog: Catalog,
) -> Option<Element<'a, Message>> {
let kind = update.banner()?;
// 三种形态各自的底色:有新版 = 次级容器(可操作、非错误);进行中 = 同色但只读;
// 就位 = 主容器(「已经好了,重启即生效」是正反馈)。
let (title, body, actions, role): (String, String, Vec<Element<'a, Message>>, Role) = match kind
{
Banner::Available {
version,
size,
notes,
} => {
let mut actions = vec![
buttons::filled(
ui,
Button::new(
catalog.text(Key::UpdateBannerInstall),
Message::Update(UpdateAction::Apply),
),
),
buttons::text(
ui,
Button::new(
catalog.text(Key::UpdateBannerDismiss),
Message::Update(UpdateAction::Dismiss),
),
),
];
let _ = &mut actions;
(
catalog.render(Key::UpdateBannerTitleAvailable, &[("version", version)]),
available_body(catalog, size, notes),
actions,
Role::SecondaryContainer,
)
}
Banner::Applying => (
catalog.text(Key::UpdateBannerApplying).to_string(),
String::new(),
Vec::new(),
Role::SecondaryContainer,
),
Banner::Ready { version } => (
catalog.text(Key::UpdateBannerTitleReady).to_string(),
catalog.render(Key::UpdateBannerBodyReady, &[("version", version)]),
vec![buttons::filled(
ui,
Button::new(
catalog.text(Key::UpdateBannerRestartNow),
Message::Update(UpdateAction::RestartNow),
),
)],
Role::PrimaryContainer,
),
Banner::Failed { reason, .. } => (
catalog.text(Key::UpdateBannerTitleFailed).to_string(),
reason.to_string(),
vec![buttons::text(
ui,
Button::new(
catalog.text(Key::UpdateBannerRetry),
Message::Update(UpdateAction::CheckNow),
),
)],
Role::ErrorContainer,
),
};
let on_container = match role {
Role::PrimaryContainer => Role::OnPrimaryContainer,
Role::ErrorContainer => Role::OnErrorContainer,
_ => Role::OnSecondaryContainer,
};
let mut text_column = column![text_role_owned(
ui,
TypeRole::LabelLarge,
true,
title,
ui.color(on_container),
)]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if !body.is_empty() {
text_column = text_column.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
body,
ui.color(on_container),
));
}
let mut line = row![text_column]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_y(Alignment::Center);
for action in actions {
line = line.push(action);
}
let padding = Padding::from([ui.space(Space::X3), ui.space(Space::X4)]);
Some(
container(line)
.width(Length::Fill)
.padding(padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(role))),
border: Border {
radius: ui.radius(ShapeScale::Medium, 0.0),
..Border::default()
},
..container::Style::default()
})
.into(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_i18n::Lang;
/// 提示条的正文**同时**给体积与说明摘要(mission:提示条含版本号、体积、说明摘要)。
#[test]
fn the_available_banner_carries_both_size_and_notes() {
for lang in [Lang::En, Lang::ZhCn] {
let body = available_body(lang.catalog(), 21_500_000, "修掉启动崩溃");
assert!(body.contains("20.5 MB"), "{lang:?} 缺体积:{body}");
assert!(body.contains("修掉启动崩溃"), "{lang:?} 缺说明:{body}");
}
// 发布没写说明时只留体积,不留一个空尾巴。
let bare = available_body(Lang::En.catalog(), 1024, " ");
assert!(bare.contains("1.0 KB"), "{bare}");
assert!(!bare.contains('·'), "没有说明时不该留分隔符:{bare}");
}
/// 视图模型的提示条判据与 presenter 同序(就位 > 进行中 > 失败 > 新版本)。
#[test]
fn the_banner_priority_matches_the_presenter() {
let mut update = Update {
status: UpdateStatus::Available {
version: "0.2.3".to_string(),
size: 1,
notes: String::new(),
forced: false,
},
..Update::default()
};
assert!(matches!(update.banner(), Some(Banner::Available { .. })));
update.dismissed = true;
assert!(update.banner().is_none(), "拒绝之后不再打扰");
update.dismissed = false;
update.applying = true;
assert!(matches!(update.banner(), Some(Banner::Applying)));
update.ready = Some("0.2.3".to_string());
assert!(matches!(update.banner(), Some(Banner::Ready { .. })));
}
}
+20
View File
@@ -42,6 +42,8 @@ pub const fn general() -> &'static str {
agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP;
[--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读)
agent config [--port N] 打印 Claude Code / Cursor 的配置样例
update check 查一次更新(只读:不动任何文件;失败退出码 1)
update apply [--dest <path>] 下载 → SHA256 校验 → 替换(缺省替换本进程 exe)
version 版本 + 系统摘要(同 --version)
help [命令] 本帮助
@@ -111,6 +113,24 @@ pub fn topic(name: &str) -> Option<&'static str> {
退出码:0 成功 / 3 部分成功 / 1 失败(2 内部错误)。
--json:信封恒为 {\"ok\":true,\"data\":{…}},分流看 data.status 与 data.error.code;
data.stages 列出实际走过的阶段。",
),
"update" => Some(
"%prog% update check
%prog% update apply [--dest <path>]
自更新(D50:GitHub Releases 固定清单地址,先只发 stable)。
check 查一次更新,只读:报告当前版本、渠道、有没有新版(版本号/体积/说明摘要)。
四种结论分开报,不折叠:up_to_date / available / missing_artifact(有新版但没本平台
产物,属发布侧漏项)/ 失败(退出码 1,code=update_check_failed)。
apply 先复查一次(清单只有几 KB),有新版才下载 → 长度 + SHA256 双检 → 原子替换。
校验不符或替换被占用时**不会**留下暂存文件、也不会破坏现有 exe(自动回滚)。
缺省替换本进程的 exe:**下次启动生效**(正在跑的这一份仍是旧版本)。
--dest 钉到别的路径(取证 / 自定义安装位置)。不写 = 本进程 exe。
环境变量 ULL_UPDATE_DIR=<dir>:改走本地目录源(`<dir>/stable.json` + 产物),
用于内网手工分发与「发现新版本」这条分支的整环取证;不设就走 GitHub。
--json 输出 {\"data\":{\"status\":…,\"current\":…,\"version\":…}},脚本按 status 分流。",
),
"install" => Some(
"\
+1 -1
View File
@@ -14,7 +14,7 @@ pub mod program;
pub use model::{
AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs,
DiagArgs, Globals, InstallArgs, InstanceLaunchArgs, Invocation, LaunchArgs, LogsArgs,
PackImportArgs,
PackImportArgs, UpdateApplyArgs, UpdateCheckArgs,
};
pub use parse::parse;
+15
View File
@@ -69,6 +69,10 @@ pub enum Command {
},
/// `agent serve`:对外 MCP server(长驻)。
AgentServe(AgentServeArgs),
/// `update check`:查一次更新,不动任何文件。
UpdateCheck(UpdateCheckArgs),
/// `update apply [--dest <path>]`:下载 → 校验 → 替换(默认替换本进程 exe)。
UpdateApply(UpdateApplyArgs),
/// `agent config`:打印 Claude Code / Cursor 的客户端配置样例。
AgentConfig {
/// 覆盖端点端口(默认取设置键 `agent.mcp.port`)。
@@ -76,6 +80,17 @@ pub enum Command {
},
}
/// `update check` 的参数(现在没有选项,留一个结构体以免将来加选项时改签名)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UpdateCheckArgs {}
/// `update apply` 的参数。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UpdateApplyArgs {
/// 替换目标(缺省 = 本进程的 exe;取证/自定义安装位置时才给)。
pub dest: Option<PathBuf>,
}
/// `agent serve` 的参数(命令行只做覆盖,默认值来自设置键 `agent.mcp.*`)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AgentServeArgs {
+27 -1
View File
@@ -13,7 +13,7 @@ mod instances;
use super::model::{
Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs,
LogsArgs, PackImportArgs,
LogsArgs, PackImportArgs, UpdateApplyArgs, UpdateCheckArgs,
};
/// `logs` 缺省尾巴行数。
@@ -93,6 +93,7 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
"java" => parse_java(tail),
"accounts" => accounts::parse_accounts(tail),
"agent" => agent::parse_agent(tail),
"update" => parse_update(tail),
other => {
Err(CliError::usage(format!("未知命令 `{other}`"))
.with_hint("%prog% --help 查看命令列表"))
@@ -241,6 +242,31 @@ fn parse_config(tail: &[String]) -> Result<Command, CliError> {
}
}
/// `update check` / `update apply [--dest <path>]`。
fn parse_update(tail: &[String]) -> Result<Command, CliError> {
let Some(sub) = tail.first().map(String::as_str) else {
return Err(CliError::usage("update 需要子命令 check|apply")
.with_hint("%prog% update check / %prog% update apply"));
};
let rest = tail.get(1..).unwrap_or_default();
match sub {
"check" => {
ensure_no_extra(rest, "update check")?;
Ok(Command::UpdateCheck(UpdateCheckArgs {}))
}
"apply" => {
let parsed = parse_flags(rest, &["dest"], &[])?;
ensure_no_positionals(&parsed, "update apply")?;
Ok(Command::UpdateApply(UpdateApplyArgs {
dest: parsed.value("dest").map(PathBuf::from),
}))
}
other => Err(CliError::usage(format!(
"update 的子命令只有 check|apply,收到 `{other}`"
))),
}
}
fn parse_java(tail: &[String]) -> Result<Command, CliError> {
match tail.first().map(String::as_str) {
None | Some("list") => {
+7
View File
@@ -15,6 +15,7 @@ pub mod meta;
pub mod pack;
pub mod sessions;
pub mod snapshot;
pub mod update;
use crate::cli::{Command, Globals, Invocation};
use crate::context::Ctx;
@@ -57,6 +58,12 @@ pub fn run(invocation: &Invocation) -> Result<Outcome, CliError> {
java::install(ctx, *major, *vendor)
}),
Command::JavaVendors => with_ctx(&invocation.globals, java::vendors),
Command::UpdateCheck(args) => {
with_ctx_outcome(&invocation.globals, |ctx| update::check(ctx, args))
}
Command::UpdateApply(args) => {
with_ctx_outcome(&invocation.globals, |ctx| update::apply(ctx, args))
}
Command::AccountsList => with_ctx(&invocation.globals, accounts::list),
Command::AccountsAdd(args) => with_ctx(&invocation.globals, |ctx| accounts::add(ctx, args)),
Command::AccountsRemove { name } => {
+203
View File
@@ -0,0 +1,203 @@
//! `ull update check` / `ull update apply`(M159):**复用 core 的同一内核**。
//!
//! 两条命令都只做「解析参数 → 造一个 [`Job`] → 调 `check` / `apply` → 排版输出」,
//! 版本比较、渠道准入、清单校验、长度/SHA256 双检、原子替换全在 `core::update`
//! (GUI 走的是同一个 `Job`,见 `crates/presenter/src/host/update.rs`)。
//!
//! 三条不许折叠的边界:
//!
//! | 情形 | 输出 |
//! |---|---|
//! | 已是最新 | `status=up_to_date`(退出码 0) |
//! | 有可装的更新 | `status=available`(退出码 0;`apply` 才动文件) |
//! | 检查失败 | **错误**(退出码 1,`code=update_check_failed`)——绝不说成「已是最新」 |
//!
//! 还有一条单独报的:`missing_artifact`(有更高版本但里面没有本平台的产物)。那是发布侧的漏项,
//! 同样不许说成「已是最新」。
//!
//! `apply` 默认替换**本进程的 exe**(`Job::for_process`);`--dest` 可以钉到别的路径(取证用)。
use std::path::PathBuf;
use std::sync::Arc;
use serde_json::json;
use unlucky_core::update::{default_source, Applied, CheckOutcome, Job, UpdateError, Version};
use crate::cli::{UpdateApplyArgs, UpdateCheckArgs};
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::{Outcome, Report};
/// D50:先只发 stable。
const CHANNEL: unlucky_core::update::Channel = unlucky_core::update::Channel::Stable;
/// 本进程版本(与 `ull --version` 同源)。
fn current_version() -> Result<Version, CliError> {
let text = env!("CARGO_PKG_VERSION");
Version::parse(text)
.map_err(|error| CliError::internal("bad_version", format!("编译期版本号不合法:{error}")))
}
/// 造一个内核 job(源 = GitHub / `ULL_UPDATE_DIR`,暂存 = 数据根缓存,目标 = 本进程 exe)。
fn job(ctx: &Ctx, dest: Option<PathBuf>) -> Job {
let engine = super::install::build_engine(ctx);
let job = Job::for_process(default_source(Arc::new(engine)), &ctx.layout().cache());
match dest {
Some(dest) => job.with_dest(dest),
None => job,
}
}
/// 失败 → CLI 错误(保留 core 的稳定分类 id 在 code 里,脚本可分流)。
fn failure(code: &'static str, action: &str, error: &UpdateError) -> CliError {
let hint = if error.is_retryable() {
"网络/占用类失败,过一会儿重试一次通常就好"
} else {
"这不是瞬时失败:清单或版本号本身有问题,等发布侧修"
};
CliError::user(
code,
format!("{action}失败({}):{error}", error.kind.as_str()),
)
.with_hint(hint)
}
/// `ull update check`:只查、不动任何文件。
///
/// # Errors
///
/// 清单取不到 / 清单不合法 / 渠道不符 → 用户错误(退出码 1)。
pub fn check(ctx: &Ctx, _args: &UpdateCheckArgs) -> Result<Outcome, CliError> {
let current = current_version()?;
let job = job(ctx, None);
let outcome = job
.check(&current, CHANNEL)
.map_err(|error| failure("update_check_failed", "检查更新", &error))?;
Ok(Outcome::new(report_of(&current, job.source_id(), &outcome)))
}
/// `ull update apply`:复查 → 下载 → 双检 → 替换(失败自动清理/回滚)。
///
/// # Errors
///
/// 复查、取包、校验、替换任一步失败 → 用户错误(退出码 1)。
pub fn apply(ctx: &Ctx, args: &UpdateApplyArgs) -> Result<Outcome, CliError> {
let current = current_version()?;
let job = job(ctx, args.dest.clone());
let dest = job.dest().display().to_string();
match job
.apply(&current, CHANNEL)
.map_err(|error| failure("update_apply_failed", "应用更新", &error))?
{
Applied::Installed { receipt, version } => {
let backup = receipt
.backup
.as_ref()
.map(|path| path.display().to_string());
let mut human = vec![
format!("已下载并校验 v{version}({} 字节)", receipt.bytes),
format!("已替换:{dest}"),
];
human.push(match &backup {
Some(backup) => format!("旧版本备份:{backup}"),
None => "原本没有旧文件(首次落盘)".to_string(),
});
human.push("下次启动生效(正在运行的进程仍是旧版本)。".to_string());
Ok(Outcome::new(Report::new(
json!({
"current": current.to_string(),
"channel": CHANNEL.as_str(),
"status": "installed",
"version": version,
"path": dest,
"bytes": receipt.bytes,
"backup": backup,
}),
human,
)))
}
Applied::Nothing(outcome) => {
let report = report_of(&current, job.source_id(), &outcome);
Ok(Outcome::new(Report::new(
json!({ "status": "nothing_to_apply", "check": report.data }),
{
let mut human =
vec!["复查之后没有可应用的更新(已经不是最新版本冲突)".to_string()];
human.extend(report.human);
human
},
)))
}
}
}
/// 把检查结论排成「人读几行 + `--json` 一份」。
fn report_of(current: &Version, source: &str, outcome: &CheckOutcome) -> Report {
let base = |status: &str| {
json!({
"current": current.to_string(),
"channel": CHANNEL.as_str(),
"source": source,
"status": status,
})
};
match outcome {
CheckOutcome::UpToDate { latest, .. } => {
let latest = latest.as_ref().map(ToString::to_string);
let mut data = base("up_to_date");
data["latest"] = json!(latest);
Report::new(
data,
vec![match &latest {
Some(latest) => format!("已是最新:v{current}(渠道 {latest} 内最高)"),
None => format!("已是最新:v{current}(该渠道还没有任何发布)"),
}],
)
}
CheckOutcome::Available(candidate) => {
let version = candidate.release.version.to_string();
let forced = candidate.urgency == unlucky_core::update::Urgency::Forced;
let notes = candidate.release.notes.clone().unwrap_or_default();
let mut human = vec![format!(
"发现新版本 v{version}(当前 v{current},{} 字节{})",
candidate.artifact.size,
if forced { ",强制更新" } else { "" }
)];
if !notes.trim().is_empty() {
human.push(format!("说明:{}", notes.trim()));
}
human.push("用 `%prog% update apply` 下载并应用(下次启动生效)".to_string());
Report::new(
json!({
"current": current.to_string(),
"channel": CHANNEL.as_str(),
"source": source,
"status": "available",
"version": version,
"size": candidate.artifact.size,
"forced": forced,
"notes": notes,
"target": candidate.artifact.target.as_str(),
}),
human,
)
}
CheckOutcome::MissingArtifact { release } => {
let version = release.version.to_string();
Report::new(
json!({
"current": current.to_string(),
"channel": CHANNEL.as_str(),
"source": source,
"status": "missing_artifact",
"version": version,
"target": unlucky_core::update::Target::current().as_str(),
}),
vec![format!(
"v{version} 已发布,但没有为本平台({})提供产物——这是发布侧漏项,不是「已是最新」",
unlucky_core::update::Target::current().as_str()
)],
)
}
}
}
+220
View File
@@ -0,0 +1,220 @@
//! 「检查 → 取包 → 长度/SHA256 双检 → 原子替换」的一条龙:**GUI 与 CLI 的唯一入口**。
//!
//! # 为什么要有本模块
//!
//! mission 的硬要求是「GUI 与 CLI 行为一致,不许两套逻辑」。两个壳子各自拼一遍「选源 + 定目标 +
//! 暂存 + 校验 + 替换」,迟早会漂:一处忘了摘要校验,一处把暂存放在数据根之外,一处把「没有更新」
//! 当成错误。所以这些决定收成一个 [`Job`]:
//!
//! ```text
//! Job { source, target, staging, dest }
//! ├─ check(current, channel) → CheckOutcome (版本比较/渠道准入都在 checker)
//! └─ apply(current, channel) → Applied (复查 → 取包 → 双检 → 替换)
//! ```
//!
//! # 应用为什么先复查一次
//!
//! 用户点「安装」离上次检查可能已过几分钟,期间发布侧可能换了包。清单只有几 KB,复查一次换来
//! 两个好处:装的一定是**此刻**最新的那一份;`apply` 自带「现在到底有没有得装」的答案,
//! 不必让调用方信任内存里的旧候选。
//!
//! # 失败时的文件去哪
//!
//! 校验失败:暂存文件由 [`stage`] 就地删掉([`super::verify::StagedPackage`] 的 `Drop` 再兜一层),
//! **不留在原地**。替换失败:由 [`install_staged`] 回滚,旧版本仍在原路径上。两件事都不需要调用方记得做。
//!
//! # 换源
//!
//! 生产源是 D50 定死的 GitHub Releases([`default_source`]);`ULL_UPDATE_DIR` 指向本地目录时
//! 改走 [`super::source::DirSource`]——那是取证/内网手工分发的口子,**不是**第二个更新源。
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::download::DownloadEngine;
use super::channel::Channel;
use super::checker::{CheckOutcome, UpdateChecker};
use super::error::UpdateError;
use super::github::GitHubReleaseSource;
use super::install::{install_staged, InstallReceipt, ReplacePolicy};
use super::source::{DirSource, UpdateSource};
use super::target::Target;
use super::verify::stage;
use super::version::Version;
/// 本机更新源覆盖(取证 / 内网手工分发):指向一个 `DirSource` 根目录(`<root>/stable.json`)。
///
/// 生产不设它——真实网络下 `releases/latest` 是唯一入口。它的用途是把「发现新版本」这条分支
/// 在没有稳定 Release 的情况下也能整环验一遍(本地清单 + 本地产物,一个字节都不出网)。
pub const UPDATE_DIR_ENV: &str = "ULL_UPDATE_DIR";
/// 一次应用的结局。
#[derive(Debug)]
pub enum Applied {
/// 替换成功(新版本已经就位,等重启)。
Installed {
/// 收据(目标路径 + 旧版本备份 + 字节数)。
receipt: InstallReceipt,
/// 装上的版本号。
version: String,
},
/// 复查时已经没有可装的更新(例如这期间别人替你升过)。
Nothing(CheckOutcome),
}
/// 一次检查/应用的全部输入。
///
/// 不含任何 UI 状态:谁在查、查完怎么展示是壳层的事。字段都可读,日志/诊断要什么有什么。
pub struct Job {
source: Box<dyn UpdateSource>,
target: Target,
staging: PathBuf,
dest: PathBuf,
}
impl std::fmt::Debug for Job {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Job")
.field("source", &self.source.source_id())
.field("target", &self.target.as_str())
.field("staging", &self.staging)
.field("dest", &self.dest)
.finish()
}
}
impl Job {
/// 显式装配(测试用:源、目标平台、暂存、替换目标全都自己给)。
#[must_use]
pub fn new(
source: Box<dyn UpdateSource>,
target: Target,
staging: impl Into<PathBuf>,
dest: impl Into<PathBuf>,
) -> Self {
Self {
source,
target,
staging: staging.into(),
dest: dest.into(),
}
}
/// 本进程的默认装配:目标平台 = 编译目标,替换目标 = 本进程 exe,
/// 暂存 = `<cache_root>/update/staging`。
#[must_use]
pub fn for_process(source: Box<dyn UpdateSource>, cache_root: &Path) -> Self {
Self::new(
source,
Target::current(),
staging_dir(cache_root),
process_dest(),
)
}
/// 换替换目标(`ull update apply --dest <path>`、单测都走它)。
#[must_use]
pub fn with_dest(mut self, dest: impl Into<PathBuf>) -> Self {
self.dest = dest.into();
self
}
/// 源 id(日志/事件里的 `source` 字段)。
#[must_use]
pub fn source_id(&self) -> &str {
self.source.source_id()
}
/// 绑定的目标平台。
#[must_use]
pub const fn target(&self) -> Target {
self.target
}
/// 暂存目录。
#[must_use]
pub fn staging(&self) -> &Path {
&self.staging
}
/// 替换目标(本进程 exe)。
#[must_use]
pub fn dest(&self) -> &Path {
&self.dest
}
/// 查一次更新。
///
/// # Errors
///
/// 清单取不到 / 清单不合法 / 渠道不符都返回 [`UpdateError`];「没有更新」不是错误,
/// 走 [`CheckOutcome::UpToDate`]。
pub fn check(&self, current: &Version, channel: Channel) -> Result<CheckOutcome, UpdateError> {
UpdateChecker::new(SourceRef(&*self.source), self.target).check(current, channel)
}
/// 下载 → 双检 → 原子替换。
///
/// # Errors
///
/// 复查失败、取包失败、长度/摘要不符、替换失败都返回 [`UpdateError`];
/// 「复查之后没有可装的更新」不是错误,走 [`Applied::Nothing`]。
pub fn apply(&self, current: &Version, channel: Channel) -> Result<Applied, UpdateError> {
let outcome = self.check(current, channel)?;
let Some(candidate) = outcome.candidate() else {
return Ok(Applied::Nothing(outcome));
};
let staged = stage(&*self.source, &candidate.artifact, &self.staging)?;
let version = candidate.release.version.to_string();
match install_staged(&staged, &self.dest, ReplacePolicy::default()) {
Ok(receipt) => Ok(Applied::Installed { receipt, version }),
Err(failure) => Err(failure.error),
}
}
}
/// `&dyn UpdateSource` 也能当源用([`UpdateChecker`] 是泛型的,这里省掉一次装箱)。
struct SourceRef<'a>(&'a dyn UpdateSource);
impl UpdateSource for SourceRef<'_> {
fn source_id(&self) -> &str {
self.0.source_id()
}
fn fetch_manifest(
&self,
channel: Channel,
) -> Result<Option<super::manifest::ReleaseManifest>, UpdateError> {
self.0.fetch_manifest(channel)
}
fn fetch_artifact(
&self,
artifact: &super::manifest::Artifact,
dest: &Path,
) -> Result<u64, UpdateError> {
self.0.fetch_artifact(artifact, dest)
}
}
/// 本进程要替换的目标(拿不到 `current_exe` 时留一个占位名,让错误信息里至少有个名字)。
#[must_use]
pub fn process_dest() -> PathBuf {
std::env::current_exe().unwrap_or_else(|_| PathBuf::from("unlucky-launcher"))
}
/// 暂存目录(`<cache_root>/update/staging`)。
#[must_use]
pub fn staging_dir(cache_root: &Path) -> PathBuf {
cache_root.join("update").join("staging")
}
/// 生产源:GitHub Releases(D50);`ULL_UPDATE_DIR` 设了就走本地目录源。
#[must_use]
pub fn default_source(engine: Arc<DownloadEngine>) -> Box<dyn UpdateSource> {
match std::env::var(UPDATE_DIR_ENV) {
Ok(root) if !root.trim().is_empty() => Box::new(DirSource::new(root.trim())),
_ => Box::new(GitHubReleaseSource::new(engine)),
}
}
+3
View File
@@ -27,6 +27,7 @@
//! source UpdateSource trait + 本地目录源 github GitHub Releases 源(D50)
//! verify 暂存 + 长度/摘要校验 checker 选择哪一份发布、急不急
//! install 原子替换 + 回滚 error 分类与文案
//! job 检查/应用的一条龙(GUI 与 CLI 共用同一入口)
//! ```
//!
//! # 刻意不做的事
@@ -43,6 +44,7 @@ pub mod checker;
pub mod error;
pub mod github;
pub mod install;
pub mod job;
pub mod manifest;
pub mod source;
pub mod target;
@@ -59,6 +61,7 @@ pub use self::github::GitHubReleaseSource;
pub use self::install::{
backup_path, install_staged, InstallFailure, InstallReceipt, ReplacePolicy,
};
pub use self::job::{default_source, process_dest, staging_dir, Applied, Job, UPDATE_DIR_ENV};
pub use self::manifest::{Artifact, Release, ReleaseManifest};
pub use self::source::{DirSource, UpdateSource};
pub use self::target::{Arch, PackageFormat, Target};
+19
View File
@@ -52,6 +52,25 @@ pub trait UpdateSource: Send + Sync {
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError>;
}
/// `Box<dyn UpdateSource>` 也是 `UpdateSource`。
///
/// 为什么需要:壳层要**运行期**才决定用哪个源(生产走 GitHub、测试/内网走本地目录),而
/// [`super::UpdateChecker`] 与 [`super::verify::stage`] 都是按 trait 泛型化的。没有这条转发,
/// 调用方只能给每种源写一份检查/下载流程——那正是 mission 禁止的「两套逻辑」。
impl<T: UpdateSource + ?Sized> UpdateSource for Box<T> {
fn source_id(&self) -> &str {
(**self).source_id()
}
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> {
(**self).fetch_manifest(channel)
}
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {
(**self).fetch_artifact(artifact, dest)
}
}
/// 本地目录源(也接受 `file://` 根地址)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirSource {
+4
View File
@@ -125,6 +125,7 @@ mod loader;
mod logs;
mod nav;
mod settings;
mod update;
/// 拆分前的 `key -> (en, zh-CN)` **逐字快照**(金表)。
///
@@ -195,6 +196,7 @@ use settings::{
settings_ai_entries, settings_appearance_entries, settings_download_entries,
settings_general_entries, settings_java_entries,
};
use update::update_entries;
// 装配顺序 = `Key` 的声明顺序。**与拆分前逐条一致**,动它等于动 `Key` 的 `Ord` 与导出表顺序,
// 金表(`catalog/golden.rs`)会红。设置页那五个域按页面上的分组顺序排,中间三个现在是空的。
@@ -224,6 +226,8 @@ nav_entries! {
// M114:账户页。同理**追加在 main 已占住的链尾之后**(先 content 再 account),
// 金表只追加不重排。
account_entries,
// M159:自更新(设置页「更新」分组 + 顶部提示条)。同样只追加在链尾之后。
update_entries,
finish,
{}
}
+30
View File
@@ -361,4 +361,34 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("AccountNoticeTitleDone", "Account updated", "账户已更新"),
("AccountHintAuthlibReauth", "Enter the password again for {server}.", "请输入 {server} 的密码。"),
("AccountHintOfflineNoRelogin", "Local profiles have no server account to sign in again — create a new profile instead.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"),
("SettingsGroupUpdate", "Updates", "更新"),
("SettingsUpdateCurrent", "Current version", "当前版本"),
("SettingsUpdateChannel", "Channel", "更新渠道"),
("SettingsUpdateChannelStable", "Stable", "稳定版"),
("SettingsUpdateAutoCheck", "Check for updates on startup", "启动时检查更新"),
("SettingsUpdateCheckNow", "Check now", "立即检查"),
("SettingsUpdateChecking", "Checking…", "正在检查…"),
("SettingsUpdateNever", "Not checked yet", "还没检查过"),
("SettingsUpdateUpToDate", "You are on the latest version ({version})", "已是最新({version})"),
("SettingsUpdateAvailable", "Version {version} is available ({size})", "有新版本 {version}({size})"),
("SettingsUpdateMissingArtifact", "Version {version} is out, but there is no build for this platform yet.", "{version} 已发布,但还没有本平台的安装包。"),
("SettingsUpdateFailed", "Check failed: {reason}", "检查失败:{reason}"),
("SettingsUpdateApply", "Download and install", "下载并安装"),
("SettingsUpdateApplying", "Downloading and verifying…", "正在下载并校验…"),
("SettingsUpdateReady", "Version {version} is ready — restart to use it.", "版本 {version} 已就位,重启后生效。"),
("SettingsUpdateNotes", "Release notes: {notes}", "更新说明:{notes}"),
("SettingsUpdateForced", "This update is required", "这是一次强制更新"),
("SettingsUpdateLastChecked", "Last check", "上次检查"),
("SettingsUpdateHint", "Downloads are verified with SHA256 before they touch the launcher. The running copy stays as it is until you restart.", "更新包在落到启动器之前会做 SHA256 校验;正在运行的这一份在你重启之前不会变。"),
("UpdateBannerTitleAvailable", "Version {version} is available", "发现新版本 {version}"),
("UpdateBannerBodyAvailable", "Download size {size}", "下载体积 {size}"),
("UpdateBannerTitleReady", "Update ready", "更新已就位"),
("UpdateBannerBodyReady", "Version {version} will run after a restart.", "重启后运行版本 {version}。"),
("UpdateBannerTitleFailed", "Update failed", "更新失败"),
("UpdateBannerApplying", "Downloading the update…", "正在下载更新…"),
("UpdateBannerInstall", "Download and install", "下载并安装"),
("UpdateBannerDismiss", "Skip this version", "忽略这个版本"),
("UpdateBannerRestartNow", "Restart now", "立即重启"),
("UpdateBannerRestartLater", "Not now", "稍后"),
("UpdateBannerRetry", "Try again", "重试"),
];
+60
View File
@@ -0,0 +1,60 @@
//! 自更新文案(M159):设置页「更新」分组 + 顶部提示条。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节;本域**追加在链尾**(`account_entries`
//! 之后),于是 `Key::ALL` 顺序 = 金表顺序 = 先 main 的全部域,最后 update(金表只追加不重排)。
//!
//! 三条口径:
//!
//! 1. **「检查失败」与「已是最新」是两句不同的文案**(mission 的硬要求):前者带原因,
//! 后者带版本号。别为了少一个 key 把它们合并成一条;
//! 2. 体积走 `{size}`(由页面按 `human_bytes` 格式化后传入),版本号走 `{version}`——
//! 目录里不写死任何版本号;
//! 3. 品牌/技术名词不翻:`OpenGL` / `SHA256` / `GitHub` 这类保持原样。
macro_rules! update_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── 设置页「更新」分组
SettingsGroupUpdate => ("Updates", "更新"),
SettingsUpdateCurrent => ("Current version", "当前版本"),
SettingsUpdateChannel => ("Channel", "更新渠道"),
SettingsUpdateChannelStable => ("Stable", "稳定版"),
SettingsUpdateAutoCheck => ("Check for updates on startup", "启动时检查更新"),
SettingsUpdateCheckNow => ("Check now", "立即检查"),
SettingsUpdateChecking => ("Checking…", "正在检查…"),
SettingsUpdateNever => ("Not checked yet", "还没检查过"),
SettingsUpdateUpToDate => ("You are on the latest version ({version})", "已是最新({version})"),
SettingsUpdateAvailable => ("Version {version} is available ({size})", "有新版本 {version}({size})"),
SettingsUpdateMissingArtifact => (
"Version {version} is out, but there is no build for this platform yet.",
"{version} 已发布,但还没有本平台的安装包。"
),
SettingsUpdateFailed => ("Check failed: {reason}", "检查失败:{reason}"),
SettingsUpdateApply => ("Download and install", "下载并安装"),
SettingsUpdateApplying => ("Downloading and verifying…", "正在下载并校验…"),
SettingsUpdateReady => ("Version {version} is ready — restart to use it.", "版本 {version} 已就位,重启后生效。"),
SettingsUpdateNotes => ("Release notes: {notes}", "更新说明:{notes}"),
SettingsUpdateForced => ("This update is required", "这是一次强制更新"),
SettingsUpdateLastChecked => ("Last check", "上次检查"),
SettingsUpdateHint => (
"Downloads are verified with SHA256 before they touch the launcher. The running copy stays as it is until you restart.",
"更新包在落到启动器之前会做 SHA256 校验;正在运行的这一份在你重启之前不会变。"
),
// ── 顶部提示条
UpdateBannerTitleAvailable => ("Version {version} is available", "发现新版本 {version}"),
UpdateBannerBodyAvailable => ("Download size {size}", "下载体积 {size}"),
UpdateBannerTitleReady => ("Update ready", "更新已就位"),
UpdateBannerBodyReady => ("Version {version} will run after a restart.", "重启后运行版本 {version}。"),
UpdateBannerTitleFailed => ("Update failed", "更新失败"),
UpdateBannerApplying => ("Downloading the update…", "正在下载更新…"),
UpdateBannerInstall => ("Download and install", "下载并安装"),
UpdateBannerDismiss => ("Skip this version", "忽略这个版本"),
UpdateBannerRestartNow => ("Restart now", "立即重启"),
UpdateBannerRestartLater => ("Not now", "稍后"),
UpdateBannerRetry => ("Try again", "重试"),
} }
};
}
pub(crate) use update_entries;
+15
View File
@@ -35,6 +35,8 @@ use crate::dto::{
PackEvent,
SessionId,
SettingGroupView,
// M159: 自更新 —— 一条快照整体替换
UpdateView,
VersionCategory,
VersionSummary,
};
@@ -129,6 +131,10 @@ pub enum CoreEvent {
// M113: import/export
/// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`])
Pack(Box<PackEvent>),
/// 自更新(M159):整份状态快照(检查结论 + 就位状态 + 用户是否已拒绝)。
///
/// 与 `SettingsChanged` 同一条形态:快照整体替换,UI 不做增量合并。
Update(Box<UpdateView>),
}
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
@@ -237,6 +243,15 @@ pub enum AppCommand {
// M113: import/export
/// 整合包导入/导出(M113):导入 / 预检 / 确认写盘 / 关面板(子枚举见 [`PackCommand`])
Pack(Box<PackCommand>),
// M159: 自更新
/// 查一次更新(设置页「立即检查」;会清掉「忽略这个版本」的标记并重新请求网络)
CheckUpdate,
/// 把当前候选下载下来、校验、替换本程序(结果经 [`CoreEvent::Update`] 回来)
ApplyUpdate,
/// 忽略这个版本(本次会话不再弹提示条;候选仍留在快照里,设置页可手动装)
DismissUpdate,
/// 开关「启动时静默检查」(写进更新域自己的偏好文件)
SetUpdateAutoCheck(bool),
}
impl AppCommand {
+6
View File
@@ -28,6 +28,8 @@ mod notices;
// M113: import/export —— 整合包导入/导出的 DTO(M111/M112 管线的 IPC 面)
mod pack;
mod settings;
// M159: 自更新(检查结论 / 候选 / 就位状态)
mod update;
mod versions;
pub use accounts::{
@@ -59,4 +61,8 @@ pub use pack::{
MissingReasonView, PackCommand, PackEvent, PackFormatView, PackState, ProbeView,
};
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
// M159: 自更新
pub use update::{
ReadyUpdate, UpdateBanner, UpdateCandidateView, UpdateFailureView, UpdateOutcome, UpdateView,
};
pub use versions::{AncientKind, VersionCategory, VersionKind, VersionSummary};
+318
View File
@@ -0,0 +1,318 @@
//! 自更新 DTO(M159):一次检查的结论、候选、失败分类、就位状态。
//!
//! 口径四条:
//!
//! 1. **「检查失败」不是「已是最新」**——两者是 [`UpdateOutcome`] 的两个变体,各有自己的文案。
//! 把它们折叠成一个(例如失败时也发 `UpToDate`)会让用户以为「已经最新」,而网络其实一次都没通;
//! 2. **「有更高版本但没给你的平台发包」也不是「已是最新」**([`UpdateOutcome::MissingArtifact`]):
//! 那是发布侧的漏项,说成「最新」等于把发布的错报成用户的好消息;
//! 3. **版本比较只发生在 core**([`unlucky_core::update::UpdateChecker`]):本模块只搬运结论,
//! UI 与 CLI 都不许自己比版本号(mission:GUI 与 CLI 行为一致,不许两套逻辑);
//! 4. 本模块不含 core 类型(D42:dto 只放扁平 IPC 类型)。
//!
//! 「拒绝更新」([`UpdateView::dismissed`])是**会话级**状态:用户点了「忽略这个版本」,本次运行
//! 不再自动弹提示条;但设置页的「立即检查」不受影响(它会把这一位清掉重查)。
use serde::{Deserialize, Serialize};
/// 检查更新的一次结论。
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum UpdateOutcome {
/// 还没查过(启动那一刻的状态)。
#[default]
Idle,
/// 正在查(网络在后台线程)。
Checking,
/// 已是最新。
UpToDate {
/// 该渠道准入的最高版本;清单里还没有任何发布时为 `None`(「还没发过版」不是错误)。
latest: Option<String>,
},
/// 有可装的更新。
Available(Box<UpdateCandidateView>),
/// 有更高的版本,但里面没有本平台的产物(发布侧漏项,不能对用户说「已是最新」)。
MissingArtifact {
/// 那个更高版本。
version: String,
},
/// 检查失败(连不上 / 清单坏了 / 渠道不符 …)。
Failed(UpdateFailureView),
}
impl UpdateOutcome {
/// 是不是正在查。
#[must_use]
pub const fn is_checking(&self) -> bool {
matches!(self, Self::Checking)
}
/// 可装的候选(没有则为 `None`)。
#[must_use]
pub fn candidate(&self) -> Option<&UpdateCandidateView> {
match self {
Self::Available(candidate) => Some(candidate),
_ => None,
}
}
/// 失败信息(不是失败则为 `None`)。
#[must_use]
pub const fn failure(&self) -> Option<&UpdateFailureView> {
match self {
Self::Failed(failure) => Some(failure),
_ => None,
}
}
/// 稳定 id(日志 / 测试 / 事件流用;**不要改名**)。
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Checking => "checking",
Self::UpToDate { .. } => "up_to_date",
Self::Available(_) => "available",
Self::MissingArtifact { .. } => "missing_artifact",
Self::Failed(_) => "failed",
}
}
}
/// 一个可安装的候选(提示条与设置页共用的四件)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpdateCandidateView {
/// 新版本号(清单里的原文,如 `0.2.3`)。
pub version: String,
/// 包体字节数(提示条上的「体积」)。
pub size: u64,
/// 更新说明摘要(清单里的 `notes`,已截断;空串 = 发布没写说明)。
pub notes: String,
/// 急不急(`true` = 发布标了强制,或当前版本低于发布侧的最低支持版本)。
pub forced: bool,
}
/// 检查失败的分类与人话。
///
/// `message` 直接来自 core 的错误(中文、含路径/URL 细节),UI 只在它前面加一句自己的话;
/// `kind` 是 core 的稳定分类 id(`network` / `invalid_manifest` / `digest_mismatch` …),
/// 给「要不要显示重试」这类判据用。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpdateFailureView {
/// 稳定分类 id(`UpdateErrorKind::as_str`)。
pub kind: String,
/// 人话原因。
pub message: String,
/// 换个时间/换个源再试是否可能变好。
pub retryable: bool,
}
impl UpdateFailureView {
/// 建一条。
pub fn new(kind: &str, message: impl Into<String>, retryable: bool) -> Self {
Self {
kind: kind.to_string(),
message: message.into(),
retryable,
}
}
}
/// 已经就位的新版本(下载 + 校验 + 替换都成功,等用户重启)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReadyUpdate {
/// 新版本号。
pub version: String,
/// 替换后的目标路径(人读展示;「下次启动生效」说的就是它)。
pub path: String,
/// 旧版本被挪到哪(`None` = 原本没有文件 / 已清理)。
pub backup: Option<String>,
}
/// 提示条此刻要显示什么。
///
/// 「检查失败」只有在**用户主动查**的时候才弹条([`UpdateView::silent`] = `false`):
/// 启动时的静默检查失败是常态(离线、公司网、镜像没同步),拿一条红条迎面上来等于每次开
/// 启动器先骂网络一次。失败原因仍然进设置页的「更新」区,那里随时能看。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateBanner<'a> {
/// 有新版本(可下载)。
Available(&'a UpdateCandidateView),
/// 正在下载/校验/替换。
Applying,
/// 已就位,等重启。
Ready(&'a ReadyUpdate),
/// 检查/应用失败(只在用户主动查之后出现)。
Failed(&'a UpdateFailureView),
}
/// 更新页 / 提示条的全部状态(一条快照整体替换)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpdateView {
/// 当前版本(`CARGO_PKG_VERSION`,由 host 填)。
pub current_version: String,
/// 渠道(D50:先只发 stable)。
pub channel: String,
/// 启动时是否静默检查(设置页开关)。
pub auto_check: bool,
/// 检查结论。
pub outcome: UpdateOutcome,
/// 上次检查时间(`MM-DD HH:MM`;空串 = 没查过)。
pub checked_at: String,
/// 这一份结论来自**静默**检查(启动时/自动)而不是用户点的「立即检查」。
pub silent: bool,
/// 正在下载/校验/替换(一条慢操作,期间禁用「立即安装」)。
pub applying: bool,
/// 已经就位的新版本(等重启)。
pub ready: Option<ReadyUpdate>,
/// 用户点了「忽略这个版本」:本次会话不再弹提示条(设置页手动检查会清掉它)。
pub dismissed: bool,
}
impl Default for UpdateView {
fn default() -> Self {
Self::new(String::new(), "stable", true)
}
}
impl UpdateView {
/// 建一份初始状态。
#[must_use]
pub fn new(current: impl Into<String>, channel: &str, auto_check: bool) -> Self {
Self {
current_version: current.into(),
channel: channel.to_string(),
auto_check,
outcome: UpdateOutcome::Idle,
checked_at: String::new(),
silent: false,
applying: false,
ready: None,
dismissed: false,
}
}
/// 提示条此刻要显示什么(`None` = 不显示)。
///
/// 优先级:就位 > 进行中 > 失败(仅主动查) > 有新版本(未被忽略)。
#[must_use]
pub fn banner(&self) -> Option<UpdateBanner<'_>> {
if let Some(ready) = &self.ready {
return Some(UpdateBanner::Ready(ready));
}
if self.applying {
return Some(UpdateBanner::Applying);
}
if let Some(failure) = self.outcome.failure() {
return (!self.silent).then_some(UpdateBanner::Failed(failure));
}
self.outcome
.candidate()
.filter(|_| !self.dismissed)
.map(UpdateBanner::Available)
}
/// 有没有「需要用户看一眼」的结论(提示条 / 就位后的重启提示 / 强制更新)。
#[must_use]
pub fn needs_attention(&self) -> bool {
self.banner().is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn candidate(version: &str) -> UpdateCandidateView {
UpdateCandidateView {
version: version.to_string(),
size: 21_000_000,
notes: "修掉启动崩溃".to_string(),
forced: false,
}
}
#[test]
fn failure_is_not_up_to_date() {
let mut view = UpdateView {
outcome: UpdateOutcome::Failed(UpdateFailureView::new(
"network",
"取更新清单失败:连接被重置",
true,
)),
..UpdateView::default()
};
assert!(view.outcome.failure().is_some());
assert!(view.outcome.candidate().is_none());
assert!(!matches!(view.outcome, UpdateOutcome::UpToDate { .. }));
assert_eq!(view.outcome.as_str(), "failed");
// 用户主动查出来的失败 → 提示条要说清原因(不是「已是最新」)。
assert!(matches!(view.banner(), Some(UpdateBanner::Failed(_))));
// 静默检查的失败 → 不弹条(离线是常态,别每次开机先骂网络一次)。
view.silent = true;
assert!(view.banner().is_none(), "静默检查失败不该变成提示条");
assert!(
view.outcome.failure().is_some(),
"但失败原因仍留在快照里(设置页要看)"
);
}
#[test]
fn dismissed_hides_the_banner_but_keeps_the_candidate() {
let mut view = UpdateView {
outcome: UpdateOutcome::Available(Box::new(candidate("0.2.3"))),
..UpdateView::default()
};
assert!(matches!(view.banner(), Some(UpdateBanner::Available(_))));
view.dismissed = true;
assert!(view.banner().is_none(), "拒绝之后本次不再打扰");
// 候选没被丢掉:设置页仍能显示「有新版本 0.2.3」并手动装
assert_eq!(
view.outcome.candidate().map(|item| item.version.as_str()),
Some("0.2.3")
);
}
#[test]
fn ready_state_replaces_the_banner() {
let view = UpdateView {
outcome: UpdateOutcome::Available(Box::new(candidate("0.2.3"))),
ready: Some(ReadyUpdate {
version: "0.2.3".to_string(),
path: "C:/ULL/unlucky-launcher.exe".to_string(),
backup: Some("C:/ULL/.unlucky-launcher.exe.ullold-1-0".to_string()),
}),
..UpdateView::default()
};
assert!(matches!(view.banner(), Some(UpdateBanner::Ready(_))));
assert!(view.needs_attention(), "就位之后要提示重启");
}
#[test]
fn up_to_date_and_idle_show_no_banner() {
let mut view = UpdateView::default();
assert!(view.banner().is_none(), "Idle 还没查过,不该弹条");
view.outcome = UpdateOutcome::UpToDate {
latest: Some("0.2.2".to_string()),
};
assert!(view.banner().is_none(), "已是最新没有任何要说的");
view.outcome = UpdateOutcome::MissingArtifact {
version: "0.2.3".to_string(),
};
assert!(view.banner().is_none(), "缺产物不进提示条,进设置页原因");
}
#[test]
fn view_round_trips_as_ipc() {
let mut view = UpdateView::new("0.2.2", "stable", false);
view.checked_at = "11-08 12:00".to_string();
view.outcome = UpdateOutcome::UpToDate {
latest: Some("0.2.2".to_string()),
};
let text = serde_json::to_string(&view).expect("序列化");
let back: UpdateView = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, view);
assert!(!back.auto_check);
}
}
+15
View File
@@ -245,6 +245,21 @@ impl CoreHost {
let epoch = self.next_install_epoch();
addon::spawn_install(self.env.clone(), self.tx.clone(), *request, cancel, epoch);
}
// M159: 自更新 —— 检查 / 下载替换 / 拒绝 / 开关
AppCommand::CheckUpdate => self.start_update_check(true),
AppCommand::ApplyUpdate => self.start_update_apply(),
AppCommand::DismissUpdate => {
self.update.dismiss();
self.publish_update();
}
AppCommand::SetUpdateAutoCheck(on) => match self.update.set_auto_check(on) {
Ok(()) => self.publish_update(),
Err(reason) => {
// 写不进去也要把这次的选择留在会话里(不把开关弹回去),并说清原因。
self.publish_update();
self.toast(ToastKind::Warn, reason);
}
},
// AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型)
other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => {
self.chat.dispatch(other);
+5
View File
@@ -184,6 +184,11 @@ impl CoreHost {
self.send(CoreEvent::DiagnosisReady(*report));
}
HostTask::Notice(notice) => self.push_notice(*notice),
// M159:更新回报(检查结论 / 应用结局)→ 快照整体替换
HostTask::Update(report) => {
self.update.adopt(*report);
self.publish_update();
}
HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)),
}
}
+2
View File
@@ -132,6 +132,8 @@ pub enum HostTask {
Toast(Box<Toast>),
/// 会话列表刷新(只换左栏)
Sessions(Vec<LogSession>),
/// 自更新(M159):检查/应用的回报(载荷已翻成 DTO,见 `host::update::Report`)
Update(Box<crate::host::update::Report>),
}
/// 后台线程用的发送端(`Sender` 克隆即可)。
+15 -1
View File
@@ -32,6 +32,8 @@ mod logs;
mod meter;
mod opener;
mod settings;
mod update;
mod update_state;
mod versions;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
@@ -103,6 +105,8 @@ pub struct CoreHost {
/// 一眼能看出「这是旧任务的」,于是既不会被取消掉的安装重新点亮面板,也不会串到下一轮安装
/// (两轮安装同时存在时,旧任务的事件曾经把新任务的进度条顶掉)。
install_epoch: u64,
/// 自更新(M159):检查结论 / 应用状态 / 更新域偏好。
update: update_state::Runtime,
}
impl CoreHost {
@@ -125,6 +129,9 @@ impl CoreHost {
fn build(env: HostEnv, refresh: bool) -> Self {
let (tx, rx) = job::channel();
game::ensure_root(&env);
// M159:更新状态要在构造时就有一份(提示条/设置页都读它),静默检查在 `refresh_all`
// 里按开关派发——`headless` 走 `refresh=false`,于是单测不会发任何网络请求。
let update = update_state::Runtime::for_env(&env);
let mut host = Self {
env,
events: VecDeque::new(),
@@ -149,6 +156,7 @@ impl CoreHost {
chat: MockBackend::default(),
install_cancel: None,
install_epoch: 0,
update,
};
if refresh {
host.refresh_all();
@@ -184,7 +192,7 @@ impl CoreHost {
}));
}
/// 全量刷新:实例 / 会话 / 账户 / 设置 / Java,并触发一次版本清单拉取。
/// 全量刷新:实例 / 会话 / 账户 / 设置 / Java / 更新,并触发一次版本清单拉取。
pub fn refresh_all(&mut self) {
self.refresh_instances();
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
@@ -192,6 +200,12 @@ impl CoreHost {
self.send(CoreEvent::SettingsChanged(settings::groups(&self.env)));
self.refresh_java();
versions::spawn_list(self.env.clone(), self.tx.clone());
// M159:更新快照先发一份(设置页首帧就有「当前版本 / 上次结果」),
// 再按开关决定要不要静默查一次。关掉开关 ⇒ 这里一行网络都不发。
self.publish_update();
if self.update.should_check_on_start() {
self.start_update_check(false);
}
}
/// 重建实例快照,并把**真实运行时**盖回去。
+382
View File
@@ -0,0 +1,382 @@
//! 自更新在宿主层的**装配与翻译**(M159)。
//!
//! # 分工
//!
//! ```text
//! core::update::Job ← 检查/应用的做法(版本比较、渠道准入、双检、原子替换全在 core)
//! ▲
//! │ 本模块只做三件事
//! Factory ← 用哪个源、暂存放哪、替换谁(生产:GitHub + 本进程 exe)
//! outcome_of ← core 的结论 → DTO(GUI 与 CLI 共用同一条判据)
//! Prefs ← 更新域自己的开关(启动时静默检查)
//! ```
//!
//! 「GUI 与 CLI 行为一致」由结构保证:两边都只构造 `core::update::Job` 并调用 `check` / `apply`,
//! 没有第二处版本比较、也没有第二个下载/校验流程(CLI 侧见 `crates/cli/src/commands/update.rs`)。
//!
//! # 静默检查的边界
//!
//! 启动时的检查能被关掉([`Prefs::auto_check`]):关掉之后 host 的装配路径**根本不派发**
//! (判据在 `CoreHost::refresh_all` 的那一行 `if self.update.should_check_on_start()`)。
//!
//! # 偏好文件为什么不在 `launcher.json` 里
//!
//! `launcher.json` 的每个键都归 `core::setting::keys` 的 schema 管,而本 mission 的 scope 不含
//! `crates/core/src/setting/**`。所以这一条先落在更新域自己的文件
//! `<数据根>/cache/update/prefs.json`(与 `cache/region.json` 同族:域内状态,坏了就回默认)。
//! **加 `update.auto_check` schema 键之后应当迁过去**(已作为 finding 上报)。
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use unlucky_core::download::DownloadEngine;
use unlucky_core::launch::time::{format_rfc3339_millis, now};
use unlucky_core::update::{
default_source, staging_dir, Applied, Channel, 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,
}
fn default_auto_check() -> bool {
true
}
impl Default for Prefs {
fn default() -> Self {
Self { auto_check: true }
}
}
impl Prefs {
/// 读偏好:文件不存在 / 坏 JSON 一律回默认(偏好坏了不该让启动器起不来)。
#[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()
}
/// 写偏好。
///
/// # 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}"))?;
}
let text = serde_json::to_string_pretty(self)
.map_err(|error| format!("序列化更新偏好失败:{error}"))?;
std::fs::write(path, text).map_err(|error| format!("写更新偏好失败:{error}"))
}
}
/// 怎么造一个 [`Job`](源 / 暂存 / 替换目标)。
///
/// 每派发一次就造一个新的(`Job` 里装的源是 `Box<dyn UpdateSource>`,不共享、不复用):
/// 这样「上一次用的源」不会跟着下一次跑,测试也能各用例各给一份本地目录。
#[derive(Clone)]
pub enum Factory {
/// 生产/spec 形态:GitHub(或 `ULL_UPDATE_DIR` 指定的本地目录),引擎懒建一次。
Default {
/// 下载引擎(镜像/地区/健康度策略都在里面)。
engine: Arc<DownloadEngine>,
/// 目标平台。
target: Target,
/// 暂存目录。
staging: PathBuf,
/// 替换目标。
dest: PathBuf,
},
/// 单测/取证:钉死一个本地目录源。
Dir {
/// 源根目录(`<root>/stable.json` + 产物)。
root: PathBuf,
/// 目标平台。
target: Target,
/// 暂存目录。
staging: PathBuf,
/// 替换目标。
dest: PathBuf,
},
}
impl std::fmt::Debug for Factory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Factory")
.field("dir", &self.dir())
.field("dest", &self.dest())
.finish()
}
}
impl Factory {
/// 生产装配:数据根的缓存做暂存,替换目标是**本进程的 exe**。
#[must_use]
pub fn for_env(env: &HostEnv) -> Self {
Self::Default {
engine: Arc::new(engine::engine(env)),
target: Target::current(),
staging: staging_dir(&env.layout.cache()),
dest: unlucky_core::update::process_dest(),
}
}
/// 换替换目标(单测专用:测试**绝不**替换真的测试二进制)。
#[must_use]
pub fn with_dest(self, dest: impl Into<PathBuf>) -> Self {
let dest = dest.into();
match self {
Self::Default {
engine,
target,
staging,
..
} => Self::Default {
engine,
target,
staging,
dest,
},
Self::Dir {
root,
target,
staging,
..
} => Self::Dir {
root,
target,
staging,
dest,
},
}
}
/// 换暂存目录(单测:落在临时目录里)。
#[must_use]
pub fn with_staging(self, staging: impl Into<PathBuf>) -> Self {
let staging = staging.into();
match self {
Self::Default {
engine,
target,
dest,
..
} => Self::Default {
engine,
target,
staging,
dest,
},
Self::Dir {
root, target, dest, ..
} => Self::Dir {
root,
target,
staging,
dest,
},
}
}
/// 换成本地目录源(单测/取证)。
#[must_use]
pub fn with_dir(self, root: impl Into<PathBuf>) -> Self {
let root = root.into();
match self {
Self::Default {
target,
staging,
dest,
..
} => Self::Dir {
root,
target,
staging,
dest,
},
Self::Dir {
target,
staging,
dest,
..
} => Self::Dir {
root,
target,
staging,
dest,
},
}
}
/// 本地目录源根(`None` = 走 GitHub)。
#[must_use]
pub fn dir(&self) -> Option<&Path> {
match self {
Self::Default { .. } => None,
Self::Dir { root, .. } => Some(root),
}
}
/// 替换目标。
#[must_use]
pub fn dest(&self) -> &Path {
match self {
Self::Default { dest, .. } | Self::Dir { dest, .. } => dest,
}
}
/// 造一个 [`Job`]。
#[must_use]
pub fn job(&self) -> Job {
match self {
Self::Default {
engine,
target,
staging,
dest,
} => Job::new(
default_source(Arc::clone(engine)),
*target,
staging.clone(),
dest.clone(),
),
Self::Dir {
root,
target,
staging,
dest,
} => Job::new(
Box::new(DirSource::new(root.clone())) as Box<dyn UpdateSource>,
*target,
staging.clone(),
dest.clone(),
),
}
}
}
/// 后台线程 → host 的**更新**回报(只含 DTO:`job.rs` 不必认识 core 类型)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Report {
/// 检查结论
Checked(UpdateOutcome),
/// 应用结局
Applied(ApplyReport),
}
/// 应用结局。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApplyReport {
/// 成功:新版本已就位
Installed {
/// 新版本号
version: String,
/// 替换后的路径
path: String,
/// 旧版本备份路径(`None` = 原本没有文件 / 已清掉)
backup: Option<String>,
},
/// 复查时已经没有可装的更新
Nothing(UpdateOutcome),
/// 失败(分类 + 人话)
Failed(UpdateFailureView),
}
impl Report {
/// 应用结局 → 回报(映射只在这一处)。
#[must_use]
pub fn applied(result: Result<Applied, UpdateError>) -> Self {
match result {
Ok(Applied::Installed { receipt, version }) => Self::Applied(ApplyReport::Installed {
version,
path: receipt.dest.display().to_string(),
backup: receipt.backup.map(|backup| backup.display().to_string()),
}),
Ok(Applied::Nothing(outcome)) => {
Self::Applied(ApplyReport::Nothing(outcome_of(Ok(outcome))))
}
Err(error) => Self::Applied(ApplyReport::Failed(failure_of(&error))),
}
}
}
/// core 的检查结论 → DTO(**唯一**的映射点)。
///
/// 三条不许折叠的边界在这里体现:`UpToDate` 与 `Failed` 是两个变体;
/// 「有更高版本但没有本平台产物」单独一个变体([`UpdateOutcome::MissingArtifact`])。
#[must_use]
pub fn outcome_of(result: Result<CheckOutcome, UpdateError>) -> UpdateOutcome {
match result {
Ok(CheckOutcome::UpToDate { latest, .. }) => UpdateOutcome::UpToDate {
latest: latest.map(|version| version.to_string()),
},
Ok(CheckOutcome::Available(candidate)) => {
UpdateOutcome::Available(Box::new(UpdateCandidateView {
version: candidate.release.version.to_string(),
size: candidate.artifact.size,
notes: notes_summary(candidate.release.notes.as_deref()),
forced: candidate.urgency == unlucky_core::update::Urgency::Forced,
}))
}
Ok(CheckOutcome::MissingArtifact { release }) => UpdateOutcome::MissingArtifact {
version: release.version.to_string(),
},
Err(error) => UpdateOutcome::Failed(failure_of(&error)),
}
}
/// core 的错误 → 失败视图(分类 id + 人话 + 可重试)。
#[must_use]
pub fn failure_of(error: &UpdateError) -> UpdateFailureView {
UpdateFailureView::new(error.kind.as_str(), error.to_string(), error.is_retryable())
}
/// 更新说明摘要的字符上限(提示条上只放一段;整篇说明在 Release 页)。
const NOTES_LIMIT: usize = 160;
fn notes_summary(notes: Option<&str>) -> String {
let text = notes.unwrap_or("").trim();
if text.chars().count() <= NOTES_LIMIT {
return text.to_string();
}
let mut short: String = text.chars().take(NOTES_LIMIT).collect();
short.push('…');
short
}
/// 当前时刻的人读戳(`MM-DD HH:MM`,与日志页同一个格式函数)。
pub(super) fn stamp() -> String {
display_time(&format_rfc3339_millis(now()))
}
/// 把版本号解析成 core 的 [`Version`](`None` = 编译进来的版本号不合法,理论上不可能)。
#[must_use]
pub(super) fn parse_version(text: &str) -> Option<Version> {
Version::parse(text).ok()
}
@@ -0,0 +1,91 @@
//! 应用(下载 → 双检 → 替换)的用例:校验失败不许碰目标、成功要留备份。
//!
//! 夹具(`TempDir` / `factory` / `job` / `manifest_for`)在父模块,本文件 `use super::*`。
use super::*;
// ------------------------------------------------------------------ 应用
/// 摘要不符 ⇒ 拒绝应用 + 暂存文件不残留 + 目标文件没被换掉。
#[test]
fn sha256_mismatch_refuses_the_apply_and_leaves_no_temp_file() {
let tmp = TempDir::new("digest");
let good = b"new-binary-v2";
let evil = b"evil-binary-x"; // 同样长度:先过长度判据,才能测到摘要这一关
assert_eq!(good.len(), evil.len());
// 清单声明的是 good 的摘要,盘上给的却是 evil。
dir_source(&tmp, &manifest_for("0.9.0", good, ""), evil);
let dest = tmp.join("bin/unlucky-launcher.exe");
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));
match report {
Report::Applied(ApplyReport::Failed(failure)) => {
assert_eq!(failure.kind, "digest_mismatch", "失败分类要能区分摘要不符");
assert!(failure.retryable, "摘要不符是可重试的(换源/重下可能变好)");
}
other => panic!("摘要不符必须拒绝应用,实际 {other:?}"),
}
// 目标文件一个字节都没动
assert_eq!(
std::fs::read(&dest).expect("读旧版本"),
b"old-binary-v1",
"校验失败不许碰目标文件"
);
// 暂存目录里不许留 .ullupd 中间文件
let staging = tmp.join("staging");
let leftovers: Vec<String> = std::fs::read_dir(&staging)
.map(|entries| {
entries
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect()
})
.unwrap_or_default();
assert!(
leftovers.is_empty(),
"校验失败后暂存目录里还有文件:{leftovers:?}"
);
}
/// 正常应用:新字节就位、旧字节进备份、回报是「已就位」。
#[test]
fn a_successful_apply_leaves_the_new_file_and_a_backup() {
let tmp = TempDir::new("apply");
let payload = b"new-binary-v2";
dir_source(
&tmp,
&manifest_for("0.9.0", payload, "修掉启动崩溃"),
payload,
);
let dest = tmp.join("bin/unlucky-launcher.exe");
std::fs::create_dir_all(dest.parent().expect("父目录")).expect("bin");
std::fs::write(&dest, b"old-binary-v1").expect("旧版本");
let applied = job(&tmp)
.apply(&version("0.2.2"), CHANNEL)
.expect("应用成功");
let Applied::Installed { receipt, version } = applied else {
panic!("应当装上");
};
assert_eq!(version, "0.9.0");
assert_eq!(std::fs::read(&dest).expect("读新版本"), payload);
let backup = receipt.backup.clone().expect("旧版本应留备份");
assert_eq!(
std::fs::read(&backup).expect("读备份"),
b"old-binary-v1",
"回滚用的旧版本要在备份里"
);
// 回报 → 快照:「就位」状态让提示条变成「重启后生效」
let mut runtime = Runtime::with_factory(&HostEnv::at_root(tmp.join("root")), factory(&tmp));
runtime.adopt(Report::applied(Ok(Applied::Installed { receipt, version })));
let view = runtime.snapshot();
assert!(view.ready.is_some(), "就位状态要进快照");
assert!(matches!(view.banner(), Some(UpdateBanner::Ready(_))));
assert!(!view.applying);
}
@@ -0,0 +1,145 @@
//! 会话状态的用例:拒绝后不再打扰、关掉自动检查后启动不派发、偏好跨重启、GUI/CLI 同一内核。
//!
//! 夹具在父模块,本文件 `use super::*`。
use super::*;
// ------------------------------------------------------------------ 会话状态
/// 拒绝之后提示条消失,但候选留着;手动检查把「拒绝」清掉。
#[test]
fn dismissed_stops_the_banner_but_a_manual_check_revives_it() {
let tmp = TempDir::new("dismiss");
let mut runtime = Runtime::with_factory(&HostEnv::at_root(tmp.join("root")), factory(&tmp));
runtime.adopt(Report::Checked(UpdateOutcome::Available(Box::new(
crate::dto::UpdateCandidateView {
version: "0.9.0".to_string(),
size: 12,
notes: String::new(),
forced: false,
},
))));
assert!(matches!(
runtime.view().banner(),
Some(UpdateBanner::Available(_))
));
runtime.dismiss();
assert!(runtime.view().banner().is_none(), "拒绝后本次不再打扰");
assert!(
runtime.view().outcome.candidate().is_some(),
"候选不能丢:设置页还要能手动装"
);
// 静默检查**回来之后**也不许把拒绝弹回来(`adopt` 才是真正写状态的那一步)
let _ = runtime.begin_check(false).expect("静默检查派发");
runtime.adopt(Report::Checked(UpdateOutcome::Available(Box::new(
crate::dto::UpdateCandidateView {
version: "0.9.0".to_string(),
size: 12,
notes: String::new(),
forced: false,
},
))));
assert!(
runtime.view().dismissed,
"静默检查的结论回来时也不能清掉用户的拒绝"
);
assert!(
runtime.view().banner().is_none(),
"被拒绝的版本不许因为一次静默检查又弹出来"
);
// 手动检查 ⇒ 清掉拒绝,重新查
let job = runtime.begin_check(true).expect("手动检查派发");
assert!(!runtime.view().dismissed, "手动检查要清掉拒绝标记");
assert!(runtime.view().outcome.is_checking());
// 静默检查(启动时)**不**清拒绝标记
runtime.adopt(Report::Checked(UpdateOutcome::UpToDate { latest: None }));
runtime.dismiss();
let _ = runtime.begin_check(false).expect("静默检查派发");
assert!(runtime.view().dismissed, "静默检查不许把用户的拒绝弹回来");
drop(job);
}
/// 关掉「启动时检查」之后:状态上是 no-op、启动路径上根本不派发。
#[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("写偏好");
let runtime = Runtime::with_factory(&env, factory(&tmp));
assert!(!runtime.view().auto_check, "开关要读进快照");
assert!(
!runtime.should_check_on_start(),
"关掉开关后启动不许派发检查"
);
// 结构性判据:`refresh_all` 里那次静默检查必须挂在 `should_check_on_start` 上,
// 否则单测绿着而启动仍然联网(这条读源码,理由同 `instances/tests.rs` 的源码断言)。
let host = include_str!("mod.rs");
assert!(
host.contains("if self.update.should_check_on_start()"),
"refresh_all 的静默检查必须由 should_check_on_start 门控"
);
}
/// 偏好能落盘、能读回(换一份 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, "缺省开");
runtime.set_auto_check(false).expect("落盘");
let restarted = Runtime::with_factory(&env, factory(&tmp));
assert!(!restarted.prefs().auto_check, "重启后仍记得关掉");
assert!(!restarted.view().auto_check);
}
/// GUI 与 CLI 共用**同一个** `Job::apply`(mission:不许两套逻辑)。
///
/// 判据是源码锚点:CLI 的 `update apply` 必须调 `Job::apply`,而不是自己拼一遍
/// 「下载 + 校验 + 替换」。
#[test]
fn the_gui_and_the_cli_share_one_kernel_path() {
let cli = include_str!("../../../cli/src/commands/update.rs");
assert!(
cli.contains("Job::") && cli.contains(".apply("),
"CLI 必须复用 presenter 的 Job::apply(同一内核),实际没有调用"
);
// 反向:check 也只许走 Job::check
assert!(
cli.contains(".check("),
"CLI 的 update check 必须走 Job::check"
);
}
/// 辅助:装配自证用的是哪份源与哪个替换目标(日志/诊断用)。
#[test]
fn the_factory_names_its_source_and_target() {
let tmp = TempDir::new("source-id");
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");
}
/// CLI 与 GUI 用的是**同一个** core Job:源码锚点,防止有人另起一条流程。
#[test]
fn the_core_job_is_the_single_kernel() {
let job = include_str!("../../../core/src/update/job.rs");
assert!(
job.contains("pub fn apply(") && job.contains("pub fn check("),
"内核必须同时提供 check / apply"
);
assert!(
job.contains("install_staged(") && job.contains("stage("),
"替换与校验必须在内核里做(壳层不许自己拼)"
);
}
+226
View File
@@ -0,0 +1,226 @@
//! 更新状态的**会话层**:一份 `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.dismissed = false;
}
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;
self.view.checked_at = super::update::stamp();
match report {
Report::Checked(outcome) => {
self.view.outcome = outcome;
// 只有**用户主动查**才清掉「忽略这个版本」;静默检查(启动时/自动)一律不许把
// 用户的拒绝弹回来(mission:拒绝之后本次不再打扰)。手动那一路在
// `begin_check` 里就已经清过了,这里的条件只是让「谁清」这件事只有一条判据。
if !self.view.silent && self.view.outcome.candidate().is_some() {
self.view.dismissed = false;
}
}
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;
}
}
}
/// 用户拒绝这次更新:本次会话不再弹提示条(候选仍在快照里,设置页可手动装)。
pub fn dismiss(&mut self) {
self.view.dismissed = true;
}
}
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(&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) {
super::job::spawn("update-apply", move || {
let report = Report::applied(job.apply(&current, CHANNEL));
let _ = tx.send(super::HostTask::Update(Box::new(report)));
});
}
#[cfg(test)]
#[path = "update_tests.rs"]
mod tests;
+244
View File
@@ -0,0 +1,244 @@
//! 自更新宿主通道的单测(M159)。
//!
//! 全部离线:清单与产物都在临时目录里造([`Source::Dir`]),**没有一个用例发公网请求**。
//! 覆盖 mission 的验收条目:
//!
//! | 用例 | 守什么 |
//! |---|---|
//! | [`version_matrix_decides_who_gets_prompted`] | 版本更高才提示;同版本/更低一律不提示;`v0.2.3` 这类真实 tag 形态 |
//! | [`a_broken_manifest_degrades_to_failure_not_up_to_date`] | 清单坏 JSON → 「检查失败」,**不是**「已是最新」 |
//! | [`a_higher_version_without_our_platform_is_not_up_to_date`] | 有新版但没本平台产物 → 单独的变体 |
//! | [`sha256_mismatch_refuses_the_apply_and_leaves_no_temp_file`] | 摘要不符拒绝应用、暂存文件不残留、目标没被换掉 |
//! | [`a_successful_apply_leaves_the_new_file_and_a_backup`] | 换上去的是新字节、旧字节留在备份里、回报是「就位」 |
//! | [`dismissed_stops_the_banner_but_a_manual_check_revives_it`] | 拒绝后不再打扰;手动检查清掉拒绝 |
//! | [`auto_check_off_means_no_request_on_start`] | 关掉开关后启动不派发检查(结构性 + 状态两层) |
//! | [`the_gui_and_the_cli_share_one_kernel_path`] | GUI/CLI 走同一个 `Job`(源码锚点,不是口号) |
use std::path::{Path, PathBuf};
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 crate::dto::{UpdateBanner, UpdateOutcome};
use super::super::env::HostEnv;
use super::super::update::{outcome_of, ApplyReport, Factory, Prefs, Report, CHANNEL};
use super::Runtime;
/// 一次性临时目录。
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("ull-m159-{}-{tag}-{unique}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时目录");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
fn join(&self, name: &str) -> PathBuf {
self.path.join(name)
}
fn write(&self, name: &str, bytes: &[u8]) -> PathBuf {
let path = self.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建父目录");
}
std::fs::write(&path, bytes).expect("写文件");
path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn sha256(bytes: &[u8]) -> String {
digest_bytes(HashAlgo::Sha256, bytes)
}
/// 本平台在清单里的 `target` 写法。
fn here() -> String {
Target::current().as_str().to_string()
}
/// 一份「本平台有产物」的清单(摘要按 `payload` 真实值算)。
fn manifest_for(version: &str, payload: &[u8], notes: &str) -> String {
format!(
r#"{{"channel":"stable","releases":[{{"version":"{version}","channel":"stable","notes":"{notes}",
"artifacts":[{{"target":"{}","format":"portable-exe","url":"asset.exe","size":{},"sha256":"{}"}}]}}]}}"#,
here(),
payload.len(),
sha256(payload)
)
}
/// 造一个本地更新源目录(`dist/stable.json` + `dist/asset.exe`)。
fn dir_source(tmp: &TempDir, manifest: &str, payload: &[u8]) {
tmp.write("dist/stable.json", manifest.as_bytes());
tmp.write("dist/asset.exe", payload);
}
/// 造一份装配:本地源 + 临时目标 + 临时暂存。
fn factory(tmp: &TempDir) -> Factory {
Factory::for_env(&HostEnv::at_root(tmp.join("root")))
.with_dir(tmp.join("dist"))
.with_dest(tmp.join("bin/unlucky-launcher.exe"))
.with_staging(tmp.join("staging"))
}
/// 一份 job(走同一份装配)。
fn job(tmp: &TempDir) -> unlucky_core::update::Job {
factory(tmp).job()
}
fn version(text: &str) -> Version {
Version::parse(text).expect("合法版本")
}
// ------------------------------------------------------------------ 版本矩阵
/// 版本更高才提示;同版本 / 更低一律不提示;真实 tag 形态(`v0.2.3`)照常解析。
///
/// 这条是 mission 第二条任务的核心判据,**判定本身在 core**(`UpdateChecker`),这里守的是
/// 「host 把它翻成 DTO 之后仍然如此」——两处任何一处把 `<=` 写成 `<` 都会红。
#[test]
fn version_matrix_decides_who_gets_prompted() {
// (当前版本, 清单里的版本, 期望「有更新」)
let cases = [
("0.2.2", "0.2.3", true),
("0.2.2", "v0.2.3", true),
("0.2.2", "0.2.2", false),
("0.2.2", "v0.2.2", false),
("0.2.2", "0.2.1", false),
("0.2.2", "0.10.0", true),
("0.2.2", "0.2.10", true),
("0.2.2", "0.2.2-rc.1", false),
("0.2.2-rc.1", "0.2.2", true),
];
for (current, listed, expected) in cases {
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));
assert_eq!(
outcome.candidate().is_some(),
expected,
"当前 {current} vs 清单 {listed}:期望 available={expected},实际 {outcome:?}"
);
if !expected {
assert!(
matches!(outcome, UpdateOutcome::UpToDate { .. }),
"不提示的时候必须是「已是最新」,实际 {outcome:?}"
);
}
}
}
// ------------------------------------------------------------- 清单契约降级
/// 坏 JSON / 缺字段 ⇒ **检查失败**,绝不是「已是最新」。
///
/// 两个变体的文案与后果完全不同:说成「已是最新」会让用户以为网络通、版本新,实际一个字节都没取到。
#[test]
fn a_broken_manifest_degrades_to_failure_not_up_to_date() {
for (label, manifest) in [
("not json", "{ this is not json".to_string()),
(
"wrong field type",
r#"{"channel":"stable","releases":42}"#.to_string(),
),
(
"bad digest",
format!(
r#"{{"channel":"stable","releases":[{{"version":"0.9.0","channel":"stable","artifacts":[{{"target":"{}","format":"portable-exe","url":"a.exe","size":3,"sha256":"zz"}}]}}]}}"#,
here()
),
),
(
"missing sha256",
format!(
r#"{{"channel":"stable","releases":[{{"version":"0.9.0","channel":"stable","artifacts":[{{"target":"{}","format":"portable-exe","url":"asset.exe","size":3}}]}}]}}"#,
here()
),
),
("bad version", manifest_for("not-a-version", b"abc", "")),
] {
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));
assert!(
matches!(outcome, UpdateOutcome::Failed(_)),
"{label}:坏清单必须降级成「检查失败」,实际 {outcome:?}"
);
let failure = outcome.failure().expect("失败带原因");
assert!(
!failure.message.trim().is_empty(),
"{label}:失败要有人话原因"
);
}
}
/// 清单里**一条发布都没有**(`{"channel":"stable"}` / 空 `releases`)⇒ 已是最新,不是失败。
///
/// 这是 core 的既定口径(「该渠道还没发过版」不是错误),与上面那条「坏清单」要分得清清楚楚:
/// 少一个字段是坏文件,空列表是「还没发」。两者都被报成同一种状态的话,发布脚本第一次上线
/// 之前所有人都会看到红字。
#[test]
fn an_empty_manifest_means_nothing_published_yet() {
for manifest in [
r#"{"channel":"stable"}"#.to_string(),
r#"{"channel":"stable","releases":[]}"#.to_string(),
] {
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));
assert!(
matches!(&outcome, UpdateOutcome::UpToDate { latest: None }),
"空清单 = 还没发过版,实际 {outcome:?}"
);
}
}
/// 有更高的版本、但里面没有本平台的产物 ⇒ 单独的变体(不是「已是最新」,也不是「有更新」)。
#[test]
fn a_higher_version_without_our_platform_is_not_up_to_date() {
let tmp = TempDir::new("missing-artifact");
tmp.write(
"dist/stable.json",
format!(
r#"{{"channel":"stable","releases":[{{"version":"0.9.0","channel":"stable","artifacts":[{{"target":"{}","format":"portable-exe","url":"asset.exe","size":3,"sha256":"{}"}}]}}]}}"#,
// 故意给一个不可能匹配本机的目标(`unknown` 架构永远对不上)
"linux-x86",
sha256(b"abc")
)
.as_bytes(),
);
let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), CHANNEL));
assert!(
matches!(&outcome, UpdateOutcome::MissingArtifact { version } if version == "0.9.0"),
"缺本平台产物要单独报出来,实际 {outcome:?}"
);
assert_eq!(outcome.as_str(), "missing_artifact");
}
// 两个子模块各守一段(拆开只为守 400 行硬线;夹具在本模块,子模块 `use super::*` 共用,
// 不重复抄一份)。
#[path = "update_apply_tests.rs"]
mod apply;
#[path = "update_session_tests.rs"]
mod session;
+5
View File
@@ -25,6 +25,7 @@ use crate::dto::{
PackState,
Progress,
SettingGroupView,
UpdateView,
VersionSummary,
};
use crate::prefs::UiPrefs;
@@ -115,6 +116,8 @@ pub struct AppState {
pub settings_tab: String,
/// 横幅通知
pub notices: Vec<NoticeView>,
/// 自更新(M159):检查结论 / 就位状态 / 是否已拒绝(提示条与设置页共用)
pub update: UpdateView,
/// 新建实例向导
pub wizard: WizardState,
/// 正在实时尾随的会话
@@ -161,6 +164,7 @@ impl AppState {
settings_groups: Vec::new(),
settings_tab: "download".to_string(),
notices: Vec::new(),
update: UpdateView::default(),
wizard: WizardState::new(),
live_session: None,
content: ContentState::default(),
@@ -281,6 +285,7 @@ impl AppState {
.insert(mc_version, result);
}
CoreEvent::NoticesChanged(notices) => self.notices = notices,
CoreEvent::Update(view) => self.update = *view,
// M113: import/export
CoreEvent::Pack(event) => self.pack.apply(*event),
CoreEvent::ContentResults(results) => self.apply_content(*results),