feat(core): 新增 update 模块,做与更新源无关的自更新内核
更新源还没拍板,所以先把与源无关的那半边做掉:语义化版本比较、发布清单模型、 渠道准入、下载校验、原子替换 + 回滚。源抽象成 UpdateSource trait(清单 + 包体两个 方法),core 里没有任何 CDN 常量;内置的 DirSource 吃本地目录或 file://,既是测试 实现也是将来 HTTP 源的对照物。 取舍: - 版本比较严格按 semver 2.0 §11:数值比(0.9 < 0.10)、预发布 < 同号正式版、 预发布标识逐段比(alpha < beta < rc 是 ASCII 序的自然结果,不另立阶段权重表)、 构建元数据不参与排序与相等。 - 渠道准入两道判据:清单自报渠道的阶梯比较 + 版本号里的预发布阶段。清单可能写错, 版本号不会,所以正式版渠道即使拿到一份自称 stable 的 rc 也不会提示更新。 - 校验两道判据且顺序有意:先比长度(抓截断,不必读全文件)再比摘要(抓内容错), 长度要求**恰好**相等;失败即删暂存文件。StagedPackage 只能由 stage() 产出, 于是「未校验的包」在类型上就进不了安装路径。 - 替换跨平台统一成「旧文件改名挪开 → 新文件搬进来 → 失败把备份搬回去」:Windows 上 覆盖运行中的 exe 不存在,改名是唯一可用动作,而改名也可能被不共享删除的句柄拒绝, 所以失败必须能回滚。Unix 也照这个顺序跑,避免回滚路径只有 Windows 被真正跑过。 - core 零 UI 依赖不变:只有 serde/serde_json + 复用的 sha1/sha2,无新依赖, Cargo.toml 未改;不接 presenter、不接 UI。 单测 62 条(crates/core/src/update/tests/):版本边界、清单校验、渠道准入、 摘要不符、包体截断(短了/多了)、无权限写目标目录、Windows 占用文件、回滚与备份清理。
This commit is contained in:
1 parent
156abfe38b
commit
2e718af711
21 files changed
+3309
No files matched your search
@@ -0,0 +1,174 @@
|
||||
//! 更新源抽象:清单从哪来、包体从哪来,全部收在一个 trait 后面。
|
||||
//!
|
||||
//! 更新源(CDN / 对象存储 / GitHub Release / 内网文件服务器)**还没拍板**,所以这里
|
||||
//! 刻意不写死任何 URL、任何厂商名:core 只认 [`UpdateSource`] 三个方法,接哪家由上层
|
||||
//! 注入。将来接 HTTP 源时,实现里用 `tokio`/`reqwest` 或
|
||||
//! [`crate::download::blocking::block_on`] 都行,trait 形状不用动。
|
||||
//!
|
||||
//! 内置的实现只有一个:[`DirSource`](本地目录 / `file://`),用途是测试、内网手工分发、
|
||||
//! 以及给将来的 HTTP 实现当对照物。
|
||||
//!
|
||||
//! # 本地源的目录约定
|
||||
//!
|
||||
//! ```text
|
||||
//! <root>/stable.json 渠道清单(文件名 = 渠道名 + .json)
|
||||
//! <root>/beta.json
|
||||
//! <root>/<产物文件> 清单里 url 写相对路径时,相对 root
|
||||
//! ```
|
||||
//!
|
||||
//! 清单里的 `url` 支持两种写法:`file://` 绝对地址,或相对 `<root>` 的路径。
|
||||
//! 出现 `http://` / `https://` 一律 [`UpdateErrorKind::UnsupportedSource`]——那是别的
|
||||
//! 实现的活,本地源不该假装能取远端字节。
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::channel::Channel;
|
||||
use super::error::{UpdateError, UpdateErrorKind};
|
||||
use super::manifest::{Artifact, ReleaseManifest};
|
||||
|
||||
/// 更新源:清单与包体的来源。
|
||||
///
|
||||
/// 实现必须是 `Send + Sync`(下载与替换可能在不同线程上跑),且**不持有运行时**:
|
||||
/// 同步签名是为了让 CLI 与 GUI 都能直接调用,异步实现自己在方法里桥接。
|
||||
pub trait UpdateSource: Send + Sync {
|
||||
/// 源标识(日志/事件里的 `source` 字段,如 `file:D:\dist`;健康度也按它归并)。
|
||||
fn source_id(&self) -> &str;
|
||||
|
||||
/// 取某渠道的清单;`Ok(None)` = 该渠道还没发布过(不是错误)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 读不到文件、JSON 坏、清单自报的渠道与 `channel` 不符都返回 [`UpdateError`]。
|
||||
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError>;
|
||||
|
||||
/// 把产物取到 `dest`,返回写入的字节数。
|
||||
///
|
||||
/// 实现只管「把字节放到 dest」;长度与摘要校验由 [`crate::update::verify`] 统一做,
|
||||
/// 别在实现里各写一套。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 源里没有这个文件、读失败、写 `dest` 失败都返回 [`UpdateError`]。
|
||||
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError>;
|
||||
}
|
||||
|
||||
/// 本地目录源(也接受 `file://` 根地址)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DirSource {
|
||||
root: PathBuf,
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl DirSource {
|
||||
/// 用本地目录构造。
|
||||
#[must_use]
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
let root = root.into();
|
||||
let id = format!("file:{}", root.display());
|
||||
Self { root, id }
|
||||
}
|
||||
|
||||
/// 用 `file://` 地址构造(`file:///D:/dist`、`file:///srv/ull`)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 不是 `file://` 开头的地址返回 [`UpdateErrorKind::UnsupportedSource`]。
|
||||
pub fn from_url(url: &str) -> Result<Self, UpdateError> {
|
||||
let rest = url
|
||||
.strip_prefix("file://")
|
||||
.ok_or_else(|| unsupported_source(url))?;
|
||||
Ok(Self::new(file_url_to_path(rest)))
|
||||
}
|
||||
|
||||
/// 根目录。
|
||||
#[must_use]
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// 某渠道的清单文件路径。
|
||||
#[must_use]
|
||||
pub fn manifest_path(&self, channel: Channel) -> PathBuf {
|
||||
self.root.join(format!("{}.json", channel.as_str()))
|
||||
}
|
||||
|
||||
/// 把清单里的 `url` 解析成源侧本地路径。
|
||||
fn resolve(&self, url: &str) -> Result<PathBuf, UpdateError> {
|
||||
if let Some(rest) = url.strip_prefix("file://") {
|
||||
return Ok(file_url_to_path(rest));
|
||||
}
|
||||
if url.contains("://") {
|
||||
return Err(unsupported_source(url));
|
||||
}
|
||||
Ok(self.root.join(url))
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_source(url: &str) -> UpdateError {
|
||||
let hint = if url.starts_with("http://") || url.starts_with("https://") {
|
||||
"本地目录源不取远端字节,请换用实现远端源的 UpdateSource"
|
||||
} else {
|
||||
"只支持本地相对路径与 file:// 地址"
|
||||
};
|
||||
UpdateError::new(
|
||||
UpdateErrorKind::UnsupportedSource,
|
||||
format!("更新源不支持地址 `{url}`:{hint}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn file_url_to_path(rest: &str) -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
// file:///D:/dist → 去掉盘符前的斜杠;Windows 上没有「以 / 开头的当前盘路径」这种写法。
|
||||
PathBuf::from(rest.trim_start_matches('/'))
|
||||
} else {
|
||||
PathBuf::from(rest)
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateSource for DirSource {
|
||||
fn source_id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> {
|
||||
let path = self.manifest_path(channel);
|
||||
let text = match fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(source) => return Err(UpdateError::io_at(&path, &source)),
|
||||
};
|
||||
let manifest = ReleaseManifest::from_json(&text).map_err(|error| error.with_path(&path))?;
|
||||
if manifest.channel != channel {
|
||||
return Err(UpdateError::new(
|
||||
UpdateErrorKind::ChannelMismatch,
|
||||
format!(
|
||||
"请求的是 {} 渠道,清单 {} 自报 {}",
|
||||
channel,
|
||||
path.display(),
|
||||
manifest.channel
|
||||
),
|
||||
)
|
||||
.with_path(&path));
|
||||
}
|
||||
Ok(Some(manifest))
|
||||
}
|
||||
|
||||
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {
|
||||
let from = self.resolve(&artifact.url)?;
|
||||
if let Some(parent) = dest.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
fs::create_dir_all(parent).map_err(|error| UpdateError::io_at(parent, &error))?;
|
||||
}
|
||||
}
|
||||
match fs::copy(&from, dest) {
|
||||
Ok(bytes) => Ok(bytes),
|
||||
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Err(UpdateError::new(
|
||||
UpdateErrorKind::SourceMissing,
|
||||
format!("源里没有产物 `{}`", artifact.url),
|
||||
)
|
||||
.with_path(&from)),
|
||||
Err(source) => Err(UpdateError::io_at(&from, &source)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user