两件事:让「这是谁发的」在产物与清单上都留下可验证的痕迹。
① PE 版本资源(两个 bin):`packaging/pe_version.rs` 是唯一口径(CompanyName
「Unlucky Launcher Project」/ ProductName、FileDescription「Unlucky Launcher」/
LegalCopyright「© 2026 … · Apache-2.0」/ 四段式版本号与 Cargo.toml 同源),被
`crates/{cli,app-iced}/build.rs` 各 include 一次;非 windows 目标整段不执行。
判据是读产物字节的 `packaging/check_pe_version.py`(CI 的 Windows 槽位)。
② 清单签名:`manifest.json` 旁边多一份 `manifest.json.sig`(分离签名,签的是清单**原始
字节**),信封是 `{format, signatures:[{alg,key_id,sig,manifest_sha256}]}` —— 多签名数组,
任意一条命中钉住的钥即放行(换钥窗口)。信任锚是 `crates/core/src/update/trust.rs`
里的编译期公钥表(正式表为空 = fail-closed,测试钥只在 cfg(test)/update-test-source 里)。
管线顺序改成「先验签、再解析」:`UpdateSource` 只交原文 + `.sig`,`verified_manifest`
是唯一收口点,`Job::apply` 因此在取包**之前**就拒。新增五类错误分类
(signature_missing / malformed / unknown_key / keyring_empty / invalid ),全部不可重试,
CLI 的 hint 单独说人话。
③ Ed25519 验签自带实现(`update/ed25519/*`,零新依赖):scope 不含 core 的 Cargo.toml,
用 RFC 8032 §7.1 向量 + 可锻/小阶/非规范编码的拒绝用例钉住;签名侧是
`packaging/sign_manifest.py`(纯 Python 参考实现,零 pip 依赖,`--self-test` 同向量)。
④ 守门:`release-publish.yml` 加签名步(`set +x`,只回显公钥;没 Secret / 钥没钉 / 签不上
一律红 ⇒ 不出 Release);`release-build.yml` 加 PE 资源读回;`release-guard.yml` 跑两个
脚本的 `--self-test`。密钥纪律与轮换(旧钥签新钥)写在 `packaging/docs/signing.md`。
Cargo.lock:新增 winresource(build-dependency)与 toml 系;toml_datetime 1.1.2→1.1.3 是
toml 1.2.1 的硬要求(`cargo update --precise 1.1.2` 已实测被拒)。
342 lines
14 KiB
Rust
342 lines
14 KiB
Rust
//! GitHub Releases 更新源(D50 定案:GitHub Releases 是唯一更新源)。
|
||
//!
|
||
//! # 地址形状(固定,不可配置)
|
||
//!
|
||
//! ```text
|
||
//! 清单 <base>/<owner>/<repo>/releases/latest/download/manifest.json
|
||
//! 产物 <base>/<owner>/<repo>/releases/latest/download/<清单里的产物文件名>
|
||
//! ```
|
||
//!
|
||
//! `releases/latest/download/<asset>` 是 GitHub 原生的「最新发布」地址:不用知道 tag、
|
||
//! 不用调 API(API 有速率限制、也要 token),发布侧只要把 `manifest.json` 和各平台产物
|
||
//! 挂在最新 release 上,客户端就能取到。清单里的 `url` 因此照 D49 的产物命名规则写成
|
||
//! **文件名**(如 `unlucky-launcher_0.6.0_windows_x64.exe`),由本源拼成上面的地址;
|
||
//! 清单若已经写了完整 `http(s)` 地址(例如临时挂到别处复测),原样使用。
|
||
//!
|
||
//! # 可配置面只有三个
|
||
//!
|
||
//! * `owner` / `repo`(默认 [`DEFAULT_OWNER`] / [`DEFAULT_REPO`]);
|
||
//! * `base`(默认 [`DEFAULT_BASE`];**只为测试存在**——把它指向本地假 HTTP 服务,
|
||
//! 单测就不必联网)。
|
||
//!
|
||
//! # 网络全走 `core::download`
|
||
//!
|
||
//! * **产物**(几十 MB 的包体):整段交给 [`DownloadEngine::download`]——镜像展开
|
||
//! (国内 gh-proxy 前缀)、地区判定、健康度排序、失败退避、断点续传、原子落盘全部复用,
|
||
//! 本源不再造一套网络栈,也不自己拼镜像地址;
|
||
//! * **清单**(几 KB 的 JSON):用引擎的候选规划 [`GitHubReleaseSource::candidates`] +
|
||
//! 共享客户端 [`DownloadEngine::client`] 逐候选取文本,与 `core::game::java` 的
|
||
//! 「一锤子 GET」同一手法。包体才需要退避/续传/分块,为几 KB 建一条下载流水线不划算。
|
||
//!
|
||
//! 两种路径都吃到同一条镜像策略:`mirrors.json` 的前缀规则 + 地区档案里生效的
|
||
//! `gh_proxy` 前缀([`crate::download::mirrors::MirrorTable::gh_proxy_candidates`]),
|
||
//! 国内环境下 `https://github.com/...` 会被改写成 `https://gh-proxy.com/https://github.com/...`。
|
||
//!
|
||
//! # 渠道
|
||
//!
|
||
//! `releases/latest` 只有一个,所以本源**只服务 stable**。beta/alpha/nightly 的清单枚举
|
||
//! 保留但不发布(D50「先只发 stable」),因此非 stable 渠道一律 `Ok(None)` = 没有更新——
|
||
//! 这不是错误,别让「还没开通道」变成用户机器上的红字。
|
||
//!
|
||
//! # 校验
|
||
//!
|
||
//! 本源只管「把字节放到 dest」:长度与摘要由 [`super::verify`] 统一双检(与 [`super::DirSource`]
|
||
//! 一致)。所以包体请求只带 `expected_size`(给进度预算与门户页假阳性兜底),不带摘要——
|
||
//! 摘要判据只有一处,别在这里再写一套。
|
||
//!
|
||
//! # 运行时
|
||
//!
|
||
//! [`UpdateSource`] 是同步签名,本源在方法内部用 [`crate::download::block_on`] 桥接异步引擎
|
||
//! (CLI 与 presenter 都是这么用的)。因此**不要在已有的 tokio 运行时里直接调本源的
|
||
//! `fetch_*`**:tokio 不允许嵌套 `block_on`,会 panic。更新流程请放在独立线程上跑。
|
||
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::Arc;
|
||
|
||
use crate::download::cache::now_ms;
|
||
use crate::download::{
|
||
Candidate, DownloadEngine, DownloadError, DownloadErrorKind, DownloadRequest, ResourceKind,
|
||
};
|
||
|
||
use super::channel::Channel;
|
||
use super::error::{UpdateError, UpdateErrorKind};
|
||
use super::manifest::Artifact;
|
||
use super::source::{ManifestDocuments, UpdateSource};
|
||
|
||
/// 默认仓库 owner。
|
||
pub const DEFAULT_OWNER: &str = "AskaEth";
|
||
/// 默认仓库名。
|
||
pub const DEFAULT_REPO: &str = "Unlucky-Launcher";
|
||
/// GitHub 站点根地址(`base` 的默认值)。
|
||
pub const DEFAULT_BASE: &str = "https://github.com";
|
||
/// 清单文件名(挂在最新 release 的产物里,地址由 D50 定死)。
|
||
pub const MANIFEST_ASSET: &str = "manifest.json";
|
||
/// 清单签名的资产名(M190:与清单并列挂在同一个 release 上)。
|
||
pub const MANIFEST_SIGNATURE_ASSET: &str = "manifest.json.sig";
|
||
|
||
/// GitHub Releases 更新源。
|
||
#[derive(Debug, Clone)]
|
||
pub struct GitHubReleaseSource {
|
||
engine: Arc<DownloadEngine>,
|
||
owner: String,
|
||
repo: String,
|
||
base: String,
|
||
id: String,
|
||
}
|
||
|
||
impl GitHubReleaseSource {
|
||
/// 用默认仓库([`DEFAULT_OWNER`] / [`DEFAULT_REPO`])与 GitHub 站点根地址构造。
|
||
#[must_use]
|
||
pub fn new(engine: Arc<DownloadEngine>) -> Self {
|
||
Self::with_repo(engine, DEFAULT_OWNER, DEFAULT_REPO)
|
||
}
|
||
|
||
/// 指定仓库构造(fork / 自建镜像仓、测试用)。
|
||
#[must_use]
|
||
pub fn with_repo(
|
||
engine: Arc<DownloadEngine>,
|
||
owner: impl Into<String>,
|
||
repo: impl Into<String>,
|
||
) -> Self {
|
||
let owner = owner.into();
|
||
let repo = repo.into();
|
||
let id = format!("github:{owner}/{repo}");
|
||
Self {
|
||
engine,
|
||
owner,
|
||
repo,
|
||
base: DEFAULT_BASE.to_string(),
|
||
id,
|
||
}
|
||
}
|
||
|
||
/// 换 `base`(站点根地址,末尾斜杠会去掉)。
|
||
///
|
||
/// 生产不用这个口子;单测把它指向本地假服务(`http://127.0.0.1:<port>`)以保持离线。
|
||
#[must_use]
|
||
pub fn with_base(mut self, base: impl Into<String>) -> Self {
|
||
self.base = base.into().trim_end_matches('/').to_string();
|
||
self
|
||
}
|
||
|
||
/// 仓库 owner。
|
||
#[must_use]
|
||
pub fn owner(&self) -> &str {
|
||
&self.owner
|
||
}
|
||
|
||
/// 仓库名。
|
||
#[must_use]
|
||
pub fn repo(&self) -> &str {
|
||
&self.repo
|
||
}
|
||
|
||
/// 站点根地址。
|
||
#[must_use]
|
||
pub fn base(&self) -> &str {
|
||
&self.base
|
||
}
|
||
|
||
/// 引擎(调用方要挂进度事件时用)。
|
||
#[must_use]
|
||
pub fn engine(&self) -> &Arc<DownloadEngine> {
|
||
&self.engine
|
||
}
|
||
|
||
/// 某渠道的清单地址;非 stable 渠道没有清单(返回 `None`)。
|
||
#[must_use]
|
||
pub fn manifest_url(&self, channel: Channel) -> Option<String> {
|
||
(channel == Channel::Stable).then(|| format!("{}/{MANIFEST_ASSET}", self.download_base()))
|
||
}
|
||
|
||
/// 把清单里的 `url` 解析成真实下载地址。
|
||
///
|
||
/// 已经是 `http(s)://` 的地址原样使用(清单可以临时指向别处);其余一律当成
|
||
/// 「最新 release 里的产物文件名」,拼到本源的下载根路径下。
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// `url` 为空是 [`UpdateErrorKind::InvalidManifest`](清单坏,不是源的问题);
|
||
/// 非 http(s) 的其它协议是 [`UpdateErrorKind::UnsupportedSource`]。
|
||
pub fn artifact_url(&self, artifact: &Artifact) -> Result<String, UpdateError> {
|
||
let raw = artifact.url.trim();
|
||
if raw.is_empty() {
|
||
return Err(UpdateError::invalid_manifest("产物的 url 为空"));
|
||
}
|
||
if raw.starts_with("http://") || raw.starts_with("https://") {
|
||
return Ok(raw.to_string());
|
||
}
|
||
if raw.contains("://") {
|
||
return Err(UpdateError::new(
|
||
UpdateErrorKind::UnsupportedSource,
|
||
format!("GitHub 源只认 http(s) 地址或 release 内的产物文件名:`{raw}`"),
|
||
));
|
||
}
|
||
// `./name` 与 `/name` 都当成「release 里的文件名」,别把整串拼出个双斜杠路径。
|
||
let name = raw.trim_start_matches(['.', '/']);
|
||
Ok(format!("{}/{name}", self.download_base()))
|
||
}
|
||
|
||
/// 某地址在引擎眼里的候选源序列(镜像展开 + 健康度排序,**不发请求**)。
|
||
///
|
||
/// 生产调用走引擎自己的规划;这里是给「镜像策略是否生效」这类断言用的观察窗。
|
||
#[must_use]
|
||
pub fn candidates(&self, url: &str) -> Vec<Candidate> {
|
||
// `dest` 只是 `DownloadRequest` 的必填字段,`plan` 不看它。
|
||
let request = DownloadRequest::new(ResourceKind::GitHubRelease, url, PathBuf::new());
|
||
self.engine.plan(&request, now_ms())
|
||
}
|
||
|
||
/// 最新的 release 产物根路径(`<base>/<owner>/<repo>/releases/latest/download`)。
|
||
fn download_base(&self) -> String {
|
||
format!(
|
||
"{}/{}/{}/releases/latest/download",
|
||
self.base, self.owner, self.repo
|
||
)
|
||
}
|
||
|
||
/// 逐候选取一段文本:`Ok(None)` = 所有候选都说「这份文件不存在」。
|
||
fn fetch_text(&self, url: &str) -> Result<Option<String>, UpdateError> {
|
||
let candidates = self.candidates(url);
|
||
if candidates.is_empty() {
|
||
return Err(UpdateError::new(
|
||
UpdateErrorKind::Network,
|
||
format!("没有可用候选源:{url}"),
|
||
));
|
||
}
|
||
let mut failure: Option<UpdateError> = None;
|
||
let mut all_missing = true;
|
||
for candidate in candidates {
|
||
match self.get_candidate_text(&candidate.url) {
|
||
Ok(Some(text)) => return Ok(Some(text)),
|
||
// 某个候选 404 不代表没有:镜像可能比官方慢半拍,继续问下一个。
|
||
Ok(None) => {}
|
||
Err(error) => {
|
||
all_missing = false;
|
||
failure = Some(error);
|
||
}
|
||
}
|
||
}
|
||
if all_missing {
|
||
Ok(None)
|
||
} else {
|
||
Err(failure.unwrap_or_else(|| {
|
||
UpdateError::new(UpdateErrorKind::Network, format!("取 {url} 失败"))
|
||
}))
|
||
}
|
||
}
|
||
|
||
/// 单个候选地址取文本;`Ok(None)` = 404/410(该候选没有这份文件)。
|
||
fn get_candidate_text(&self, url: &str) -> Result<Option<String>, UpdateError> {
|
||
let client = self.engine.client();
|
||
crate::download::block_on(async {
|
||
let response = client.get(url).send().await.map_err(|error| {
|
||
UpdateError::new(
|
||
UpdateErrorKind::Network,
|
||
format!("取更新清单失败:{error}({url})"),
|
||
)
|
||
})?;
|
||
let status = response.status();
|
||
if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE {
|
||
return Ok(None);
|
||
}
|
||
if !status.is_success() {
|
||
return Err(UpdateError::new(
|
||
UpdateErrorKind::Network,
|
||
format!("更新清单返回 HTTP {status}({url})"),
|
||
));
|
||
}
|
||
let text = response.text().await.map_err(|error| {
|
||
UpdateError::new(
|
||
UpdateErrorKind::Network,
|
||
format!("读更新清单响应失败:{error}({url})"),
|
||
)
|
||
})?;
|
||
if text.trim().is_empty() {
|
||
return Err(UpdateError::invalid_manifest(format!(
|
||
"清单响应为空({url}),发布脚本可能漏传了 {MANIFEST_ASSET}"
|
||
)));
|
||
}
|
||
Ok(Some(text))
|
||
})
|
||
}
|
||
}
|
||
|
||
impl UpdateSource for GitHubReleaseSource {
|
||
fn source_id(&self) -> &str {
|
||
&self.id
|
||
}
|
||
|
||
fn fetch_manifest_documents(
|
||
&self,
|
||
channel: Channel,
|
||
) -> Result<Option<ManifestDocuments>, UpdateError> {
|
||
let Some(url) = self.manifest_url(channel) else {
|
||
return Ok(None);
|
||
};
|
||
let Some(manifest) = self.fetch_text(&url)? else {
|
||
return Ok(None);
|
||
};
|
||
// 签名文件的 404 与清单的 404 处理不同:清单没有 = 该渠道没发过版(不是错误);
|
||
// 签名没有 = 发布侧漏了签名(上层判 SignatureMissing 并拒装)。
|
||
let signature_url = format!("{}/{MANIFEST_SIGNATURE_ASSET}", self.download_base());
|
||
let signatures = self.fetch_text(&signature_url)?;
|
||
Ok(Some(ManifestDocuments {
|
||
manifest,
|
||
signatures,
|
||
}))
|
||
}
|
||
|
||
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {
|
||
let url = self.artifact_url(artifact)?;
|
||
if let Some(parent) = dest.parent() {
|
||
if !parent.as_os_str().is_empty() {
|
||
std::fs::create_dir_all(parent)
|
||
.map_err(|error| UpdateError::io_at(parent, &error))?;
|
||
}
|
||
}
|
||
// 摘要不在这里传:`update::verify` 统一做长度/摘要双检(trait 契约,别各写一套)。
|
||
let request = DownloadRequest::new(ResourceKind::GitHubRelease, url.clone(), dest)
|
||
.with_size(artifact.size);
|
||
let outcome = crate::download::block_on(self.engine.download(request))
|
||
.map_err(|error| download_error(&error, &url))?;
|
||
Ok(outcome.bytes)
|
||
}
|
||
}
|
||
|
||
/// 把下载引擎的错误翻译成自更新的错误分类(两套分类各有各的职责,别混用)。
|
||
fn download_error(error: &DownloadError, url: &str) -> UpdateError {
|
||
let kind = match error.kind {
|
||
DownloadErrorKind::NotFound => UpdateErrorKind::SourceMissing,
|
||
DownloadErrorKind::Integrity => UpdateErrorKind::DigestMismatch,
|
||
DownloadErrorKind::Io => UpdateErrorKind::Io,
|
||
DownloadErrorKind::InvalidRequest => UpdateErrorKind::UnsupportedSource,
|
||
DownloadErrorKind::AllSourcesFailed if all_candidates_missing(error) => {
|
||
UpdateErrorKind::SourceMissing
|
||
}
|
||
DownloadErrorKind::ServerError
|
||
| DownloadErrorKind::Timeout
|
||
| DownloadErrorKind::Network
|
||
| DownloadErrorKind::RangeUnsupported
|
||
| DownloadErrorKind::AllSourcesFailed => UpdateErrorKind::Network,
|
||
};
|
||
let mut message = format!("{}({url})", error.message);
|
||
if !error.suppressed.is_empty() {
|
||
message.push_str(&format!(";各源:{}", error.suppressed.join(";")));
|
||
}
|
||
UpdateError::new(kind, message)
|
||
}
|
||
|
||
/// 所有候选是不是都回「这份文件不存在」。
|
||
///
|
||
/// 引擎把逐源失败压成一条 [`DownloadErrorKind::AllSourcesFailed`],原始的 404/410 分类只剩在
|
||
/// 失败摘要文本里(`download::http::classify_status` 的文案:`HTTP 404`)。不还原这一层,
|
||
/// 「发布清单里挂了个没上传的产物」会被报成「网络失败 + 可重试」,重试多少次都一样。
|
||
fn all_candidates_missing(error: &DownloadError) -> bool {
|
||
!error.suppressed.is_empty()
|
||
&& error
|
||
.suppressed
|
||
.iter()
|
||
.all(|line| line.contains("HTTP 404") || line.contains("HTTP 410"))
|
||
}
|