D50 定案 GitHub Releases 为唯一更新源,清单地址固定为 `releases/latest/download/manifest.json`。此前 update 模块只有对拍用的 DirSource,这次把真实源补上。 - `update::github::GitHubReleaseSource` 实现 `UpdateSource`:清单从 `<base>/<owner>/<repo>/releases/latest/download/manifest.json` 取, 产物按 D49 命名规则(文件名)拼到同一条 latest/download 路径下;清单里 已写完整 http(s) 地址的原样使用。 - 网络全走 `core::download`,不新造栈:包体交给引擎 `download()`(镜像展开 → 地区判定 → 健康度排序 → 退避 → 续传 → 原子落盘),清单走引擎的 `plan()` + 共享 client 逐候选取文本(与 `game::java` 的一锤子 GET 同一手法)。 两条路径都吃到 mirrors.json 与地区档案里生效的 gh-proxy 前缀。 - 可配置面只有 owner/repo(默认 AskaEth/Unlucky-Launcher)与 base(默认 https://github.com,测试指向本地假服务);渠道只有 stable 有清单, beta/alpha/nightly 返回 Ok(None)(D50「先只发 stable」),清单 404 也算 「还没发布」而不是错误。 - `UpdateErrorKind` 新增 `Network`:远端取字节失败(连不上/超时/5xx/候选全挂) 与本地 IO 分开——前者可重试,后者重试无意义。引擎把逐源失败压成 AllSourcesFailed,本源从失败摘要里还原「所有源都 404」为 SourceMissing (产物没上传不该被报成网络抖动)。 - 校验仍只有一处:包体请求只带 expected_size,长度/摘要双检留在 `update::verify`(与 DirSource 同口径)。 - 单测 13 条,全程离线(假 HTTP 服务在 127.0.0.1):固定清单地址、产物 URL 解析、自定义 base/owner/repo、清单 404/非 JSON/字段非法/渠道不符/5xx 各自 归到白名单分类、产物下载、产物 404 → SourceMissing、镜像优先下 gh-proxy 前缀(清单与产物都验)、与 DirSource 同清单同产物选择结果一致。
174 lines
6.3 KiB
Rust
174 lines
6.3 KiB
Rust
//! 更新源抽象:清单从哪来、包体从哪来,全部收在一个 trait 后面。
|
||
//!
|
||
//! 这一层刻意不写死任何 URL、任何厂商名:core 只认 [`UpdateSource`] 三个方法,接哪家由上层
|
||
//! 注入。真实源已定(D50):[`super::GitHubReleaseSource`],它在 `github.rs`,网络与镜像策略
|
||
//! 全交给 `crate::download`。
|
||
//!
|
||
//! 内置的另一个实现是 [`DirSource`](本地目录 / `file://`),用途是测试、内网手工分发,
|
||
//! 以及给远端实现当对拍基准。
|
||
//!
|
||
//! # 本地源的目录约定
|
||
//!
|
||
//! ```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)),
|
||
}
|
||
}
|
||
}
|