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