From 6ceb25af508333dabcc5c8691e1ee960b63efb0e Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 27 Oct 2026 00:21:22 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(updater):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E6=94=B9=E7=94=B1=20CDN=20=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=EF=BC=8C=E5=AE=A2=E6=88=B7=E7=AB=AF=E4=B8=8D=E5=86=8D=E8=AF=BB?= =?UTF-8?q?=20release=20=E9=87=8C=E7=9A=84=20json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 窗口内的更新器原先从 GitHub Release 资产取清单 (releases/latest/download/latest-{target}-{arch}[-beta].json),于是每个 release 要挂 14 个 latest-*.json(7 个 target/arch × mirror 双份),且 Stable 走滚动 指针 → 每次发版都要把同名文件重新传一遍。 改成读项目自己的 CDN: https://dl.aeoform.com/updates/stable/latest-{target}-{arch}.json https://dl.aeoform.com/updates/beta/latest-{target}-{arch}-beta.json - 每个平台/渠道只有一个滚动文件,发版覆盖即可,不再往 release 累积副本。 - 渠道隔离从「文件名后缀」变成「URL 路径」,Stable 客户端拿不到 Beta 清单。 - 清单格式不变(version/pub_date/url/signature),plugin-updater 继续负责下载、 minisign 校验与安装;清单里的下载地址也指向 CDN,国内下载不再经过 github.com。 - Beta 不再需要拉 releases.atom 找 tag,整条链路去掉一个 github.com 依赖。 - Android 更新检查同步改为 CDN 单地址;SSRF 白名单加入 CDN,同时保留旧 GitHub/fastgit 前缀,已缓存的旧清单仍可安装。 release 侧发布流程与既有资产完全不动。 --- README.md | 13 +- README.zh.md | 13 +- RELEASING.md | 18 ++- .../crates/openless-core/src/shared_types.rs | 9 +- .../repository-owner-contract.test.mjs | 31 ++++- .../app/src-tauri/src/android/updater.rs | 73 ++++------ .../src-tauri/src/android/updater_logic.rs | 76 +--------- .../app/src-tauri/src/commands/settings.rs | 66 ++++----- openless-all/app/src-tauri/src/lib.rs | 1 + .../app/src-tauri/src/update_source.rs | 130 ++++++++++++++++++ openless-all/app/src-tauri/tauri.conf.json | 3 +- 11 files changed, 259 insertions(+), 174 deletions(-) create mode 100644 openless-all/app/src-tauri/src/update_source.rs diff --git a/README.md b/README.md index a80e238a1..3e191ac65 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ Go to [Releases](https://github.com/Open-Less/openless/releases) and download: - **Older 32-bit ARM**: `OpenLess__armeabi-v7a.apk` - **64-bit emulator**: `OpenLess__x86_64.apk` - **32-bit emulator**: `OpenLess__x86.apk` - - In-app updates (Settings → About) use `latest-android-{arch}.json` manifests; Beta users join Beta in Advanced settings. + - In-app updates (Settings → About) read `https://dl.aeoform.com/updates/stable/latest-android-{arch}.json`; Beta users join Beta in Advanced settings and read the `.../updates/beta/latest-android-{arch}-beta.json` counterpart. - Debug smoke builds: `OpenLess-android-debug-{abi}-*.apk` from workflow_dispatch artifacts. - If unsure, run `adb shell getprop ro.product.cpu.abi` and pick the matching APK. - **Linux**: the Tauri/WebView build has been retired. The native `eframe` host uses the shared Core 2.0 services. After CI and real Ubuntu audio, input, install, upgrade and rollback acceptance, the shared release includes deb/rpm packages and `SHA256SUMS`. Linux requires glibc ≥ 2.39 (Ubuntu 24.04 or a comparable distribution); there is no AppImage or in-app updater. @@ -330,7 +330,16 @@ Rules of thumb: - **Beta work must not leak to Stable.** `main` receives merges only from `beta`, performed by maintainers after a successful two-platform smoke build. No direct pushes to `main`. - **Stable releases are cut from `main`** by pushing a `v-tauri` tag — see the maintainer release checklist below. -Beta release distribution (manual-download opt-in): the in-app updater always reads the Stable manifest, so regular users never receive Beta builds via auto-update. Users who want to try Beta open **Settings → About**, enable "Join Beta channel", and download the latest Beta installer manually from the link the app fetches from GitHub. Tag convention: `v-beta-tauri` produces the Beta release (marked as a GitHub pre-release; manifest written as `latest-{tgt}-{arch}-beta.json`), while `v-tauri` produces the Stable release. The two manifest files never overlap, so Stable users' updater feed cannot pick up Beta releases. +Beta release distribution (manual-download opt-in): the in-app updater's Stable channel only ever reads the Stable manifest path, so regular users never receive Beta builds via auto-update. Users who want to try Beta open **Settings → About**, enable "Join Beta channel", and download the latest Beta installer manually from the link the app fetches from GitHub. Tag convention: `v-beta-tauri` produces the Beta release (marked as a GitHub pre-release), while `v-tauri` produces the Stable release. + +Updater manifests are served from the project CDN, one rolling file per platform and channel: + +```text +https://dl.aeoform.com/updates/stable/latest-{target}-{arch}.json +https://dl.aeoform.com/updates/beta/latest-{target}-{arch}-beta.json +``` + +The channel split is the URL path (`/updates/stable/` vs `/updates/beta/`), so a Stable client cannot reach a Beta manifest even if it asked for the wrong URL. Artifact URLs inside the manifest point at the same CDN (`https://dl.aeoform.com/releases/{tag}/{asset}`), which is what mainland-China users download from. Cloning manifests into the GitHub Release assets is no longer required by current clients; it is retained only so clients built before this change keep updating. ## Credentials diff --git a/README.zh.md b/README.zh.md index 10215d759..1e744c20d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -243,7 +243,7 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 - **旧款 32 位 ARM**:`OpenLess__armeabi-v7a.apk` - **64 位模拟器**:`OpenLess__x86_64.apk` - **32 位模拟器**:`OpenLess__x86.apk` - - 应用内更新(设置 → 关于)读取 `latest-android-{arch}.json`;Beta 用户在高级设置加入 Beta 渠道。 + - 应用内更新(设置 → 关于)读取 `https://dl.aeoform.com/updates/stable/latest-android-{arch}.json`;Beta 用户在高级设置加入 Beta 渠道,读取 `.../updates/beta/latest-android-{arch}-beta.json`。 - 调试包:`OpenLess-android-debug-{abi}-*.apk`(workflow_dispatch 产物)。 - 不确定时执行 `adb shell getprop ro.product.cpu.abi`,下载对应 ABI 的包。 - **Linux**:原 Tauri/WebView 构建已退出 Linux 路线。基于共享 Core 2.0 服务的原生 `eframe` 宿主经 CI 和 Ubuntu 真实音频、输入、安装、升级、回滚验收后,随共用 Release 提供 deb/rpm 和 `SHA256SUMS`。要求 glibc ≥ 2.39(Ubuntu 24.04 或同等级发行版);没有 AppImage 或应用内更新。 @@ -337,7 +337,16 @@ your fork / topic branch - **Beta 的工作不得泄漏到 Stable。** `main` 只接受来自 `beta` 的合并,由维护者在一次成功的双平台冒烟构建之后执行。不允许直接向 `main` 推送。 - **Stable 发布从 `main` 切出**,通过推送 `v-tauri` 标签完成——见下方的维护者发布清单。 -Beta 发布的分发(手动下载、可选加入):应用内的更新器始终读取 Stable 清单,因此普通用户绝不会通过自动更新拿到 Beta 构建。想尝试 Beta 的用户打开 **设置 → 关于**,启用“加入 Beta 频道”,再从应用从 GitHub 获取的链接处手动下载最新的 Beta 安装包。标签约定:`v-beta-tauri` 产出 Beta 发布(标记为 GitHub pre-release;清单写为 `latest-{tgt}-{arch}-beta.json`),而 `v-tauri` 产出 Stable 发布。两份清单文件互不重叠,因此 Stable 用户的更新器源不会拾取到 Beta 发布。 +Beta 发布的分发(手动下载、可选加入):应用内更新器的 Stable 渠道只读 Stable 清单路径,因此普通用户绝不会通过自动更新拿到 Beta 构建。想尝试 Beta 的用户打开 **设置 → 关于**,启用“加入 Beta 频道”,再从应用从 GitHub 获取的链接处手动下载最新的 Beta 安装包。标签约定:`v-beta-tauri` 产出 Beta 发布(标记为 GitHub pre-release),而 `v-tauri` 产出 Stable 发布。 + +更新清单由项目 CDN 提供,每个平台/渠道一个滚动文件: + +```text +https://dl.aeoform.com/updates/stable/latest-{target}-{arch}.json +https://dl.aeoform.com/updates/beta/latest-{target}-{arch}-beta.json +``` + +渠道隔离就是 URL 路径(`/updates/stable/` vs `/updates/beta/`),Stable 客户端即使请求错地址也拿不到 Beta 清单。清单里的下载地址同样指向 CDN(`https://dl.aeoform.com/releases/{tag}/{asset}`),国内用户直接从这里下载。当前客户端已不需要 GitHub Release 资产里的清单副本,那边继续保留只是为了改动前的旧客户端还能更新。 ## 凭据 diff --git a/RELEASING.md b/RELEASING.md index d142901fd..c6855b261 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -149,9 +149,21 @@ The script takes a plain `X.Y.Z`; for a prerelease version such as 4. The applicable [desktop feature and device acceptance](docs/2.0-desktop-acceptance.md), signing, and distribution requirements are met; green builds alone do not establish product readiness. Linux acceptance below is required before including Linux assets. 5. Then, and only then, push the release tag. 6. Both Beta and Stable tag workflows upload Tauri and Android assets to a shared - draft. Wait for **both** workflows to succeed, verify the packages and channel - updater manifests, then publish that draft (Beta as a prerelease; Stable as latest). + draft. Wait for **both** workflows to succeed, verify the packages, then publish that + draft (Beta as a prerelease; Stable as latest). Do not rerun an asset workflow after publication without first returning the release to draft. + The workflows also write the channel updater manifests (`latest-{target}-{arch}.json` and + `latest-{target}-{arch}-beta.json`). Current clients do **not** read those release assets: + they read the CDN, so publishing a release is only half the job — the manifests must also + land on the CDN at + + ```text + https://dl.aeoform.com/updates/stable/latest-{target}-{arch}.json + https://dl.aeoform.com/updates/beta/latest-{target}-{arch}-beta.json + ``` + + with their `url` fields pointing at the CDN artifacts (`.../releases/{tag}/{asset}`). The + in-release copies exist only for clients built before the CDN switch. 7. Publishing Stable with the administrator's CLI/UI credential automatically triggers `sync-homebrew-cask.yml`. It accepts only the published latest Stable tag, preserves application build metadata in DMG filenames, verifies matching @@ -324,6 +336,6 @@ change the published channel. The same format applies to subsequent releases. 2. For a Stable release, a maintainer merges `beta → main`. 3. An **admin** bumps the Tauri version (five-location sync), verifies CI is green, and pushes the release tag, which triggers the macOS/Windows/Android asset - workflows and their channel-specific updater manifests. + workflows and their channel updater manifests. 4. After both release workflows pass, an admin verifies and publishes the shared draft. Linux packages are attached independently only after Linux acceptance. diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index ddb570f96..3987593b3 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -140,10 +140,11 @@ pub enum MacosNewlineMode { } /// Auto-update channel. Picks which manifest the background AutoUpdateGate -/// fetches. `Stable` = `latest-android-{arch}.json` (or the desktop -/// plugin-updater stable endpoints). `Beta` = `latest-android-{arch}-beta.json` -/// (or the desktop beta endpoints). The Settings manual "check stable / check -/// beta" buttons pass the channel explicitly and ignore this pref. +/// fetches. `Stable` = `/updates/stable/latest-{target}-{arch}.json` on the CDN +/// (`latest-android-{arch}.json` for the Android client). `Beta` = the +/// `/updates/beta/` counterpart with a `-beta` filename suffix. The Settings +/// manual "check stable / check beta" buttons pass the channel explicitly and +/// ignore this pref. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum UpdateChannel { diff --git a/openless-all/app/scripts/repository-owner-contract.test.mjs b/openless-all/app/scripts/repository-owner-contract.test.mjs index 50c56e94b..6bf991801 100644 --- a/openless-all/app/scripts/repository-owner-contract.test.mjs +++ b/openless-all/app/scripts/repository-owner-contract.test.mjs @@ -4,7 +4,6 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)); -const currentRepo = 'Open-Less/openless'; const legacyRepo = 'appergb/openless'; const repositoryReferences = [ @@ -18,6 +17,7 @@ const repositoryReferences = [ 'openless-all/app/src-tauri/src/android/updater_logic.rs', 'openless-all/app/src-tauri/src/commands/mod.rs', 'openless-all/app/src-tauri/src/commands/settings.rs', + 'openless-all/app/src-tauri/src/update_source.rs', 'openless-all/app/src-tauri/tauri.conf.json', 'openless-all/app/src/components/AutoUpdate.tsx', 'openless-all/app/src/components/SettingsModal.tsx', @@ -41,9 +41,34 @@ assert( Array.isArray(updaterEndpoints) && updaterEndpoints.length > 0, 'desktop updater endpoints are missing', ); + +// Updater manifests are served by the project CDN, not by the GitHub Release assets. Both +// halves of that arrangement live in different files, so pin them together here: the endpoint +// in tauri.conf.json must come from the same CDN base that `update_source.rs` declares, and it +// must be the Stable path. +const updateSource = await readFile( + join(repoRoot, 'openless-all/app/src-tauri/src/update_source.rs'), + 'utf8', +); +const cdnBase = /CDN_BASE: &str = "(https:\/\/[^"]+)"/.exec(updateSource)?.[1]; +assert(cdnBase, 'update_source.rs must declare the CDN_BASE constant'); assert( - updaterEndpoints.every((endpoint) => endpoint.includes(currentRepo)), - 'desktop updater endpoints must use the current GitHub repository', + updateSource.includes('/updates/{path}/latest-'), + 'update_source.rs must derive manifest URLs from the CDN update path', ); +for (const endpoint of updaterEndpoints) { + assert( + endpoint.startsWith(`${cdnBase}/updates/stable/latest-`), + `desktop updater endpoint must point at the CDN Stable manifest: ${endpoint}`, + ); + assert( + !endpoint.includes('github.com'), + `desktop updater endpoint must not read GitHub Release assets: ${endpoint}`, + ); + assert( + endpoint.includes('{{target}}') && endpoint.includes('{{arch}}'), + `desktop updater endpoint must keep the {{target}}/{{arch}} placeholders: ${endpoint}`, + ); +} console.log('repository-owner-contract.test.mjs passed'); diff --git a/openless-all/app/src-tauri/src/android/updater.rs b/openless-all/app/src-tauri/src/android/updater.rs index ab54d13e8..eea55ba01 100644 --- a/openless-all/app/src-tauri/src/android/updater.rs +++ b/openless-all/app/src-tauri/src/android/updater.rs @@ -8,12 +8,10 @@ mod android_impl { use tauri::{AppHandle, Emitter}; use crate::android::updater_logic::{ - beta_manifest_urls, format_manifest_error, map_abi_to_arch, stable_manifest_urls, - verify_updater_signature, version_is_newer, INSTALLER_NOT_OPENED_MSG, UPDATER_PUBKEY_B64, - }; - use crate::commands::{ - fetch_latest_beta_release, parse_latest_beta_from_atom, AppUpdateMetadata, + format_manifest_error, map_abi_to_arch, verify_updater_signature, version_is_newer, + INSTALLER_NOT_OPENED_MSG, UPDATER_PUBKEY_B64, }; + use crate::commands::AppUpdateMetadata; use crate::net; use crate::types::UpdateChannel; @@ -80,47 +78,33 @@ mod android_impl { pub async fn check_update(channel: UpdateChannel) -> Result, String> { let arch = device_arch()?; - let urls = match channel { - UpdateChannel::Stable => stable_manifest_urls(arch), - UpdateChannel::Beta => { - let Some(latest) = fetch_latest_beta_release().await? else { - return Err("尚未发布过 Beta 版本".to_string()); - }; - beta_manifest_urls(arch, &latest.tag_name) - } - }; + // Manifests live on the project CDN; channel is a URL path, not a filename suffix, so + // there is no tag discovery and no GitHub dependency (see `crate::update_source`). + let url = crate::update_source::manifest_url(channel, "android", arch); let current = env!("CARGO_PKG_VERSION").to_string(); - let mut last_err = String::new(); - for url in urls { - match fetch_manifest(&url).await { - Ok(manifest) => { - if !version_is_newer(&manifest.version, ¤t) { - return Ok(None); - } - let raw_json = serde_json::json!({ - "version": manifest.version, - "url": manifest.url, - "signature": manifest.signature, - "pubDate": manifest.pub_date, - "notes": manifest.notes, - }); - return Ok(Some(AppUpdateMetadata { - rid: 0, - current_version: current, - version: manifest.version, - date: manifest.pub_date, - body: manifest.notes, - raw_json, - })); + match fetch_manifest(&url).await { + Ok(manifest) => { + if !version_is_newer(&manifest.version, ¤t) { + return Ok(None); } - Err(err) => last_err = err, + let raw_json = serde_json::json!({ + "version": manifest.version, + "url": manifest.url, + "signature": manifest.signature, + "pubDate": manifest.pub_date, + "notes": manifest.notes, + }); + Ok(Some(AppUpdateMetadata { + rid: 0, + current_version: current, + version: manifest.version, + date: manifest.pub_date, + body: manifest.notes, + raw_json, + })) } + Err(err) => Err(err), } - Err(if last_err.is_empty() { - "无法获取更新清单".to_string() - } else { - last_err - }) } fn verify_signature(apk_bytes: &[u8], signature_b64: &str) -> Result<(), String> { @@ -207,11 +191,6 @@ mod android_impl { } Ok(()) } - - #[allow(dead_code)] - pub fn parse_beta_atom(body: &str) -> Option { - parse_latest_beta_from_atom(body) - } } #[cfg(target_os = "android")] diff --git a/openless-all/app/src-tauri/src/android/updater_logic.rs b/openless-all/app/src-tauri/src/android/updater_logic.rs index 7e8e4fc26..b992e58f5 100644 --- a/openless-all/app/src-tauri/src/android/updater_logic.rs +++ b/openless-all/app/src-tauri/src/android/updater_logic.rs @@ -1,7 +1,4 @@ -//! Pure Android updater helpers (manifest URLs, version compare). Testable on all targets. - -pub const MIRROR_BASE: &str = "https://fastgit.cc/https://github.com/Open-Less/openless"; -pub const DIRECT_BASE: &str = "https://github.com/Open-Less/openless"; +//! Pure Android updater helpers (version compare, minisign verify). Testable on all targets. /// Must match `plugins.updater.pubkey` in `tauri.conf.json` (TAURI_SIGNING_PRIVATE_KEY pair). pub const UPDATER_PUBKEY_B64: &str = @@ -31,20 +28,6 @@ pub fn version_is_newer(remote: &str, current: &str) -> bool { remote > current } -pub fn stable_manifest_urls(arch: &str) -> Vec { - vec![ - format!("{MIRROR_BASE}/releases/latest/download/latest-android-{arch}-mirror.json"), - format!("{DIRECT_BASE}/releases/latest/download/latest-android-{arch}.json"), - ] -} - -pub fn beta_manifest_urls(arch: &str, tag: &str) -> Vec { - vec![ - format!("{MIRROR_BASE}/releases/download/{tag}/latest-android-{arch}-beta-mirror.json"), - format!("{DIRECT_BASE}/releases/download/{tag}/latest-android-{arch}-beta.json"), - ] -} - /// Human-readable manifest fetch failure for UI tooltips. pub fn format_manifest_error(status: u16, url: &str) -> String { if status == 404 { @@ -120,63 +103,6 @@ mod tests { assert!(!version_is_newer("not-a-version", "1.3.18-Beta.7")); } - #[test] - fn stable_manifest_urls_use_latest_download_path() { - let urls = stable_manifest_urls("aarch64"); - assert_eq!(urls.len(), 2); - assert!(urls[0].contains("latest-android-aarch64-mirror.json")); - assert!(urls[1].ends_with("latest-android-aarch64.json")); - assert!(!urls[1].contains("-beta")); - } - - #[test] - fn beta_manifest_urls_include_tag_and_beta_suffix() { - let urls = beta_manifest_urls("aarch64", "v1.3.8-1-beta-tauri"); - assert!(urls[0].contains("/releases/download/v1.3.8-1-beta-tauri/")); - assert!(urls[1].ends_with("latest-android-aarch64-beta.json")); - } - - #[test] - fn beta_manifest_urls_use_newest_modern_beta_tag_from_atom() { - let body = r#" - - 2026-07-15T07:00:00Z - - - - 2026-06-17T15:41:46Z - - -"#; - let latest = crate::commands::parse_latest_beta_from_atom(body) - .expect("Android updater must discover the modern Beta tag"); - - let urls = beta_manifest_urls("aarch64", &latest.tag_name); - - assert_eq!(latest.tag_name, "v1.3.15-Beta.1-tauri"); - assert!(urls - .iter() - .all(|url| url.contains("/releases/download/v1.3.15-Beta.1-tauri/"))); - } - - #[test] - fn beta_manifest_urls_skip_malformed_modern_tags_from_atom() { - let body = r#" - - - -"#; - let latest = crate::commands::parse_latest_beta_from_atom(body) - .expect("Android updater must skip malformed Beta tags"); - - let urls = beta_manifest_urls("aarch64", &latest.tag_name); - - assert_eq!(latest.tag_name, "v1.3.15-Beta.1-tauri"); - assert!(urls - .iter() - .all(|url| url.contains("/releases/download/v1.3.15-Beta.1-tauri/"))); - } - #[test] fn map_abi_to_arch_maps_arm64() { assert_eq!(map_abi_to_arch("arm64-v8a"), "aarch64"); diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 2b869abce..0a6ac05ee 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -690,20 +690,21 @@ fn extract_between(haystack: &str, open: &str, close: &str) -> Option { // ─────────────────────── Channel-aware updater check ──────────────────────── // // Replaces the frontend's previous direct import('@tauri-apps/plugin-updater').check() path: -// - Stable channel: the builder keeps tauri.conf's stable manifest URL endpoints untouched. -// - Beta channel: fetch_latest_beta_release gets the latest prerelease tag, builds the -beta -// manifest URL (as a mirror + direct pair), then +// - Stable channel: the builder keeps tauri.conf's Stable manifest endpoint untouched, which +// is `https://dl.aeoform.com/updates/stable/latest-{{target}}-{{arch}}.json`. +// - Beta channel: swap in `https://dl.aeoform.com/updates/beta/...-beta.json` through // builder.endpoints(vec![url])?.build()?.check(). // +// Manifests live on the project CDN instead of the GitHub Release assets (see +// `crate::update_source`), so a release no longer accumulates fourteen `latest-*.json` copies. +// The channel split is now a URL *path* (`/updates/stable/` vs `/updates/beta/`) instead of a +// filename suffix, and artifact URLs inside the manifest point at the same CDN — a Stable +// client can never reach a Beta package, and mainland-China downloads never touch github.com. +// // The returned Metadata shape exactly matches plugin-updater's JS UpdateMetadata (rid + // currentVersion and other camelCase fields), so the frontend can call // `new Update(metadata)` directly and reuse the plugin's download / install / close // implementations instead of writing our own download and signature verification. -// -// Physical isolation: manifests published from Beta tags carry a `-beta` filename suffix -// (see the comment at line 382 of release-tauri.yml), separate from Stable's -// `latest-{tgt}-{arch}.json` in the GitHub Release assets — even if buggy code passed a -// Beta URL to a Stable user, HTTP returns 404 outright; the wrong artifact is unreachable. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -720,7 +721,7 @@ pub struct AppUpdateMetadata { pub raw_json: serde_json::Value, } -/// Resolves the manifest source, then runs plugin-updater's standard check flow. +/// Resolves the update endpoint, then runs plugin-updater's standard check flow. /// Channel: uses an explicitly passed `channel` (the About page always checks Stable, the /// advanced page's Beta section checks Beta); when absent, uses the user's explicit /// channel choice; when none chosen yet, follows the current build type. @@ -742,10 +743,9 @@ pub async fn app_check_update_with_channel( builder = builder.timeout(std::time::Duration::from_millis(ms)); } if matches!(channel, UpdateChannel::Beta) { - let urls = resolve_beta_manifest_endpoints().await?; builder = builder - .endpoints(urls) - .map_err(|e| format!("set beta endpoints: {e}"))?; + .endpoints(vec![beta_manifest_endpoint()?]) + .map_err(|e| format!("set beta endpoint: {e}"))?; } let updater = builder.build().map_err(|e| format!("build updater: {e}"))?; let update = updater @@ -770,28 +770,17 @@ pub async fn app_check_update_with_channel( Ok(Some(metadata)) } -/// Builds the -beta manifest URL pair from the latest prerelease tag found by -/// fetch_latest_beta_release. -/// Order: mirror first (fastgit.cc proxies GitHub), then direct — matching tauri.conf's -/// existing Stable endpoints so mainland-China access hits the CDN first. +/// The Beta manifest endpoint, with `{{target}}` / `{{arch}}` left in place for +/// `tauri-plugin-updater` to substitute at check time. +/// +/// Beta is a URL *path* on the CDN (`/updates/beta/`), so there is no tag discovery step and +/// no dependency on `releases.atom` or the GitHub Release assets. #[cfg(not(mobile))] -async fn resolve_beta_manifest_endpoints() -> Result, String> { - let Some(latest) = fetch_latest_beta_release().await? else { - return Err("尚未发布过 Beta 版本".to_string()); - }; - let tag = latest.tag_name; - // {{target}} / {{arch}} placeholders are substituted by the plugin at check time. A - // Rust raw string (r#""#) needs no escaped double braces and is cleaner than format!-ing - // the literal. - let mirror = format!( - "https://fastgit.cc/https://github.com/Open-Less/openless/releases/download/{tag}/latest-{{{{target}}}}-{{{{arch}}}}-beta-mirror.json" - ); - let direct = format!( - "https://github.com/Open-Less/openless/releases/download/{tag}/latest-{{{{target}}}}-{{{{arch}}}}-beta.json" - ); - let mirror_url = url::Url::parse(&mirror).map_err(|e| format!("parse beta mirror url: {e}"))?; - let direct_url = url::Url::parse(&direct).map_err(|e| format!("parse beta direct url: {e}"))?; - Ok(vec![mirror_url, direct_url]) +fn beta_manifest_endpoint() -> Result { + let template = crate::update_source::endpoint_template(UpdateChannel::Beta); + // `url` percent-encodes the `{{target}}` / `{{arch}}` braces; the plugin substitutes both + // the literal and the `%7B%7B…%7D%7D` form, so either spelling works at check time. + url::Url::parse(&template).map_err(|e| format!("parse beta manifest url: {e}")) } #[cfg(mobile)] @@ -823,11 +812,16 @@ pub async fn app_download_and_install_android_update( version: String, ) -> Result<(), String> { // Security: validate the URL before downloading to prevent SSRF (e.g. intranet metadata - // endpoints, localhost services). Only the known GitHub direct links and the fastgit - // mirror prefix are allowed. + // endpoints, localhost services). Only the project CDN and the legacy GitHub/fastgit + // prefixes are allowed — manifests used to point at the latter two, and artifacts already + // cached by an older client must keep installing. + const CDN_BASE: &str = crate::update_source::CDN_BASE; const DIRECT_BASE: &str = "https://github.com/Open-Less/openless"; const MIRROR_BASE: &str = "https://fastgit.cc/https://github.com/Open-Less/openless"; - if !url.starts_with(DIRECT_BASE) && !url.starts_with(MIRROR_BASE) { + if !url.starts_with(CDN_BASE) + && !url.starts_with(DIRECT_BASE) + && !url.starts_with(MIRROR_BASE) + { return Err(format!("不信任的更新 URL,拒绝下载: {url}")); } #[cfg(target_os = "android")] diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index e0cf76be2..49150f7d1 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -116,6 +116,7 @@ mod unicode_keystroke; #[cfg(mobile)] #[path = "mobile_stubs/unicode_keystroke.rs"] mod unicode_keystroke; +mod update_source; #[cfg(target_os = "windows")] mod windows_ime_ipc; mod windows_ime_profile; diff --git a/openless-all/app/src-tauri/src/update_source.rs b/openless-all/app/src-tauri/src/update_source.rs new file mode 100644 index 000000000..26ca3f528 --- /dev/null +++ b/openless-all/app/src-tauri/src/update_source.rs @@ -0,0 +1,130 @@ +//! Where the in-app updater looks for release manifests. +//! +//! # Background +//! +//! The updater used to read its manifest from the GitHub Release assets +//! (`releases/latest/download/latest-{target}-{arch}.json`, and a `-beta` twin per tag). That +//! forced every release to carry fourteen `latest-*.json` files — seven target/arch entries +//! times a `-mirror` twin — all repeating the same three fields (`version`, `url`, `signature`), +//! and every new release re-uploaded the same file names because the Stable endpoint was a +//! rolling pointer. +//! +//! The manifests now live on the project's own CDN: +//! +//! ```text +//! https://dl.aeoform.com/updates/stable/latest-{target}-{arch}.json +//! https://dl.aeoform.com/updates/beta/latest-{target}-{arch}-beta.json +//! ``` +//! +//! Two properties matter: +//! +//! - **One rolling file per platform.** The CDN name is stable per channel, so a release never +//! accumulates manifest copies; publishing overwrites in place. +//! - **Channel separation is a path, not a filename suffix.** `/updates/stable/` and +//! `/updates/beta/` are distinct locations, so a Stable client cannot walk into a Beta +//! manifest by accident. +//! +//! The manifest body is unchanged (`version`, `pub_date`, `url`, `signature`), so +//! `tauri-plugin-updater` keeps owning download, minisign verification and platform install. +//! Artifact URLs inside the manifest point at the same CDN (`/releases/{tag}/{asset}`), which +//! is what mainland-China users download from. + +use crate::types::UpdateChannel; + +/// CDN that serves both the update manifests and the mirrored release artifacts. +pub(crate) const CDN_BASE: &str = "https://dl.aeoform.com"; + +/// Manifest URL for `channel`, with the `{{target}}` / `{{arch}}` tokens left in place for +/// `tauri-plugin-updater` to substitute at check time. +/// +/// `format!` doubles the braces, so `{{{{target}}}}` renders as the literal `{{target}}`. +pub fn endpoint_template(channel: UpdateChannel) -> String { + let (path, suffix) = match channel { + UpdateChannel::Stable => ("stable", ""), + UpdateChannel::Beta => ("beta", "-beta"), + }; + format!("{CDN_BASE}/updates/{path}/latest-{{{{target}}}}-{{{{arch}}}}{suffix}.json") +} + +/// Concrete manifest URL for clients that fetch the manifest themselves (Android). +/// +/// `target`/`arch` use the updater token convention (`android` + `aarch64`, `darwin` + +/// `x86_64`, …), i.e. the same values the plugin would substitute into +/// [`endpoint_template`]. +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +pub fn manifest_url(channel: UpdateChannel, target: &str, arch: &str) -> String { + endpoint_template(channel) + .replace("{{target}}", target) + .replace("{{arch}}", arch) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_endpoint_has_no_beta_marker() { + let url = endpoint_template(UpdateChannel::Stable); + assert!( + !url.ends_with('/'), + "endpoint must not end with a slash: {url}" + ); + assert!(url.starts_with("https://dl.aeoform.com/updates/stable/")); + assert!(url.contains("{{target}}") && url.contains("{{arch}}")); + assert!(!url.contains("beta")); + } + + #[test] + fn beta_endpoint_is_a_separate_path_with_suffix() { + let url = endpoint_template(UpdateChannel::Beta); + assert!(url.starts_with("https://dl.aeoform.com/updates/beta/")); + assert!(url.ends_with("-{{target}}-{{arch}}-beta.json")); + } + + #[test] + fn manifest_url_substitutes_every_token() { + for (channel, expected) in [ + ( + UpdateChannel::Stable, + "https://dl.aeoform.com/updates/stable/latest-darwin-aarch64.json", + ), + ( + UpdateChannel::Beta, + "https://dl.aeoform.com/updates/beta/latest-darwin-aarch64-beta.json", + ), + ] { + let url = manifest_url(channel, "darwin", "aarch64"); + assert_eq!(url, expected); + assert!(!url.contains("{{"), "unsubstituted token in {url}"); + } + } + + #[test] + fn manifest_url_covers_every_published_target() { + for (target, arch, expected) in [ + ("darwin", "aarch64", "latest-darwin-aarch64.json"), + ("darwin", "x86_64", "latest-darwin-x86_64.json"), + ("windows", "x86_64", "latest-windows-x86_64.json"), + ("android", "aarch64", "latest-android-aarch64.json"), + ("android", "armv7", "latest-android-armv7.json"), + ("android", "x86_64", "latest-android-x86_64.json"), + ("android", "i686", "latest-android-i686.json"), + ] { + assert!( + manifest_url(UpdateChannel::Stable, target, arch).ends_with(expected), + "{target}/{arch} should resolve to {expected}" + ); + assert!( + manifest_url(UpdateChannel::Beta, target, arch) + .ends_with(&expected.replace(".json", "-beta.json")), + "{target}/{arch} beta should carry the -beta suffix" + ); + } + } + + #[test] + fn cdn_base_has_no_trailing_slash() { + assert!(!CDN_BASE.ends_with('/')); + assert!(endpoint_template(UpdateChannel::Stable).starts_with(CDN_BASE)); + } +} diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index a0086f01d..e491a9b78 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -109,8 +109,7 @@ "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFERUFBODAzNTY0QzMyM0YKUldRL01reFdBNmpxSGE1K0JadlpONXNWTzhJcGZCRGxjUVdIWExNNFJpeUNsSGZwazdlQThhemkK", "endpoints": [ - "https://github.com/Open-Less/openless/releases/latest/download/latest-{{target}}-{{arch}}.json", - "https://fastgit.cc/https://github.com/Open-Less/openless/releases/latest/download/latest-{{target}}-{{arch}}-mirror.json" + "https://dl.aeoform.com/updates/stable/latest-{{target}}-{{arch}}.json" ] } } From ecc99943096b905e87617bce4531bcdd46dd7619 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 27 Oct 2026 07:06:29 +0800 Subject: [PATCH 2/3] =?UTF-8?q?ci(release):=20=E6=B8=85=E5=8D=95=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8C=82=E5=88=B0=20Release=EF=BC=8C=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=20workflow=20=E4=BA=A7=E7=89=A9=E4=BE=9B=20CDN=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 客户端已经改读 dl.aeoform.com 的 /updates/{stable,beta}/ 清单(上一提交), release 里那 14 个 latest-*.json 没有任何客户端再读,继续上传只会让每个 release 多背 14 个几乎相同的文件。 - release-tauri.yml:softprops files 去掉 bundle/latest-*.json。桌面清单仍然 通过既有的 openless-macos--updater / openless-windows-x64-updater 产物离开 workflow(CDN 同步从那里取)。 - android-apk.yml:release 文件列表去掉 latest-android-*.json;新增 openless-android-updater-manifests 产物,否则 Android 清单会随删除一起丢失 (原来它只存在于 release 资产里)。顺带把 release 正文里的清单说明改成 CDN 口径。 - 契约测试跟着收紧: - release-publication-contract:两个 workflow 的发布者 files 不得含 latest-*.json,且必须有且仅有一个 upload-artifact 携带这些清单。 - android-apk-workflow-contract:把清单文件放进 fixture,断言它们不会进 release 资产,并断言清单产物步骤存在。 - README / README.zh / RELEASING:说明清单由运行产物手动同步到 CDN,以及 改动前的客户端读的是旧 GitHub 路径、因此需要一次手动下载。 --- .github/workflows/android-apk.yml | 18 ++++++++++--- .github/workflows/release-tauri.yml | 11 +++++--- README.md | 2 +- README.zh.md | 2 +- RELEASING.md | 23 +++++++++++++---- .../android-apk-workflow-contract.test.mjs | 25 ++++++++++++------- .../release-publication-contract.test.mjs | 18 +++++++++++++ 7 files changed, 76 insertions(+), 23 deletions(-) diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 7fbe29d4f..ce1b255fe 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -526,7 +526,17 @@ jobs: node scripts/write-updater-manifest.mjs done - - name: Append release files (manifests + signatures) + # The manifests are not release assets any more (the in-app updater reads them from the + # project CDN), but they must still leave the run so the CDN sync can publish them. + - name: Upload Android updater manifests + uses: actions/upload-artifact@v4 + with: + name: openless-android-updater-manifests + if-no-files-found: error + path: | + ${{ steps.stage.outputs.out_dir }}/latest-android-*.json + + - name: Append release files (signatures + aliases) id: release_assets shell: bash run: | @@ -534,7 +544,7 @@ jobs: out_dir="${{ steps.stage.outputs.out_dir }}" { echo "${{ steps.stage.outputs.release_files }}" - for f in "$out_dir"/*.apk.sig "$out_dir"/latest-android-*.json "$out_dir"/OpenLess_android_*.apk; do + for f in "$out_dir"/*.apk.sig "$out_dir"/OpenLess_android_*.apk; do [ -e "$f" ] && echo "$f" done } > "$RUNNER_TEMP/android-release-files.txt" @@ -555,8 +565,8 @@ jobs: ### Android 应用内更新 - - Stable 用户检查 `latest-android-aarch64.json`(及 mirror 变体)。 - - Beta 用户(设置 → 高级 → 加入 Beta)检查带 `-beta` 后缀的 manifest。 + - 清单由项目 CDN 提供:Stable 读 `/updates/stable/latest-android-.json`,Beta 读 `/updates/beta/latest-android--beta.json`。 + - 应用内会自动检查并拉起系统安装器,无需手动下载清单。 EOF - name: Attach Android assets to GitHub Release diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index feecb025a..448061777 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -704,8 +704,9 @@ jobs: # The administrator explicitly publishes the shared release afterwards. draft: true # Beta releases must be marked prerelease=true: GitHub UI collapses them and regular - # users do not see them; only latest-*-beta.json is uploaded, so stable users' - # endpoint (latest-*.json) is never overwritten and beta never leaks into stable. + # users do not see them. Channel isolation for the updater is a CDN URL path + # (`/updates/stable/` vs `/updates/beta/`, see src-tauri/src/update_source.rs), not a + # filename suffix in these assets. prerelease: ${{ env.OPENLESS_RELEASE_CHANNEL == 'beta' }} # Non-leader jobs have OPENLESS_RELEASE_BODY_PATH as an empty string; softprops keeps # the existing content via the `body || existing.body` branch when body is empty. @@ -715,6 +716,11 @@ jobs: body_path: ${{ env.OPENLESS_RELEASE_BODY_PATH }} # generate_release_notes also runs only on the leader, avoiding duplicates from matrix jobs. generate_release_notes: ${{ matrix.updater-target == 'darwin' && matrix.updater-arch == 'aarch64' }} + # `latest-*.json` is deliberately NOT attached: the in-app updater reads its manifests + # from the project CDN (`/updates/{stable,beta}/`, see src-tauri/src/update_source.rs), + # and cloning them here added fourteen near-identical files to every release. The + # manifests still leave the workflow through the `*-updater` artifacts above, which is + # where the CDN sync job picks them up. files: | openless-all/app/src-tauri/target/release/bundle/dmg/*.dmg openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz @@ -723,5 +729,4 @@ jobs: openless-all/app/src-tauri/target/release/bundle/nsis/*.exe.sig openless-all/app/src-tauri/target/release/bundle/msi/*.msi openless-all/app/src-tauri/target/release/bundle/msi/*.msi.sig - openless-all/app/src-tauri/target/release/bundle/latest-*.json openless-all/app/src-tauri/target/release/downloads/* diff --git a/README.md b/README.md index 3e191ac65..0d0a74735 100644 --- a/README.md +++ b/README.md @@ -339,7 +339,7 @@ https://dl.aeoform.com/updates/stable/latest-{target}-{arch}.json https://dl.aeoform.com/updates/beta/latest-{target}-{arch}-beta.json ``` -The channel split is the URL path (`/updates/stable/` vs `/updates/beta/`), so a Stable client cannot reach a Beta manifest even if it asked for the wrong URL. Artifact URLs inside the manifest point at the same CDN (`https://dl.aeoform.com/releases/{tag}/{asset}`), which is what mainland-China users download from. Cloning manifests into the GitHub Release assets is no longer required by current clients; it is retained only so clients built before this change keep updating. +The channel split is the URL path (`/updates/stable/` vs `/updates/beta/`), so a Stable client cannot reach a Beta manifest even if it asked for the wrong URL. Artifact URLs inside the manifest point at the same CDN (`https://dl.aeoform.com/releases/{tag}/{asset}`), which is what mainland-China users download from. The manifests are generated by the release workflows as run artifacts and are **not** attached to the GitHub Release, so maintainers must upload them to the CDN after a build — see `RELEASING.md`. Clients built before this switch read the old `releases/latest/download/latest-*.json` assets and therefore cannot auto-update past the last release that still carries them; they need one manual download. ## Credentials diff --git a/README.zh.md b/README.zh.md index 1e744c20d..119bd6e41 100644 --- a/README.zh.md +++ b/README.zh.md @@ -346,7 +346,7 @@ https://dl.aeoform.com/updates/stable/latest-{target}-{arch}.json https://dl.aeoform.com/updates/beta/latest-{target}-{arch}-beta.json ``` -渠道隔离就是 URL 路径(`/updates/stable/` vs `/updates/beta/`),Stable 客户端即使请求错地址也拿不到 Beta 清单。清单里的下载地址同样指向 CDN(`https://dl.aeoform.com/releases/{tag}/{asset}`),国内用户直接从这里下载。当前客户端已不需要 GitHub Release 资产里的清单副本,那边继续保留只是为了改动前的旧客户端还能更新。 +渠道隔离就是 URL 路径(`/updates/stable/` vs `/updates/beta/`),Stable 客户端即使请求错地址也拿不到 Beta 清单。清单里的下载地址同样指向 CDN(`https://dl.aeoform.com/releases/{tag}/{asset}`),国内用户直接从这里下载。清单由发布 workflow 生成为运行产物(run artifact),**不再挂到 GitHub Release**;因此发版后需要维护者手动把清单上传到 CDN,见 `RELEASING.md`。改动前装的客户端读的是旧的 `releases/latest/download/latest-*.json`,因此无法自动更新到最后一个还带这些文件的版本之后,需要手动下载一次。 ## 凭据 diff --git a/RELEASING.md b/RELEASING.md index c6855b261..032d9fa52 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -152,18 +152,31 @@ The script takes a plain `X.Y.Z`; for a prerelease version such as draft. Wait for **both** workflows to succeed, verify the packages, then publish that draft (Beta as a prerelease; Stable as latest). Do not rerun an asset workflow after publication without first returning the release to draft. + The workflows also write the channel updater manifests (`latest-{target}-{arch}.json` and - `latest-{target}-{arch}-beta.json`). Current clients do **not** read those release assets: - they read the CDN, so publishing a release is only half the job — the manifests must also - land on the CDN at + `latest-{target}-{arch}-beta.json`) but deliberately keep them **out** of the release assets — + current clients read the CDN, not the release. Download them from the run and publish them to + the CDN as part of publishing the release: + + | platform | workflow artifact | + | --- | --- | + | macOS | `openless-macos--updater` | + | Windows | `openless-windows-x64-updater` | + | Android | `openless-android-updater-manifests` | + + Destination (overwrite in place; the file name is per platform and channel, never per + version): ```text https://dl.aeoform.com/updates/stable/latest-{target}-{arch}.json https://dl.aeoform.com/updates/beta/latest-{target}-{arch}-beta.json ``` - with their `url` fields pointing at the CDN artifacts (`.../releases/{tag}/{asset}`). The - in-release copies exist only for clients built before the CDN switch. + Their `url` fields must point at the CDN artifacts (`.../releases/{tag}/{asset}`). + Manifest upload is the step that makes a release reachable by auto-update: a published + release whose manifests were not synced is downloadable by hand but invisible to the updater. + Clients built before the CDN switch read the old `releases/latest/download/latest-*.json` + assets, which new releases no longer carry, so they need one manual download. 7. Publishing Stable with the administrator's CLI/UI credential automatically triggers `sync-homebrew-cask.yml`. It accepts only the published latest Stable tag, preserves application build metadata in DMG filenames, verifies matching diff --git a/openless-all/app/scripts/android-apk-workflow-contract.test.mjs b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs index 33fffe94f..90312c3cd 100644 --- a/openless-all/app/scripts/android-apk-workflow-contract.test.mjs +++ b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs @@ -169,16 +169,15 @@ try { const apk = `OpenLess_${version}_${gradleAbi}.apk`; const alias = `OpenLess_android_${gradleAbi}.apk`; originals.push(`${directory}/${apk}`); - return [ - apk, - `${apk}.sig`, - alias, - `${alias}.sig`, - `latest-android-${abi}.json`, - `latest-android-${abi}-mirror.json`, - ]; + return [apk, `${apk}.sig`, alias, `${alias}.sig`]; }); - for (const name of names) writeFileSync(join(releaseTmp, name), 'fixture'); + // The manifests are still written (they feed the CDN sync through a workflow artifact) but + // must never be re-attached to the release: the in-app updater reads them from the CDN. + const manifests = ANDROID_ABI_MATRIX.flatMap(({ abi }) => [ + `latest-android-${abi}.json`, + `latest-android-${abi}-mirror.json`, + ]); + for (const name of [...names, ...manifests]) writeFileSync(join(releaseTmp, name), 'fixture'); const step = parsed.jobs['publish-android-release'].steps.find( (step) => step.id === 'release_assets', ); @@ -199,6 +198,14 @@ try { .trim() .split(/\r?\n/); assert.deepEqual(assets.sort(), names.map((name) => `${directory}/${name}`).sort()); + const manifestUpload = parsed.jobs['publish-android-release'].steps.find((step) => + step.uses?.startsWith('actions/upload-artifact@'), + ); + assert.match( + manifestUpload?.with.path, + /latest-android-\*\.json/, + 'manifests must leave the run as an artifact so the CDN sync can publish them', + ); } finally { rmSync(releaseTmp, { recursive: true, force: true }); } diff --git a/openless-all/app/scripts/release-publication-contract.test.mjs b/openless-all/app/scripts/release-publication-contract.test.mjs index b8d3e21cb..a56ee867a 100644 --- a/openless-all/app/scripts/release-publication-contract.test.mjs +++ b/openless-all/app/scripts/release-publication-contract.test.mjs @@ -15,6 +15,24 @@ for (const name of ['release-tauri.yml', 'android-apk.yml']) { `${name}: no channel may publish a partially built release`, ); assert.match(publishers[0].with.prerelease, /OPENLESS_RELEASE_CHANNEL == 'beta'/); + // Updater manifests belong to the CDN, not to the release assets: current clients read + // `/updates/{stable,beta}/` (see src-tauri/src/update_source.rs), and cloning them into the + // release produced fourteen near-identical files per tag. + assert.doesNotMatch( + publishers[0].with.files, + /latest-[^\s]*\.json/, + `${name}: updater manifests must not be attached to the release`, + ); + const manifestUploads = steps.filter( + (step) => + step.uses?.startsWith('actions/upload-artifact@') && + /latest-[^\s]*\.json/.test(step.with?.path ?? ''), + ); + assert.equal( + manifestUploads.length, + 1, + `${name}: exactly one artifact must carry the updater manifests for the CDN sync`, + ); } const sync = workflow('sync-homebrew-cask.yml'); assert.deepEqual(sync.on.release.types, ['published']); From 035d982be70a28863c8cc0c5c0b93d559ee1eb2f Mon Sep 17 00:00:00 2001 From: Chris233 Date: Tue, 27 Oct 2026 21:05:35 +0800 Subject: [PATCH 3/3] fix(updater): use CDN artifacts and validate Android URLs --- .github/workflows/android-apk.yml | 3 +- .github/workflows/release-tauri.yml | 10 +-- .../write-updater-manifest-contract.test.mjs | 66 +++++++++++++++++++ .../app/scripts/write-updater-manifest.mjs | 19 +++--- .../app/src-tauri/src/commands/settings.rs | 57 ++++++++++++---- 5 files changed, 124 insertions(+), 31 deletions(-) create mode 100644 openless-all/app/scripts/write-updater-manifest-contract.test.mjs diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index ce1b255fe..5e15404b1 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -515,8 +515,7 @@ jobs: working-directory: openless-all/app env: OPENLESS_UPDATE_APK_DIR: ${{ steps.stage.outputs.out_dir }} - OPENLESS_UPDATE_REPO: Open-Less/openless - OPENLESS_UPDATE_MIRROR_BASE_URL: https://fastgit.cc/https://github.com + OPENLESS_UPDATE_CDN_BASE_URL: https://dl.aeoform.com OPENLESS_RELEASE_CHANNEL: ${{ env.OPENLESS_RELEASE_CHANNEL }} OPENLESS_RELEASE_TAG: ${{ github.ref_name }} run: | diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 0616902f9..ed7880b9a 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -531,15 +531,11 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} OPENLESS_UPDATE_TARGET: ${{ matrix.updater-target }} OPENLESS_UPDATE_ARCH: ${{ matrix.updater-arch }} - OPENLESS_UPDATE_REPO: ${{ github.repository }} - OPENLESS_UPDATE_MIRROR_BASE_URL: https://fastgit.cc/https://github.com + OPENLESS_UPDATE_CDN_BASE_URL: https://dl.aeoform.com # On the beta channel output latest-{tgt}-{arch}-beta.json; stable keeps the old filename. OPENLESS_RELEASE_CHANNEL: ${{ env.OPENLESS_RELEASE_CHANNEL }} - # On the beta channel the script must write manifest.url as releases/download//... - # rather than releases/latest (the latter is always = Stable, so beta users downloading - # via url would fetch the wrong file). - # On workflow_dispatch github.ref_name is not a tag — but then channel=stable and the - # script does not read this field, so it is safe. + # Manifest URLs use the CDN's tag-scoped `releases//` path. Stable + # workflow validation without a release tag falls back to the CDN `latest` path. OPENLESS_RELEASE_TAG: ${{ env.OPENLESS_RELEASE_TAG }} run: node scripts/write-updater-manifest.mjs diff --git a/openless-all/app/scripts/write-updater-manifest-contract.test.mjs b/openless-all/app/scripts/write-updater-manifest-contract.test.mjs new file mode 100644 index 000000000..4acf2e332 --- /dev/null +++ b/openless-all/app/scripts/write-updater-manifest-contract.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const script = fileURLToPath(new URL('./write-updater-manifest.mjs', import.meta.url)); +const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); +const releaseTag = 'v2.0.2-Beta.3-tauri'; +const tempDir = mkdtempSync(join(tmpdir(), 'openless-updater-')); +const assetName = `OpenLess_${packageJson.version}_arm64-v8a.apk`; + +try { + writeFileSync(join(tempDir, assetName), 'fixture'); + writeFileSync(join(tempDir, `${assetName}.sig`), 'signature'); + execFileSync(process.execPath, [script], { + env: { + ...process.env, + OPENLESS_UPDATE_APK_DIR: tempDir, + OPENLESS_UPDATE_ARCH: 'aarch64', + OPENLESS_UPDATE_CDN_BASE_URL: 'https://cdn.example/', + OPENLESS_UPDATE_TARGET: 'android', + OPENLESS_RELEASE_CHANNEL: 'beta', + OPENLESS_RELEASE_TAG: releaseTag, + }, + stdio: 'pipe', + }); + + const expected = + `https://cdn.example/releases/${encodeURIComponent(releaseTag)}/` + + encodeURIComponent(assetName); + for (const suffix of ['', '-mirror']) { + const manifest = JSON.parse( + readFileSync(join(tempDir, `latest-android-aarch64-beta${suffix}.json`), 'utf8'), + ); + assert.equal(manifest.url, expected); + } + + const stableTag = 'v2.0.1-tauri'; + execFileSync(process.execPath, [script], { + env: { + ...process.env, + OPENLESS_UPDATE_APK_DIR: tempDir, + OPENLESS_UPDATE_ARCH: 'aarch64', + OPENLESS_UPDATE_CDN_BASE_URL: 'https://cdn.example/', + OPENLESS_UPDATE_TARGET: 'android', + OPENLESS_RELEASE_CHANNEL: 'stable', + OPENLESS_RELEASE_TAG: stableTag, + }, + stdio: 'pipe', + }); + const stableExpected = + `https://cdn.example/releases/${encodeURIComponent(stableTag)}/` + + encodeURIComponent(assetName); + for (const suffix of ['', '-mirror']) { + const manifest = JSON.parse( + readFileSync(join(tempDir, `latest-android-aarch64${suffix}.json`), 'utf8'), + ); + assert.equal(manifest.url, stableExpected); + } +} finally { + rmSync(tempDir, { recursive: true, force: true }); +} + +console.log('write-updater-manifest-contract.test.mjs passed'); diff --git a/openless-all/app/scripts/write-updater-manifest.mjs b/openless-all/app/scripts/write-updater-manifest.mjs index d9512ed98..6c1258c95 100755 --- a/openless-all/app/scripts/write-updater-manifest.mjs +++ b/openless-all/app/scripts/write-updater-manifest.mjs @@ -6,9 +6,10 @@ import { fileURLToPath } from 'node:url'; const target = process.env.OPENLESS_UPDATE_TARGET; const arch = process.env.OPENLESS_UPDATE_ARCH; -const repo = process.env.OPENLESS_UPDATE_REPO || 'Open-Less/openless'; -const mirrorBaseUrl = - process.env.OPENLESS_UPDATE_MIRROR_BASE_URL || 'https://fastgit.cc/https://github.com'; +const cdnBaseUrl = (process.env.OPENLESS_UPDATE_CDN_BASE_URL || 'https://dl.aeoform.com').replace( + /\/$/, + '', +); const rawChannel = (process.env.OPENLESS_RELEASE_CHANNEL || 'stable').toLowerCase(); if (rawChannel !== 'stable' && rawChannel !== 'beta') { throw new Error( @@ -111,21 +112,17 @@ if (!existsSync(signaturePath)) { const assetName = basename(artifact); const manifestName = `latest-${target}-${arch}${channelSuffix}.json`; const mirrorManifestName = `latest-${target}-${arch}${channelSuffix}-mirror.json`; -const downloadPath = - rawChannel === 'beta' - ? `releases/download/${releaseTag}/${assetName}` - : `releases/latest/download/${assetName}`; -const githubAssetUrl = `https://github.com/${repo}/${downloadPath}`; -const mirrorAssetUrl = `${mirrorBaseUrl.replace(/\/$/, '')}/${repo}/${downloadPath}`; +const releasePath = + rawChannel === 'beta' ? releaseTag : /^v.+-tauri$/.test(releaseTag) ? releaseTag : 'latest'; +const cdnAssetUrl = `${cdnBaseUrl}/releases/${encodeURIComponent(releasePath)}/${encodeURIComponent(assetName)}`; const manifest = { version: packageJson.version, pub_date: new Date().toISOString(), - url: githubAssetUrl, + url: cdnAssetUrl, signature: readFileSync(signaturePath, 'utf8').trim(), }; const mirrorManifest = { ...manifest, - url: mirrorAssetUrl, }; const outDir = target === 'android' ? bundleDir : bundleDir; diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 758a6fe5b..4ee82123e 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -539,6 +539,28 @@ mod tests { assert_eq!(stale_payload.update_channel, UpdateChannel::Stable); assert!(stale_payload.update_channel_explicit); } + + #[test] + fn android_update_url_validation_requires_exact_https_release_hosts() { + assert!(is_allowed_android_update_url( + "https://dl.aeoform.com/releases/v2.0.2-tauri/OpenLess_aarch64.app.tar.gz" + )); + assert!(is_allowed_android_update_url( + "https://github.com/Open-Less/openless/releases/latest/download/OpenLess.apk" + )); + assert!(is_allowed_android_update_url( + "https://fastgit.cc/https://github.com/Open-Less/openless/releases/latest/download/OpenLess.apk" + )); + assert!(!is_allowed_android_update_url( + "https://dl.aeoform.com@attacker.example/package.apk" + )); + assert!(!is_allowed_android_update_url( + "https://dl.aeoform.com.attacker.example/package.apk" + )); + assert!(!is_allowed_android_update_url( + "http://dl.aeoform.com/releases/v2.0.2-tauri/OpenLess.apk" + )); + } } // ─────────────────────────── release channel (Beta opt-in) ─────────────────────────── @@ -825,6 +847,27 @@ pub async fn app_check_update_with_channel( } } +fn is_allowed_android_update_url(raw: &str) -> bool { + let Ok(parsed) = url::Url::parse(raw) else { + return false; + }; + if parsed.scheme() != "https" + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.port().is_some() + { + return false; + } + match (parsed.host_str(), parsed.path()) { + (Some("dl.aeoform.com"), path) => path.starts_with("/releases/"), + (Some("github.com"), path) => path.starts_with("/Open-Less/openless/releases/"), + (Some("fastgit.cc"), path) => { + path.starts_with("/https://github.com/Open-Less/openless/releases/") + } + _ => false, + } +} + #[cfg(mobile)] #[tauri::command] pub async fn app_download_and_install_android_update( @@ -833,17 +876,9 @@ pub async fn app_download_and_install_android_update( signature: String, version: String, ) -> Result<(), String> { - // Security: validate the URL before downloading to prevent SSRF (e.g. intranet metadata - // endpoints, localhost services). Only the project CDN and the legacy GitHub/fastgit - // prefixes are allowed — manifests used to point at the latter two, and artifacts already - // cached by an older client must keep installing. - const CDN_BASE: &str = crate::update_source::CDN_BASE; - const DIRECT_BASE: &str = "https://github.com/Open-Less/openless"; - const MIRROR_BASE: &str = "https://fastgit.cc/https://github.com/Open-Less/openless"; - if !url.starts_with(CDN_BASE) - && !url.starts_with(DIRECT_BASE) - && !url.starts_with(MIRROR_BASE) - { + // Parse and validate the host before downloading so userinfo and hostname-prefix tricks + // cannot turn the signed update fetch into an SSRF primitive. + if !is_allowed_android_update_url(&url) { return Err(format!("不信任的更新 URL,拒绝下载: {url}")); } #[cfg(target_os = "android")]