diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index fb904e6..434dcf6 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -51,6 +51,7 @@ mod download; mod instances; mod page_messages; mod settings; +mod update_action; impl App { /// 一条消息进来,改状态并返回后续任务。 diff --git a/crates/app-iced/src/app/update/instances.rs b/crates/app-iced/src/app/update/instances.rs index 0abfbe2..10fec0c 100644 --- a/crates/app-iced/src/app/update/instances.rs +++ b/crates/app-iced/src/app/update/instances.rs @@ -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}; diff --git a/crates/app-iced/src/app/update/page_messages.rs b/crates/app-iced/src/app/update/page_messages.rs index e661e36..afe52b9 100644 --- a/crates/app-iced/src/app/update/page_messages.rs +++ b/crates/app-iced/src/app/update/page_messages.rs @@ -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` 里穷尽分发 diff --git a/crates/app-iced/src/app/update/update_action.rs b/crates/app-iced/src/app/update/update_action.rs new file mode 100644 index 0000000..4d45dbc --- /dev/null +++ b/crates/app-iced/src/app/update/update_action.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 { + 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 { + if self.options.screenshot.is_some() { + eprintln!("[update] 截图通道不重启(忽略「立即重启」)"); + return Task::none(); + } + match std::env::current_exe() { + Ok(exe) => { + let args: Vec = 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>>, + } + + impl Backend for Recorder { + fn drain_events(&mut self) -> Vec { + 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>>) { + 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>>) -> 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(), + "重启不是一条后端指令(不该派发任何命令)" + ); + } +} diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index cc69040..983ef76 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -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` 就是静息态,不叠状态层。 diff --git a/crates/app-iced/src/app/view/update.rs b/crates/app-iced/src/app/view/update.rs new file mode 100644 index 0000000..4715203 --- /dev/null +++ b/crates/app-iced/src/app/view/update.rs @@ -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()); + } +} diff --git a/crates/app-iced/src/pages/harness/app.rs b/crates/app-iced/src/pages/harness/app.rs index bcb227d..1fcb3dd 100644 --- a/crates/app-iced/src/pages/harness/app.rs +++ b/crates/app-iced/src/pages/harness/app.rs @@ -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} \ 配色来源={:?} 选中分组={}", diff --git a/crates/app-iced/src/pages/instances/demo.rs b/crates/app-iced/src/pages/instances/demo.rs index 4d10619..87e201d 100644 --- a/crates/app-iced/src/pages/instances/demo.rs +++ b/crates/app-iced/src/pages/instances/demo.rs @@ -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()) } diff --git a/crates/app-iced/src/pages/instances/launch_args.rs b/crates/app-iced/src/pages/instances/launch_args.rs index 8f64e3d..9a631b7 100644 --- a/crates/app-iced/src/pages/instances/launch_args.rs +++ b/crates/app-iced/src/pages/instances/launch_args.rs @@ -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)); } diff --git a/crates/app-iced/src/pages/instances/tests.rs b/crates/app-iced/src/pages/instances/tests.rs index 0385462..96db157 100644 --- a/crates/app-iced/src/pages/instances/tests.rs +++ b/crates/app-iced/src/pages/instances/tests.rs @@ -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}; diff --git a/crates/app-iced/src/pages/instances/tests/launch_args.rs b/crates/app-iced/src/pages/instances/tests/launch_args.rs new file mode 100644 index 0000000..fe85333 --- /dev/null +++ b/crates/app-iced/src/pages/instances/tests/launch_args.rs @@ -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)), + "门限关闭时至少有一档可点(否则三态整体锁死)" + ); +} diff --git a/crates/app-iced/src/pages/message.rs b/crates/app-iced/src/pages/message.rs index b4966bb..63d9676 100644 --- a/crates/app-iced/src/pages/message.rs +++ b/crates/app-iced/src/pages/message.rs @@ -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 { diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index b508ac1..8e29894 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -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 } diff --git a/crates/app-iced/src/pages/model.rs b/crates/app-iced/src/pages/model.rs index c66addf..123ea7a 100644 --- a/crates/app-iced/src/pages/model.rs +++ b/crates/app-iced/src/pages/model.rs @@ -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, } diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index d06d737..f578ad6 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -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, 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 diff --git a/crates/app-iced/src/pages/settings/tests/mod.rs b/crates/app-iced/src/pages/settings/tests/mod.rs index 3159dfc..5546c52 100644 --- a/crates/app-iced/src/pages/settings/tests/mod.rs +++ b/crates/app-iced/src/pages/settings/tests/mod.rs @@ -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(), "演示下标仍在范围内"); } diff --git a/crates/app-iced/src/pages/settings/tests/update_section.rs b/crates/app-iced/src/pages/settings/tests/update_section.rs new file mode 100644 index 0000000..f97423c --- /dev/null +++ b/crates/app-iced/src/pages/settings/tests/update_section.rs @@ -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()); +} diff --git a/crates/app-iced/src/pages/settings/update.rs b/crates/app-iced/src/pages/settings/update.rs new file mode 100644 index 0000000..afc42d7 --- /dev/null +++ b/crates/app-iced/src/pages/settings/update.rs @@ -0,0 +1,257 @@ +//! 设置页「更新」分组(M159):当前版本 / 渠道 / 自动检查开关 / 立即检查 / 上次结果。 +//! +//! ## 为什么这一组不走 `Group.body` +//! +//! `Group.body` 的签名是 `fn(&Ui, States) -> Option`,而 `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) +} diff --git a/crates/app-iced/src/pages/update.rs b/crates/app-iced/src/pages/update.rs new file mode 100644 index 0000000..0d5cdfe --- /dev/null +++ b/crates/app-iced/src/pages/update.rs @@ -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, + }, + /// 有可装的更新 + 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, + /// 用户是否已忽略这个版本(本次会话) + 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> { + 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> { + let kind = update.banner()?; + + // 三种形态各自的底色:有新版 = 次级容器(可操作、非错误);进行中 = 同色但只读; + // 就位 = 主容器(「已经好了,重启即生效」是正反馈)。 + let (title, body, actions, role): (String, String, Vec>, 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 { .. }))); + } +} diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs index 620957b..494ca4b 100644 --- a/crates/cli/src/cli/help.rs +++ b/crates/cli/src/cli/help.rs @@ -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 ] 下载 → 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 ] + +自更新(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=:改走本地目录源(`/stable.json` + 产物), +用于内网手工分发与「发现新版本」这条分支的整环取证;不设就走 GitHub。 +--json 输出 {\"data\":{\"status\":…,\"current\":…,\"version\":…}},脚本按 status 分流。", ), "install" => Some( "\ diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index 0ddc3af..91b55dc 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -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; diff --git a/crates/cli/src/cli/model.rs b/crates/cli/src/cli/model.rs index ad6c92a..c37e7de 100644 --- a/crates/cli/src/cli/model.rs +++ b/crates/cli/src/cli/model.rs @@ -69,6 +69,10 @@ pub enum Command { }, /// `agent serve`:对外 MCP server(长驻)。 AgentServe(AgentServeArgs), + /// `update check`:查一次更新,不动任何文件。 + UpdateCheck(UpdateCheckArgs), + /// `update apply [--dest ]`:下载 → 校验 → 替换(默认替换本进程 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, +} + /// `agent serve` 的参数(命令行只做覆盖,默认值来自设置键 `agent.mcp.*`)。 #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AgentServeArgs { diff --git a/crates/cli/src/cli/parse.rs b/crates/cli/src/cli/parse.rs index 1b7c639..0f0247f 100644 --- a/crates/cli/src/cli/parse.rs +++ b/crates/cli/src/cli/parse.rs @@ -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 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 { } } +/// `update check` / `update apply [--dest ]`。 +fn parse_update(tail: &[String]) -> Result { + 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 { match tail.first().map(String::as_str) { None | Some("list") => { diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index b3b2b9c..4adeb9a 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -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 { 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 } => { diff --git a/crates/cli/src/commands/update.rs b/crates/cli/src/commands/update.rs new file mode 100644 index 0000000..673c1cf --- /dev/null +++ b/crates/cli/src/commands/update.rs @@ -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 { + 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) -> 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 { + let current = current_version()?; + let job = job(ctx, None); + let outcome = job + .check(¤t, CHANNEL) + .map_err(|error| failure("update_check_failed", "检查更新", &error))?; + Ok(Outcome::new(report_of(¤t, job.source_id(), &outcome))) +} + +/// `ull update apply`:复查 → 下载 → 双检 → 替换(失败自动清理/回滚)。 +/// +/// # Errors +/// +/// 复查、取包、校验、替换任一步失败 → 用户错误(退出码 1)。 +pub fn apply(ctx: &Ctx, args: &UpdateApplyArgs) -> Result { + let current = current_version()?; + let job = job(ctx, args.dest.clone()); + let dest = job.dest().display().to_string(); + match job + .apply(¤t, 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(¤t, 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() + )], + ) + } + } +} diff --git a/crates/core/src/update/job.rs b/crates/core/src/update/job.rs new file mode 100644 index 0000000..94656eb --- /dev/null +++ b/crates/core/src/update/job.rs @@ -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` 根目录(`/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, + 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, + target: Target, + staging: impl Into, + dest: impl Into, + ) -> Self { + Self { + source, + target, + staging: staging.into(), + dest: dest.into(), + } + } + + /// 本进程的默认装配:目标平台 = 编译目标,替换目标 = 本进程 exe, + /// 暂存 = `/update/staging`。 + #[must_use] + pub fn for_process(source: Box, cache_root: &Path) -> Self { + Self::new( + source, + Target::current(), + staging_dir(cache_root), + process_dest(), + ) + } + + /// 换替换目标(`ull update apply --dest `、单测都走它)。 + #[must_use] + pub fn with_dest(mut self, dest: impl Into) -> 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 { + UpdateChecker::new(SourceRef(&*self.source), self.target).check(current, channel) + } + + /// 下载 → 双检 → 原子替换。 + /// + /// # Errors + /// + /// 复查失败、取包失败、长度/摘要不符、替换失败都返回 [`UpdateError`]; + /// 「复查之后没有可装的更新」不是错误,走 [`Applied::Nothing`]。 + pub fn apply(&self, current: &Version, channel: Channel) -> Result { + 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, UpdateError> { + self.0.fetch_manifest(channel) + } + + fn fetch_artifact( + &self, + artifact: &super::manifest::Artifact, + dest: &Path, + ) -> Result { + 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")) +} + +/// 暂存目录(`/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) -> Box { + 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)), + } +} diff --git a/crates/core/src/update/mod.rs b/crates/core/src/update/mod.rs index 0b5738b..edd7e73 100644 --- a/crates/core/src/update/mod.rs +++ b/crates/core/src/update/mod.rs @@ -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}; diff --git a/crates/core/src/update/source.rs b/crates/core/src/update/source.rs index f79a69d..7184a9a 100644 --- a/crates/core/src/update/source.rs +++ b/crates/core/src/update/source.rs @@ -52,6 +52,25 @@ pub trait UpdateSource: Send + Sync { fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result; } +/// `Box` 也是 `UpdateSource`。 +/// +/// 为什么需要:壳层要**运行期**才决定用哪个源(生产走 GitHub、测试/内网走本地目录),而 +/// [`super::UpdateChecker`] 与 [`super::verify::stage`] 都是按 trait 泛型化的。没有这条转发, +/// 调用方只能给每种源写一份检查/下载流程——那正是 mission 禁止的「两套逻辑」。 +impl UpdateSource for Box { + fn source_id(&self) -> &str { + (**self).source_id() + } + + fn fetch_manifest(&self, channel: Channel) -> Result, UpdateError> { + (**self).fetch_manifest(channel) + } + + fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result { + (**self).fetch_artifact(artifact, dest) + } +} + /// 本地目录源(也接受 `file://` 根地址)。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct DirSource { diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index 3f76194..d6a5f44 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -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, {} } diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index 761ca11..c155643 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -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", "重试"), ]; diff --git a/crates/i18n/src/catalog/update.rs b/crates/i18n/src/catalog/update.rs new file mode 100644 index 0000000..9aadb7e --- /dev/null +++ b/crates/i18n/src/catalog/update.rs @@ -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; diff --git a/crates/presenter/src/dto/events.rs b/crates/presenter/src/dto/events.rs index 6181e25..e54dcda 100644 --- a/crates/presenter/src/dto/events.rs +++ b/crates/presenter/src/dto/events.rs @@ -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), + /// 自更新(M159):整份状态快照(检查结论 + 就位状态 + 用户是否已拒绝)。 + /// + /// 与 `SettingsChanged` 同一条形态:快照整体替换,UI 不做增量合并。 + Update(Box), } /// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路, @@ -237,6 +243,15 @@ pub enum AppCommand { // M113: import/export /// 整合包导入/导出(M113):导入 / 预检 / 确认写盘 / 关面板(子枚举见 [`PackCommand`]) Pack(Box), + // M159: 自更新 + /// 查一次更新(设置页「立即检查」;会清掉「忽略这个版本」的标记并重新请求网络) + CheckUpdate, + /// 把当前候选下载下来、校验、替换本程序(结果经 [`CoreEvent::Update`] 回来) + ApplyUpdate, + /// 忽略这个版本(本次会话不再弹提示条;候选仍留在快照里,设置页可手动装) + DismissUpdate, + /// 开关「启动时静默检查」(写进更新域自己的偏好文件) + SetUpdateAutoCheck(bool), } impl AppCommand { diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 233ad3f..843bcfd 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -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}; diff --git a/crates/presenter/src/dto/update.rs b/crates/presenter/src/dto/update.rs new file mode 100644 index 0000000..2f94004 --- /dev/null +++ b/crates/presenter/src/dto/update.rs @@ -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, + }, + /// 有可装的更新。 + Available(Box), + /// 有更高的版本,但里面没有本平台的产物(发布侧漏项,不能对用户说「已是最新」)。 + 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, 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, +} + +/// 提示条此刻要显示什么。 +/// +/// 「检查失败」只有在**用户主动查**的时候才弹条([`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, + /// 用户点了「忽略这个版本」:本次会话不再弹提示条(设置页手动检查会清掉它)。 + 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, 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> { + 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); + } +} diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index d310a8f..2e14c44 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -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); diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index d203296..42045d7 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -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)), } } diff --git a/crates/presenter/src/host/job.rs b/crates/presenter/src/host/job.rs index 74b497c..54fe7f0 100644 --- a/crates/presenter/src/host/job.rs +++ b/crates/presenter/src/host/job.rs @@ -132,6 +132,8 @@ pub enum HostTask { Toast(Box), /// 会话列表刷新(只换左栏) Sessions(Vec), + /// 自更新(M159):检查/应用的回报(载荷已翻成 DTO,见 `host::update::Report`) + Update(Box), } /// 后台线程用的发送端(`Sender` 克隆即可)。 diff --git a/crates/presenter/src/host/mod.rs b/crates/presenter/src/host/mod.rs index 0576a60..2a103c9 100644 --- a/crates/presenter/src/host/mod.rs +++ b/crates/presenter/src/host/mod.rs @@ -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); + } } /// 重建实例快照,并把**真实运行时**盖回去。 diff --git a/crates/presenter/src/host/update.rs b/crates/presenter/src/host/update.rs new file mode 100644 index 0000000..9490e22 --- /dev/null +++ b/crates/presenter/src/host/update.rs @@ -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`,不共享、不复用): +/// 这样「上一次用的源」不会跟着下一次跑,测试也能各用例各给一份本地目录。 +#[derive(Clone)] +pub enum Factory { + /// 生产/spec 形态:GitHub(或 `ULL_UPDATE_DIR` 指定的本地目录),引擎懒建一次。 + Default { + /// 下载引擎(镜像/地区/健康度策略都在里面)。 + engine: Arc, + /// 目标平台。 + target: Target, + /// 暂存目录。 + staging: PathBuf, + /// 替换目标。 + dest: PathBuf, + }, + /// 单测/取证:钉死一个本地目录源。 + Dir { + /// 源根目录(`/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) -> 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) -> 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) -> 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, + *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, + }, + /// 复查时已经没有可装的更新 + Nothing(UpdateOutcome), + /// 失败(分类 + 人话) + Failed(UpdateFailureView), +} + +impl Report { + /// 应用结局 → 回报(映射只在这一处)。 + #[must_use] + pub fn applied(result: Result) -> 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) -> 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::parse(text).ok() +} diff --git a/crates/presenter/src/host/update_apply_tests.rs b/crates/presenter/src/host/update_apply_tests.rs new file mode 100644 index 0000000..4b81b0d --- /dev/null +++ b/crates/presenter/src/host/update_apply_tests.rs @@ -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 = 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); +} diff --git a/crates/presenter/src/host/update_session_tests.rs b/crates/presenter/src/host/update_session_tests.rs new file mode 100644 index 0000000..79fc0cd --- /dev/null +++ b/crates/presenter/src/host/update_session_tests.rs @@ -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("), + "替换与校验必须在内核里做(壳层不许自己拼)" + ); +} diff --git a/crates/presenter/src/host/update_state.rs b/crates/presenter/src/host/update_state.rs new file mode 100644 index 0000000..04b0a0c --- /dev/null +++ b/crates/presenter/src/host/update_state.rs @@ -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::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 { + 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 { + 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(¤t, CHANNEL))); + let _ = tx.send(super::HostTask::Update(Box::new(report))); + }); +} + +/// 起一条应用线程(下载 + 双检 + 替换都在里面)。 +pub fn spawn_apply(job: unlucky_core::update::Job, current: Version, tx: super::JobSender) { + super::job::spawn("update-apply", move || { + let report = Report::applied(job.apply(¤t, CHANNEL)); + let _ = tx.send(super::HostTask::Update(Box::new(report))); + }); +} + +#[cfg(test)] +#[path = "update_tests.rs"] +mod tests; diff --git a/crates/presenter/src/host/update_tests.rs b/crates/presenter/src/host/update_tests.rs new file mode 100644 index 0000000..b8a5ab5 --- /dev/null +++ b/crates/presenter/src/host/update_tests.rs @@ -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; diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index a3b6518..526f862 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -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, + /// 自更新(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),