refactor(core): 拆分 model/mqtt/update 为单一职责子模块

- model/ 按领域拆为 connection/subscription/message/publish/variable/log/settings
- mqtt/ 拆为门面(引擎与事件)、tls、v5、v4 四块
- update/ 拆为门面(版本工具与命名约定)、check(检查)、install(下载/安装/清理)
- sysmon 只保留进程资源采样,窗口置顶迁至新 platform.rs
- 对外路径经 mod.rs 再导出保持不变,测试随门面保留(33 通过)
This commit is contained in:
2026-09-29 22:20:53 +08:00
parent 303b348ffe
commit b5089f5898
21 changed files with 3393 additions and 3252 deletions
+319
View File
@@ -0,0 +1,319 @@
//! 更新执行:下载暂存(7z 优先)→ sha256 校验 → 重启自替换安装,以及同意标记与旧档清理。
use std::io::Read;
use std::path::PathBuf;
use super::check::agent;
use super::*;
/// 暂存目录:数据目录下的 `updates/`,重启不丢失。
fn stage_dir() -> Result<PathBuf, String> {
let dir = dirs::data_dir()
.unwrap_or_else(std::env::temp_dir)
.join("MQTTX-GPUI")
.join("updates");
std::fs::create_dir_all(&dir).map_err(|e| format!("创建暂存目录失败: {e}"))?;
Ok(dir)
}
/// 下载并校验到暂存区,返回待安装的更新。
///
/// 附件为 7z 压缩包:下载 → 校验 → 解压出二进制暂存(压缩包随即删除)。
/// 回退路径(裸二进制附件)直接校验暂存。
/// `on_progress(downloaded, total)` 在阻塞线程上回调,total 为 0 表示未知。
pub fn download_and_stage(
info: &UpdateInfo,
on_progress: &dyn Fn(u64, u64),
) -> Result<StagedUpdate, String> {
let dir = stage_dir()?;
let binary_name = binary_name_for(&info.version);
let is_7z = info.asset_name.ends_with(".7z");
let final_name = if is_7z {
asset_name_for(&info.version)
} else {
binary_name.clone()
};
// tmp 名加入毫秒时间戳:同 pid 并发下载不共用文件
let tmp = dir.join(format!(
"{}.{}.{}.tmp",
final_name,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
));
let dst = dir.join(&final_name);
if let Err(e) = download_to(info, &tmp, on_progress) {
// 失败路径统一清理临时文件
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
// 清掉其他版本的残留暂存文件,避免堆积与误装旧版
let staged_path = if is_7z {
// 解压到暂存目录,取出二进制后删掉压缩包
if let Err(e) = sevenz_rust::decompress_file(&tmp, &dir) {
let _ = std::fs::remove_file(&tmp);
return Err(format!("解压更新包失败: {e}"));
}
let _ = std::fs::remove_file(&tmp);
let extracted = dir.join(&binary_name);
if !extracted.exists() {
return Err(format!(
"更新包内缺少 {},压缩包内容异常",
binary_name
));
}
// 确认解压产物后再清理其他版本,最后返回二进制路径
remove_other_staged(&dir, &extracted);
extracted
} else {
remove_other_staged(&dir, &dst);
std::fs::rename(&tmp, &dst).map_err(|e| format!("暂存失败: {e}"))?;
dst
};
Ok(StagedUpdate {
version: info.version.clone(),
path: staged_path,
})
}
fn download_to(info: &UpdateInfo, tmp: &std::path::Path, on_progress: &dyn Fn(u64, u64)) -> Result<(), String> {
let resp = agent()
.get(&info.asset_url)
.call()
.map_err(|e| format!("下载失败: {e}"))?;
let mut reader = resp.into_reader();
let total = info.size;
let mut file = std::fs::File::create(tmp).map_err(|e| format!("创建临时文件失败: {e}"))?;
let mut buf = [0u8; 64 * 1024];
let mut downloaded: u64 = 0;
let mut hasher = sha2::Sha256::new();
loop {
let n = reader.read(&mut buf).map_err(|e| format!("下载中断: {e}"))?;
if n == 0 {
break;
}
std::io::Write::write_all(&mut file, &buf[..n])
.map_err(|e| format!("写入临时文件失败: {e}"))?;
hasher.update(&buf[..n]);
downloaded += n as u64;
on_progress(downloaded, total);
}
std::io::Write::flush(&mut file).ok();
drop(file);
// 传输完整性:API 提供了大小却没下满/超量,视为损坏(read 返回 0
// 无法区分干净结束与连接提前断开,必须与预期大小对账)
if total > 0 && downloaded != total {
return Err(format!(
"下载不完整:期望 {total} 字节,实际 {downloaded} 字节"
));
}
// sha256 校验
if let Some(expect) = fetch_expected_sha256(info)? {
let actual = format!("{:x}", hasher.finalize());
if !actual.eq_ignore_ascii_case(&expect) {
return Err(format!(
"校验失败:期望 sha256 {expect},实际 {actual}"
));
}
}
Ok(())
}
/// 删除暂存目录中除 `keep` 外的其他版本暂存文件。
fn remove_other_staged(dir: &std::path::Path, keep: &std::path::Path) {
if let Ok(entries) = std::fs::read_dir(dir) {
for e in entries.flatten() {
let p = e.path();
if p == *keep {
continue;
}
let name = e.file_name();
let name = name.to_string_lossy();
if name.starts_with("mqttx-v")
&& name.contains(platform_triple())
&& !name.ends_with(".tmp")
&& !name.ends_with(".sha256")
&& !name.ends_with(".7z")
{
let _ = std::fs::remove_file(&p);
}
}
}
}
/// 读取 `.sha256` 侧车内容,返回其中的哈希值。没有侧车时返回 None。
fn fetch_expected_sha256(info: &UpdateInfo) -> Result<Option<String>, String> {
let Some(url) = &info.sha256_url else {
return Ok(None);
};
let text = agent()
.get(url)
.call()
.map_err(|e| format!("获取校验文件失败: {e}"))?
.into_string()
.map_err(|e| format!("读取校验文件失败: {e}"))?;
// 常见格式:<hex> <filename> 或纯 hex
let hex = text
.split_whitespace()
.next()
.unwrap_or_default()
.to_lowercase();
if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(Some(hex))
} else {
Err("校验文件格式异常".into())
}
}
/// 多个版本并存时取最新版本(readdir 顺序不保证有序)。
pub fn load_staged() -> Option<StagedUpdate> {
let dir = stage_dir().ok()?;
let mut best: Option<StagedUpdate> = None;
for e in std::fs::read_dir(&dir).ok()?.flatten() {
if !e.path().is_file() {
continue;
}
let name = e.file_name();
let name = name.to_string_lossy().to_string();
let Some(version) = parse_staged_name(&name) else {
continue;
};
if !is_newer(&version, current_version()) {
continue;
}
if best.as_ref().is_none_or(|b| is_newer(&version, &b.version)) {
best = Some(StagedUpdate {
version,
path: e.path(),
});
}
}
best
}
/// 安装暂存的更新:替换自身并拉起新进程(旧文件改名 `.old`,
/// 新进程启动时由 [`cleanup_old`] 清理)。成功后调用方应退出当前进程。
pub fn install_staged(staged: &StagedUpdate) -> Result<(), String> {
let cur = std::env::current_exe().map_err(|e| format!("无法定位自身: {e}"))?;
let mut old_name = cur.clone().into_os_string();
old_name.push(".old");
let old = PathBuf::from(old_name);
// 旧残留先清掉,避免改名失败
let _ = std::fs::remove_file(&old);
std::fs::rename(&cur, &old).map_err(|e| format!("替换失败(可能无写入权限): {e}"))?;
if let Err(e) = std::fs::rename(&staged.path, &cur) {
// 回滚,尽量保住当前可执行文件
let rollback = std::fs::rename(&old, &cur);
return Err(format!(
"替换失败: {e};{}",
rollback_result(rollback, &old)
));
}
if let Err(e) = std::process::Command::new(&cur).spawn() {
// 新文件无法启动(常见:杀毒软件锁定刚落地的文件):恢复旧版本
let _ = std::fs::remove_file(&cur);
let rollback = std::fs::rename(&old, &cur);
return Err(format!(
"启动新版本失败: {e};{}",
rollback_result(rollback, &old)
));
}
clear_install_consent();
Ok(())
}
/// 描述回滚结果;回滚失败时提示 `.old` 备份仍保留在原处。
fn rollback_result(rollback: std::io::Result<()>, old: &std::path::Path) -> String {
match rollback {
Ok(()) => "已恢复原版本".to_string(),
Err(re) => format!(
"回滚失败({re}),原版本备份保留在 {}",
old.display()
),
}
}
/// 安装同意标记:仅当用户在更新对话框明确选择「下次启动安装」后才写入,
/// 启动时据此自动安装——用户未选择的更新不会被强制替换。
fn consent_path() -> Result<PathBuf, String> {
stage_dir().map(|d| d.join("install-consent.txt"))
}
/// 记录对指定版本的安装同意。
pub fn mark_install_consent(version: &str) {
if let Ok(p) = consent_path() {
let _ = std::fs::write(p, version);
}
}
/// 清除安装同意标记。
pub fn clear_install_consent() {
if let Ok(p) = consent_path() {
let _ = std::fs::remove_file(p);
}
}
/// 同意标记是否与暂存的版本匹配(版本不匹配视为未同意)。
pub fn install_consent_matches(staged_version: &str) -> bool {
consent_path()
.ok()
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|v| v.trim() == staged_version)
.unwrap_or(false)
}
/// 用户拒绝本次更新:删除暂存包与同意标记。
pub fn discard_staged(staged: &StagedUpdate) {
let _ = std::fs::remove_file(&staged.path);
clear_install_consent();
}
/// 启动时清理上次更新遗留的旧文件(改名失败的 `.old`、中断的 `.tmp`)。
pub fn cleanup_old() {
if let Ok(cur) = std::env::current_exe()
&& let Some(dir) = cur.parent()
&& let Ok(entries) = std::fs::read_dir(dir)
{
for e in entries.flatten() {
// Windows 文件系统不区分大小写,统一按小写匹配
let name = e.file_name();
let lower = name.to_string_lossy().to_lowercase();
if (lower.ends_with(".old") && lower.starts_with("mqttx"))
|| lower.ends_with(".update.tmp")
{
let _ = std::fs::remove_file(e.path());
}
}
}
// 暂存目录里中断的 .tmp。跳过最近修改的文件,避免误删
// 并行实例正在写入的临时文件。
if let Ok(dir) = stage_dir()
&& let Ok(entries) = std::fs::read_dir(&dir)
{
for e in entries.flatten() {
let name = e.file_name();
if !name.to_string_lossy().ends_with(".tmp") {
continue;
}
let recent = std::fs::metadata(e.path())
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.is_some_and(|age| age < std::time::Duration::from_secs(10 * 60));
if recent {
continue;
}
let _ = std::fs::remove_file(e.path());
}
}
}
// ─── 单元测试 ────────────────────────────────────────────────────────────────