From b93b64a52b4e802cd5d1ff0d1691ad5822ab5269 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Mon, 9 Nov 2026 18:50:59 +0800 Subject: [PATCH 1/3] fix(android): discover signed updates from GitHub and CDN --- docs/desktop-input-and-update.md | 8 +- docs/releases/2.0.2-Beta.6-release-notes.md | 74 ++ openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/package-lock.json | 4 +- openless-all/app/package.json | 2 +- .../scripts/desktop-version-contract.test.mjs | 2 +- openless-all/app/src-tauri/Cargo.lock | 2 +- openless-all/app/src-tauri/Cargo.toml | 2 +- openless-all/app/src-tauri/src/android/mod.rs | 2 + .../src-tauri/src/android/update_pipeline.rs | 586 +++++++++++++ .../app/src-tauri/src/android/updater.rs | 143 +-- .../src-tauri/src/android/updater_logic.rs | 179 +++- .../app/src-tauri/src/update_source.rs | 22 +- .../src-tauri/src/update_source/android.rs | 817 ++++++++++++++++++ openless-all/app/src-tauri/tauri.conf.json | 15 +- 15 files changed, 1731 insertions(+), 129 deletions(-) create mode 100644 docs/releases/2.0.2-Beta.6-release-notes.md create mode 100644 openless-all/app/src-tauri/src/android/update_pipeline.rs create mode 100644 openless-all/app/src-tauri/src/update_source/android.rs diff --git a/docs/desktop-input-and-update.md b/docs/desktop-input-and-update.md index 89aa39881..f5eb3864b 100644 --- a/docs/desktop-input-and-update.md +++ b/docs/desktop-input-and-update.md @@ -16,7 +16,13 @@ macOS 辅助功能设置提供带应用图标的浮动引导,帮助用户将 同版本并发读取两条安装包链路的最多 64 KiB,用首字节延迟与传输速率比较。阿里云延迟不超过 GitHub 的 120%,且速率不低于其 80% 时视为接近,优先阿里云;GitHub 明显更快或探测无法确认阿里云优势时使用 GitHub。判断使用实际网络测量,不读取用户位置。探测上限 4 秒,不预下载整包。 -GitHub 版本从安装包签名的 trusted comment 读取并核对所属 tag,保留 build metadata。版本元数据仍须由 Tauri 的安装包签名校验确认;没有签名版本或版本不匹配不能静默接受。此更新不改变 Android 独立更新路径。 +GitHub 版本从安装包签名的 trusted comment 读取并核对所属 tag,保留 build metadata。版本元数据仍须由 Tauri 的安装包签名校验确认;没有签名版本或版本不匹配不能静默接受。桌面与 Android 使用各自的安装流程和架构选择。 + +## Android 双源更新 + +Android 在同一次检查中并发读取 GitHub 官方 Release 与当前渠道的 CDN 更新清单,从可验证的候选中选择更高版本;Beta 与 Stable 保持分离,并匹配设备 ABI。GitHub 已有新版而 CDN 仍为旧版时,可以直接使用 GitHub 的新版候选,不要求等待 CDN 同步,也不需要用户配置 GitHub 密钥。 + +下载阶段仅比较同一版本、同一架构、同一签名资产的可用链路,根据有界网络探测的延迟与传输表现选择较快来源。一个来源不可用时,只回退到该候选的其他有效链路,不能静默改为另一个版本。每个来源下载的 APK 都必须通过更新签名校验后才能进入安装流程;切换来源不绕过验签。网络探测不读取用户地理位置。 ## 设置与资源生命周期 diff --git a/docs/releases/2.0.2-Beta.6-release-notes.md b/docs/releases/2.0.2-Beta.6-release-notes.md new file mode 100644 index 000000000..8682a578a --- /dev/null +++ b/docs/releases/2.0.2-Beta.6-release-notes.md @@ -0,0 +1,74 @@ +# OpenLess 2.0.2-Beta.6+build.20261109-1f057451 + +本次为 **Beta 测试版**,比较范围为上一公开 Beta `v2.0.2-Beta.4-tauri` 至 `v2.0.2-Beta.6-tauri`。正式版更新源继续保持独立。Beta.5 保留为未公开草稿,本版汇总该草稿中的改进并加入 Android 双源更新与后续修复。 + +## Android 更新与长录音 + +- Android 并发检查 GitHub 与 CDN 的同渠道更新候选,选择可验证的更高版本。GitHub 已更新而 CDN 尚未同步时,可直接从 GitHub 获取新版。 +- 下载时对同一版本与架构的可用链路进行有界测速,选择较快来源;回退仍须使用同一候选并通过签名校验,不需要用户配置 GitHub 密钥。 +- 改善兼容型 ASR 的长录音上传:未设置特定限制时采用默认分段,并在分段边界附近选择较安静的位置,减少一次上传文件过大导致的失败。 +- 自定义兼容 ASR 的零值或空白分段设置仍表示整段上传,不被默认分段覆盖。 +- macOS 输入目标失效时保留已完成的转写并进入复制兜底,避免结果丢失;有效目标仍沿用自动插入。 + +## 桌面界面与设置 + +- 调整界面曲面与嵌套圆角,保留录音胶囊、开关和小型操作按钮各自的形状。 +- 修复录音方式、传统与多模态、主题、概览统计切换控件在缩放后的对齐与选中块显示。 +- 修复快捷键搜索框两侧描边被裁切、背景不明显的问题,改善下拉菜单和焦点轮廓。 +- 高级功能采用独立子页;感知页面的卡片保持清晰间隔,离开页面时释放控件与订阅。 +- 历史列表和用量记录使用容器内滚动;改善输入法选字时的回车处理、消息跟随和跳至最新。 + +## 输入与胶囊 + +- 修复内部预览中 macOS 终稿插入错误进入手动复制兜底的回归,避免流式内容已写入后再次弹出复制窗口。 +- 保留胶囊内实时原文显示。macOS 输入框内实时原文实验已从内部预览撤下;该实验未包含在公开 Beta.4 中。 +- Windows 输入框内实时原文设置集中到「高级功能 → 文字插入」。 +- 改善胶囊翻译语言菜单的窗口隔离、原生点击区域和多屏输入归属。 +- 新增 macOS 辅助功能授权浮动引导,显示应用图标并支持拖入系统授权列表。 + +## 用量与模型缓存 + +- 侧栏设置旁新增用量入口,提供概览、服务与用途、记录三个页面。 +- 已接入的文字处理路径保存服务商明确返回的 Token 报告;数量支持 K/M/B 显示,保留原始数值。 +- 缓存占比按服务、渠道、模型和协议分组,区分不同接口的输入计量口径,并显示可比较报告数量。旧记录或缺失字段保持未知。 +- 固定提示词规则位于动态应用、光标上下文之前,减少可避免的缓存前缀变化。 +- 按实际服务地址、接口和支持的模型启用缓存标记或路由提示;多轮请求继续复用历史。缓存可用性、有效期和写入计费遵循服务商规则,实际收益以新请求返回的用量为准。 + +## 实验性感知与纠错 + +- 光标上下文、手改学词和表达格式学习集中在「高级功能 → 感知」。 +- 表达格式学习默认关闭;当前学习少量全局格式倾向,在本机保存抽象证据并支持清除。 +- 纠错观察过滤部分数字、否定和敏感上下文变化,建议仍由用户确认。 + +## 更新、性能与入口整理 + +- 改善 GitHub 与阿里云更新发现、限流后的备用发现,以及按网络探测选择下载来源。 +- 释放闲置或失败的 WebGL、头像动画和流式字符绘制资源,减少隐藏页面持续绘制。 +- 修复 Windows 选区捕获污染剪贴板历史,以及托盘退出无法结束的问题。 +- 下线桌面 Less Computer / 编码 Agent 和 Claude Console,包括页面、IPC、热键和后台轮询。 +- 整合 CI 并行、缓存、编译产物复用和超时优化。Linux 附包改为逐标签的明确设备验收门槛。 + +## 已知范围 + +- 用量账本尚未完整覆盖 ASR、TTS、原生 Gemini/Codex、多模态和所有重试、失败或取消路径,不能用作完整账单。 +- 感知仍为实验性功能;按应用画像、黑名单和完整表达习惯系统仍在后续计划中。 +- 缓存统计是输入 Token 的加权占比,不能据此承诺所有服务或所有请求达到固定命中率。 +- 安装包构建与自动检查不能替代每一种编辑器、输入法、麦克风和设备上的实机行为验收。 + +## 分发与升级 + +- 发布标签为 `v2.0.2-Beta.6-tauri`,应用版本为 `2.0.2-Beta.6+build.20261109-1f057451`。 +- macOS Apple Silicon 与 Intel、Windows x64、Android 各 ABI 的安装包通过同一标签构建;HarmonyOS 仓库分发包由后续流程附加。 +- 本次不附加尚未完成独立设备验收的 Linux 安装包。 +- Beta 用户可在「设置 → 关于与更新」加入 Beta 渠道,或从本次 GitHub Release 下载对应安装包。 +- macOS 包若使用临时签名且被 Gatekeeper 拦截,可在拖入「应用程序」后按既有发行说明执行: + + ```bash + xattr -cr /Applications/OpenLess.app + ``` + +## 来源与验证 + +本版另外包含 [#1416](https://github.com/Open-Less/openless/pull/1416) 的长录音与目标失效修复,以及本轮 Android 双源更新。此前草稿的主要整合来源包括 [#1400](https://github.com/Open-Less/openless/pull/1400)、[#1406](https://github.com/Open-Less/openless/pull/1406)、[#1407](https://github.com/Open-Less/openless/pull/1407)、[#1410](https://github.com/Open-Less/openless/pull/1410)、[#1411](https://github.com/Open-Less/openless/pull/1411)、[#1413](https://github.com/Open-Less/openless/pull/1413)、[#1414](https://github.com/Open-Less/openless/pull/1414)、[#1415](https://github.com/Open-Less/openless/pull/1415)、[#1418](https://github.com/Open-Less/openless/pull/1418)、[#1419](https://github.com/Open-Less/openless/pull/1419)、[#1420](https://github.com/Open-Less/openless/pull/1420)、[#1421](https://github.com/Open-Less/openless/pull/1421)、[#1422](https://github.com/Open-Less/openless/pull/1422)。对应贡献与作者信息见各 PR 的记录。 + +公开发布前须以最终发布提交上的完整 CI、各平台构建、签名、校验和及 Beta 更新清单核验为准。预览提交上通过的测试不替代发布提交的检查;发布标签、安装包和更新清单应对应同一份源码与版本。 diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index 1b9cba4ae..492dff089 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -151 +152 diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 238e17ae2..e5a021b9e 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "openless-app", - "version": "2.0.2-Beta.5+build.20261109-47405c88", + "version": "2.0.2-Beta.6+build.20261109-1f057451", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "2.0.2-Beta.5+build.20261109-47405c88", + "version": "2.0.2-Beta.6+build.20261109-1f057451", "dependencies": { "@base-ui/react": "^1.6.0", "@shadcn/react": "^0.2.1", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index c69d6d146..b49d025df 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -1,7 +1,7 @@ { "name": "openless-app", "private": true, - "version": "2.0.2-Beta.5+build.20261109-47405c88", + "version": "2.0.2-Beta.6+build.20261109-1f057451", "type": "module", "scripts": { "pretest": "npm run build", diff --git a/openless-all/app/scripts/desktop-version-contract.test.mjs b/openless-all/app/scripts/desktop-version-contract.test.mjs index 644defd57..c3af97b94 100644 --- a/openless-all/app/scripts/desktop-version-contract.test.mjs +++ b/openless-all/app/scripts/desktop-version-contract.test.mjs @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'; const app = new URL('../', import.meta.url); const read = async (path) => (await readFile(new URL(path, app), 'utf8')).replace(/\r\n/g, '\n'); -const version = '2.0.2-Beta.5+build.20261109-47405c88'; +const version = '2.0.2-Beta.6+build.20261109-1f057451'; const pkg = JSON.parse(await read('package.json')); const lock = JSON.parse(await read('package-lock.json')); const tauri = JSON.parse(await read('src-tauri/tauri.conf.json')); diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index dee733805..d580928a5 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -4372,7 +4372,7 @@ dependencies = [ [[package]] name = "openless" -version = "2.0.2-Beta.5+build.20261109-47405c88" +version = "2.0.2-Beta.6+build.20261109-1f057451" dependencies = [ "anyhow", "arboard", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 3215e3e47..e7383e0a5 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openless" -version = "2.0.2-Beta.5+build.20261109-47405c88" +version = "2.0.2-Beta.6+build.20261109-1f057451" license = "AGPL-3.0-only" description = "OpenLess — local voice input that types where your cursor is" authors = ["OpenLess"] diff --git a/openless-all/app/src-tauri/src/android/mod.rs b/openless-all/app/src-tauri/src/android/mod.rs index d5da8bbcb..0e958b22b 100644 --- a/openless-all/app/src-tauri/src/android/mod.rs +++ b/openless-all/app/src-tauri/src/android/mod.rs @@ -12,6 +12,8 @@ pub(crate) mod local_migration; pub mod native_bridge; pub mod overlay; pub mod shizuku; +#[cfg(any(target_os = "android", test))] +pub(crate) mod update_pipeline; #[cfg(target_os = "android")] pub mod updater; pub mod updater_logic; diff --git a/openless-all/app/src-tauri/src/android/update_pipeline.rs b/openless-all/app/src-tauri/src/android/update_pipeline.rs new file mode 100644 index 000000000..1bf12bbfb --- /dev/null +++ b/openless-all/app/src-tauri/src/android/update_pipeline.rs @@ -0,0 +1,586 @@ +//! Source discovery and authenticated APK transfer, shared with host-side tests. + +use std::time::Duration; + +use super::updater_logic::{self, UpdateCandidate}; +use crate::{types::UpdateChannel, update_source}; + +const MAX_APK_BYTES: u64 = 200 * 1024 * 1024; +const MAX_MANIFEST_BYTES: usize = 2 * 1024 * 1024; + +fn validate( + candidate: UpdateCandidate, + channel: UpdateChannel, + arch: &str, +) -> Result { + let version = + semver::Version::parse(&candidate.version).map_err(|_| "invalid update version")?; + let matches_channel = match channel { + UpdateChannel::Stable => version.pre.is_empty(), + UpdateChannel::Beta => version + .pre + .as_str() + .to_ascii_lowercase() + .starts_with("beta."), + }; + if !matches_channel { + return Err("update version does not match selected channel".into()); + } + let url = url::Url::parse(&candidate.url).map_err(|_| "invalid update URL")?; + if !update_source::allowed_update_download_url(&url) { + return Err("untrusted APK source".into()); + } + let abi = match arch { + "aarch64" => "arm64-v8a", + "armv7" => "armeabi-v7a", + "i686" => "x86", + "x86_64" => "x86_64", + _ => return Err("unsupported update architecture".into()), + }; + let filename = format!("OpenLess_{}_{abi}.apk", candidate.version); + let tag_version = format!( + "{}.{}.{}{}", + version.major, + version.minor, + version.patch, + if version.pre.is_empty() { + String::new() + } else { + format!("-{}", version.pre) + } + ); + let signed = update_source::android_signature_version( + &candidate.signature, + &filename, + &tag_version, + arch, + )?; + if signed != candidate.version { + return Err("APK signature version does not match update metadata".into()); + } + Ok(candidate) +} + +async fn cdn_manifest(channel: UpdateChannel, arch: &str) -> Result { + let url = url::Url::parse(&update_source::manifest_url(channel, "android", arch)) + .map_err(|_| "invalid CDN manifest URL")?; + let response = update_source::bounded_update_get(url, Duration::from_secs(8)) + .await + .map_err(|_| "CDN update manifest unavailable")?; + let length = response.content_length(); + let bytes = manifest_bytes(response.bytes_stream(), length).await?; + let candidate = serde_json::from_slice::(&bytes) + .map_err(|_| "invalid CDN update manifest")?; + validate(candidate, channel, arch) +} + +async fn manifest_bytes(mut stream: S, length: Option) -> Result, String> +where + S: futures_util::Stream> + Unpin, + B: AsRef<[u8]>, +{ + use futures_util::StreamExt; + if length.is_some_and(|value| value > MAX_MANIFEST_BYTES as u64) { + return Err("CDN update manifest exceeds size limit".into()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| "CDN update manifest interrupted")?; + let chunk = chunk.as_ref(); + if bytes.len().saturating_add(chunk.len()) > MAX_MANIFEST_BYTES { + return Err("CDN update manifest exceeds size limit".into()); + } + bytes.extend_from_slice(chunk); + } + Ok(bytes) +} + +pub(crate) async fn candidates( + channel: UpdateChannel, + arch: &str, +) -> Result, String> { + let (github, cdn) = tokio::join!( + async { + let value = update_source::github_android_manifest(channel, arch).await?; + validate( + serde_json::from_value(value).map_err(|_| "invalid GitHub update manifest")?, + channel, + arch, + ) + }, + async { + tokio::time::timeout(Duration::from_secs(8), cdn_manifest(channel, arch)) + .await + .map_err(|_| "CDN update check timed out".to_string())? + } + ); + merge_sources(github, cdn) +} + +fn merge_sources( + github: Result, + cdn: Result, +) -> Result, String> { + // A known release with invalid/unavailable signature metadata is not proof + // that an older mirror is the newest release. Preserve the failure. + if let Err(error) = &github { + if error.starts_with("GitHub release candidate:") { + return Err(error.clone()); + } + } + let mut available = Vec::new(); + let mut errors = Vec::new(); + // GitHub is canonical when release precedence is equal but build metadata differs. + for (source, result) in [("GitHub", github), ("CDN", cdn)] { + match result { + Ok(candidate) => available.push(candidate), + Err(error) => errors.push(format!("{source}: {error}")), + } + } + if available.is_empty() { + return Err(errors.join("; ")); + } + Ok(available) +} + +pub(crate) async fn check( + channel: UpdateChannel, + arch: &str, + current: &str, +) -> Result, String> { + Ok( + updater_logic::newest_candidates(candidates(channel, arch).await?) + .into_iter() + .find(|candidate| updater_logic::version_is_newer(&candidate.version, current)), + ) +} + +async fn transfer)>( + candidate: &UpdateCandidate, + progress: &P, +) -> Result, String> { + use futures_util::StreamExt; + let url = url::Url::parse(&candidate.url).map_err(|_| "invalid APK source")?; + let response = update_source::bounded_update_get(url, Duration::from_secs(600)).await?; + if !response.status().is_success() { + return Err(format!( + "APK download status {}", + response.status().as_u16() + )); + } + let total = response.content_length(); + if total.is_some_and(|length| length > MAX_APK_BYTES) { + return Err("APK exceeds size limit".into()); + } + let mut stream = response.bytes_stream(); + let mut bytes = Vec::new(); + while let Some(chunk) = tokio::time::timeout(Duration::from_secs(30), stream.next()) + .await + .map_err(|_| "APK download stalled")? + { + let chunk = chunk.map_err(|_| "APK download interrupted")?; + if bytes.len() as u64 + chunk.len() as u64 > MAX_APK_BYTES { + return Err("APK exceeds size limit".into()); + } + bytes.extend_from_slice(&chunk); + progress(bytes.len() as u64, total); + } + if total.is_some_and(|length| length != bytes.len() as u64) { + return Err("incomplete APK download".into()); + } + Ok(bytes) +} + +pub(crate) async fn download)>( + version: String, + arch: &str, + url: String, + signature: String, + progress: P, +) -> Result, String> { + let parsed = semver::Version::parse(&version).map_err(|_| "invalid update version")?; + let channel = if parsed.pre.is_empty() { + UpdateChannel::Stable + } else { + UpdateChannel::Beta + }; + let original = validate( + UpdateCandidate { + version: version.clone(), + url, + signature, + notes: None, + pub_date: None, + size: None, + }, + channel, + arch, + )?; + let progress_ref = &progress; + download_selected( + &version, + original, + candidates(channel, arch).await, + &progress, + |url| async move { update_source::probe_android_download(&url).await }, + |candidate| async move { + tokio::time::timeout(Duration::from_secs(600), transfer(&candidate, progress_ref)) + .await + .map_err(|_| "APK download timed out".to_string())? + }, + |bytes, signature| { + updater_logic::verify_updater_signature( + bytes, + signature, + updater_logic::UPDATER_PUBKEY_B64, + ) + }, + ) + .await +} + +// Both discovery and the original candidate are validated before entering this transfer loop. +async fn download_selected( + version: &str, + original: UpdateCandidate, + discovered: Result, String>, + progress: &P, + probe: Probe, + transfer: Transfer, + verify: Verify, +) -> Result, String> +where + Probe: Fn(String) -> ProbeFuture, + ProbeFuture: std::future::Future>, + Transfer: Fn(UpdateCandidate) -> TransferFuture, + TransferFuture: std::future::Future, String>>, + Verify: Fn(&[u8], &str) -> Result<(), String>, + P: Fn(u64, Option), +{ + let mut available: Vec<_> = discovered + .unwrap_or_default() + .into_iter() + .filter(|candidate| candidate.version == version) + .collect(); + if !available + .iter() + .any(|candidate| candidate.url == original.url && candidate.signature == original.signature) + { + available.push(original); + } + // Every fallback is bound to the version the user selected; never install a downgrade. + available.retain(|candidate| candidate.version == version); + let known_size = available.iter().find_map(|candidate| candidate.size); + for candidate in &mut available { + if candidate.size.is_none() { + candidate.size = known_size; + } + } + let probes = futures_util::future::join_all(available.into_iter().map(|candidate| { + let sample = probe(candidate.url.clone()); + async move { (candidate, sample.await) } + })) + .await; + let ranked = updater_logic::rank_download_candidates(probes); + let mut errors = Vec::new(); + for candidate in ranked { + progress(0, candidate.size); + let signature = candidate.signature.clone(); + let result = transfer(candidate).await.and_then(|bytes| { + verify(&bytes, &signature)?; + Ok(bytes) + }); + match result { + Ok(bytes) => return Ok(bytes), + Err(error) => errors.push(error), + } + } + Err(format!("更新包下载或验证失败:{}", errors.join("; "))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + // Public minisign-verify 0.3.0 test vector; no production or test private key is used. + const TEST_PUBKEY: &str = "untrusted comment: minisign public key E7620F1842B4E81F\nRWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3"; + const TEST_SIGNATURE: &str = "untrusted comment: signature from minisign secret key\nRUQf6LRCGA9i559r3g7V1qNyJDApGip8MfqcadIgT9CuhV3EMhHoN1mGTkUidF/z7SrlQgXdy8ofjb7bNJJylDOocrCo8KLzZwo=\ntrusted comment: timestamp:1556193335\tfile:test\ny/rUw2y8/hOUYjZU71eHp/Wo1KZ40fGy2VJEDl34XMJM+TX48Ss/17u3IvIfbVR1FkZZSNCisQbuQY+bHwhEBg=="; + const VERSION: &str = "2.0.2-Beta.6+build.test"; + + fn candidate(host: &str, version: &str) -> UpdateCandidate { + UpdateCandidate { + version: version.into(), + url: format!("https://{host}/releases/fixture.apk"), + signature: TEST_SIGNATURE.into(), + notes: None, + pub_date: None, + size: Some(10_000_000), + } + } + + #[test] + fn a_known_release_failure_cannot_be_replaced_by_an_older_mirror() { + let old = candidate("dl.aeoform.com", "2.0.2-Beta.4"); + assert!(merge_sources( + Err("GitHub release candidate: signature unavailable".into()), + Ok(old.clone()), + ) + .is_err()); + let available = merge_sources(Err("GitHub unavailable".into()), Ok(old)).unwrap(); + assert_eq!(available.len(), 1); + assert_eq!(available[0].version, "2.0.2-Beta.4"); + } + + fn metadata_candidate(version: &str, abi: &str) -> UpdateCandidate { + let mut candidate = candidate("dl.aeoform.com", version); + // Metadata validation precedes cryptographic validation of the downloaded bytes. + candidate.signature = format!("untrusted comment: fixture\nnot-a-signature\ntrusted comment: timestamp:1\tfile:OpenLess_{version}_{abi}.apk\nnot-a-global-signature"); + candidate + } + + fn verify_fixture(bytes: &[u8], signature: &str) -> Result<(), String> { + updater_logic::verify_updater_signature(bytes, signature, TEST_PUBKEY) + } + + #[test] + fn metadata_validation_binds_channel_abi_full_signed_version_and_origin() { + assert!(validate( + metadata_candidate(VERSION, "arm64-v8a"), + UpdateChannel::Beta, + "aarch64" + ) + .is_ok()); + assert!(validate( + metadata_candidate("2.0.2", "arm64-v8a"), + UpdateChannel::Stable, + "aarch64" + ) + .is_ok()); + assert!(validate( + metadata_candidate(VERSION, "arm64-v8a"), + UpdateChannel::Stable, + "aarch64" + ) + .is_err()); + assert!(validate( + metadata_candidate("2.0.2", "arm64-v8a"), + UpdateChannel::Beta, + "aarch64" + ) + .is_err()); + assert!(validate( + metadata_candidate("2.0.2-rc.1", "arm64-v8a"), + UpdateChannel::Beta, + "aarch64" + ) + .is_err()); + assert!(validate( + metadata_candidate(VERSION, "x86_64"), + UpdateChannel::Beta, + "aarch64" + ) + .is_err()); + assert!(validate( + metadata_candidate(VERSION, "arm64-v8a"), + UpdateChannel::Beta, + "unknown" + ) + .is_err()); + let mut wrong = metadata_candidate(VERSION, "arm64-v8a"); + wrong.version = "2.0.2-Beta.6+build.different".into(); + assert!(validate(wrong, UpdateChannel::Beta, "aarch64").is_err()); + for url in [ + "http://dl.aeoform.com/releases/fixture.apk", + "https://dl.aeoform.com.attacker.test/releases/fixture.apk", + "https://github.com/another/repo/releases/download/v1/fixture.apk", + ] { + let mut wrong = metadata_candidate(VERSION, "arm64-v8a"); + wrong.url = url.into(); + assert!(validate(wrong, UpdateChannel::Beta, "aarch64").is_err()); + } + } + + #[tokio::test] + async fn signature_failure_tries_only_same_version_alternative() { + let mut fast = candidate("github.com", VERSION); + fast.signature = TEST_SIGNATURE.replace("file:test", "file:tampered"); + let slow = candidate("dl.aeoform.com", VERSION); + let older = candidate("older.invalid", "2.0.2-Beta.5"); + let probed = RefCell::new(Vec::new()); + let attempts = RefCell::new(Vec::new()); + let resets = RefCell::new(Vec::new()); + let result = download_selected( + VERSION, + fast.clone(), + Ok(vec![fast, slow.clone(), older]), + &|done, total| resets.borrow_mut().push((done, total)), + |url| { + probed.borrow_mut().push(url.clone()); + std::future::ready(Some(if url.contains("github.com") { + (0.01, 100_000_000.0) + } else { + (0.1, 100_000_000.0) + })) + }, + |candidate| { + attempts.borrow_mut().push(candidate.url); + std::future::ready(Ok(b"test".to_vec())) + }, + verify_fixture, + ) + .await + .unwrap(); + assert_eq!(result, b"test"); + assert_eq!(attempts.borrow().len(), 2); + assert!(attempts.borrow()[0].contains("github.com")); + assert_eq!(attempts.borrow()[1], slow.url); + assert_eq!(probed.borrow().len(), 2); + assert!(probed + .borrow() + .iter() + .all(|url| !url.contains("older.invalid"))); + assert_eq!( + *resets.borrow(), + vec![(0, Some(10_000_000)), (0, Some(10_000_000))] + ); + } + + #[tokio::test] + async fn newest_github_candidate_never_probes_stale_cdn() { + let selected = candidate("github.com", VERSION); + let old = candidate("dl.aeoform.com", "2.0.2-Beta.4"); + let probed = RefCell::new(Vec::new()); + let attempts = RefCell::new(Vec::new()); + let error = download_selected( + VERSION, + selected.clone(), + Ok(vec![old, selected]), + &|_, _| {}, + |url| { + probed.borrow_mut().push(url); + std::future::ready(Some((0.1, 1000.0))) + }, + |candidate| { + attempts.borrow_mut().push(candidate.url); + std::future::ready(Err("offline".into())) + }, + verify_fixture, + ) + .await + .unwrap_err(); + assert!(error.contains("offline")); + assert_eq!(probed.borrow().len(), 1); + assert_eq!(attempts.borrow().len(), 1); + assert!(probed.borrow()[0].contains("github.com")); + } + + #[tokio::test] + async fn source_discovery_errors_preserve_users_original_candidate() { + let original = candidate("dl.aeoform.com", VERSION); + let attempts = RefCell::new(Vec::new()); + let result = download_selected( + VERSION, + original.clone(), + Err("both metadata sources offline".into()), + &|_, _| {}, + |_| std::future::ready(None), + |candidate| { + attempts + .borrow_mut() + .push((candidate.url, candidate.signature)); + std::future::ready(Ok(b"test".to_vec())) + }, + verify_fixture, + ) + .await + .unwrap(); + assert_eq!(result, b"test"); + assert_eq!(*attempts.borrow(), vec![(original.url, original.signature)]); + } + + #[tokio::test] + async fn estimated_finish_time_outweighs_first_byte_latency() { + let low_latency = candidate("github.com", VERSION); + let high_throughput = candidate("dl.aeoform.com", VERSION); + let attempts = RefCell::new(Vec::new()); + let result = download_selected( + VERSION, + low_latency.clone(), + Ok(vec![low_latency, high_throughput.clone()]), + &|_, _| {}, + |url| { + std::future::ready(Some(if url.contains("github.com") { + (0.01, 1000.0) + } else { + (0.8, 20_000_000.0) + })) + }, + |candidate| { + attempts.borrow_mut().push(candidate.url); + std::future::ready(Ok(b"test".to_vec())) + }, + verify_fixture, + ) + .await + .unwrap(); + assert_eq!(result, b"test"); + assert_eq!(*attempts.borrow(), vec![high_throughput.url]); + } + + #[tokio::test] + async fn corrupted_bytes_cannot_escape_when_all_sources_fail_authentication() { + let original = candidate("github.com", VERSION); + let alternate = candidate("dl.aeoform.com", VERSION); + let error = download_selected( + VERSION, + original.clone(), + Ok(vec![original, alternate]), + &|_, _| {}, + |_| std::future::ready(None), + |_| std::future::ready(Ok(b"modified".to_vec())), + verify_fixture, + ) + .await + .unwrap_err(); + assert!(error.contains("signature verify failed")); + } + #[tokio::test] + async fn cdn_metadata_reader_bounds_announced_and_chunked_bodies() { + let untouched = futures_util::stream::poll_fn( + |_| -> std::task::Poll, &'static str>>> { + panic!("oversized declared body must not be consumed") + }, + ); + assert!( + manifest_bytes(untouched, Some(MAX_MANIFEST_BYTES as u64 + 1)) + .await + .unwrap_err() + .contains("size limit") + ); + let oversized = futures_util::stream::iter([ + Ok::<_, &str>(vec![0; MAX_MANIFEST_BYTES / 2]), + Ok(vec![0; MAX_MANIFEST_BYTES / 2 + 1]), + ]); + assert!(manifest_bytes(oversized, None) + .await + .unwrap_err() + .contains("size limit")); + let exact = futures_util::stream::iter([Ok::<_, &str>(vec![0; MAX_MANIFEST_BYTES])]); + assert_eq!( + manifest_bytes(exact, Some(MAX_MANIFEST_BYTES as u64)) + .await + .unwrap() + .len(), + MAX_MANIFEST_BYTES + ); + let interrupted = futures_util::stream::iter([ + Ok(b"{".to_vec()), + Err("secret url query must not escape"), + ]); + assert_eq!( + manifest_bytes(interrupted, None).await.unwrap_err(), + "CDN update manifest interrupted" + ); + } +} diff --git a/openless-all/app/src-tauri/src/android/updater.rs b/openless-all/app/src-tauri/src/android/updater.rs index eea55ba01..4e7c2d839 100644 --- a/openless-all/app/src-tauri/src/android/updater.rs +++ b/openless-all/app/src-tauri/src/android/updater.rs @@ -4,28 +4,12 @@ mod android_impl { use std::path::PathBuf; - use serde::Deserialize; use tauri::{AppHandle, Emitter}; - use crate::android::updater_logic::{ - format_manifest_error, map_abi_to_arch, verify_updater_signature, version_is_newer, - INSTALLER_NOT_OPENED_MSG, UPDATER_PUBKEY_B64, - }; + use crate::android::updater_logic::{map_abi_to_arch, INSTALLER_NOT_OPENED_MSG}; use crate::commands::AppUpdateMetadata; - use crate::net; use crate::types::UpdateChannel; - #[derive(Debug, Deserialize)] - struct UpdaterManifest { - version: String, - #[serde(default)] - pub_date: Option, - url: String, - signature: String, - #[serde(default)] - notes: Option, - } - #[derive(Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct AndroidUpdateProgress { @@ -60,55 +44,25 @@ mod android_impl { }) } - async fn fetch_manifest(url: &str) -> Result { - let resp = net::send_with_retry(|| { - net::http() - .get(url) - .timeout(std::time::Duration::from_secs(15)) - }) - .await - .map_err(|e| format!("fetch manifest: {e}"))?; - if !resp.status().is_success() { - return Err(format_manifest_error(resp.status().as_u16(), url)); - } - resp.json::() - .await - .map_err(|e| format!("parse manifest: {e}")) - } - pub async fn check_update(channel: UpdateChannel) -> Result, String> { let arch = device_arch()?; - // 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(); - 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, - }); - Ok(Some(AppUpdateMetadata { - rid: 0, - current_version: current, - version: manifest.version, - date: manifest.pub_date, - body: manifest.notes, - raw_json, - })) - } - Err(err) => Err(err), - } - } - - fn verify_signature(apk_bytes: &[u8], signature_b64: &str) -> Result<(), String> { - verify_updater_signature(apk_bytes, signature_b64, UPDATER_PUBKEY_B64) + let Some(candidate) = super::super::update_pipeline::check(channel, arch, ¤t).await? + else { + return Ok(None); + }; + let raw_json = serde_json::json!({ + "version": candidate.version, "url": candidate.url, "signature": candidate.signature, + "pubDate": candidate.pub_date, "notes": candidate.notes, + }); + Ok(Some(AppUpdateMetadata { + rid: 0, + current_version: current, + version: candidate.version, + date: candidate.pub_date, + body: candidate.notes, + raw_json, + })) } fn updates_cache_dir() -> Result { @@ -122,52 +76,31 @@ mod android_impl { signature: String, version: String, ) -> Result<(), String> { + semver::Version::parse(&version).map_err(|_| "invalid update version")?; + let arch = device_arch()?; + let bytes = super::super::update_pipeline::download( + version.clone(), + arch, + url, + signature, + |downloaded, content_length| { + let _ = app.emit( + "android-update:progress", + AndroidUpdateProgress { + downloaded, + content_length, + phase: "downloading".into(), + }, + ); + }, + ) + .await?; let cache_dir = updates_cache_dir()?; std::fs::create_dir_all(&cache_dir).map_err(|e| format!("create cache dir: {e}"))?; let dest = cache_dir.join(format!("OpenLess_{version}.apk")); - - let resp = net::send_with_retry(|| net::http().get(&url)) - .await - .map_err(|e| format!("download apk: {e}"))?; - if !resp.status().is_success() { - return Err(format!("download status {}", resp.status())); - } - let total = resp.content_length(); - // Security: cap the stream so an unbounded one can't exhaust memory. 200 MB far - // exceeds any real APK size (~50 MB currently). - const MAX_APK_BYTES: u64 = 200 * 1024 * 1024; - if let Some(len) = total { - if len > MAX_APK_BYTES { - return Err(format!( - "APK 声明大小 {len} 字节超过上限 {MAX_APK_BYTES},拒绝下载" - )); - } - } - let mut downloaded: u64 = 0; - let mut bytes = Vec::new(); - let mut stream = resp.bytes_stream(); - use futures_util::StreamExt; - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|e| format!("download chunk: {e}"))?; - downloaded += chunk.len() as u64; - if downloaded > MAX_APK_BYTES { - return Err(format!( - "下载字节数 {downloaded} 超过上限 {MAX_APK_BYTES},已终止" - )); - } - bytes.extend_from_slice(&chunk); - let _ = app.emit( - "android-update:progress", - AndroidUpdateProgress { - downloaded, - content_length: total, - phase: "downloading".to_string(), - }, - ); - } - - verify_signature(&bytes, &signature)?; std::fs::write(&dest, &bytes).map_err(|e| format!("write apk: {e}"))?; + let downloaded = bytes.len() as u64; + let total = Some(downloaded); let _ = app.emit( "android-update:progress", 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 b992e58f5..6ea857c05 100644 --- a/openless-all/app/src-tauri/src/android/updater_logic.rs +++ b/openless-all/app/src-tauri/src/android/updater_logic.rs @@ -25,7 +25,7 @@ pub fn version_is_newer(remote: &str, current: &str) -> bool { ) else { return false; }; - remote > current + remote.cmp_precedence(¤t).is_gt() } /// Human-readable manifest fetch failure for UI tooltips. @@ -83,6 +83,93 @@ pub fn verify_updater_signature( Ok(()) } +/// Every fallback retains the matching URL, version and detached signature as one unit. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct UpdateCandidate { + pub version: String, + pub url: String, + pub signature: String, + #[serde(default)] + pub notes: Option, + #[serde(default)] + pub pub_date: Option, + #[serde(default)] + pub size: Option, +} + +fn freshest_full_version<'a>( + candidates: impl Iterator, +) -> Option { + let mut selected: Option<(&UpdateCandidate, semver::Version)> = None; + let github = |candidate: &UpdateCandidate| { + url::Url::parse(&candidate.url) + .ok() + .is_some_and(|url| url.host_str() == Some("github.com")) + }; + for candidate in candidates { + let Ok(version) = semver::Version::parse(&candidate.version) else { + continue; + }; + let replace = selected.as_ref().is_none_or(|(current, current_version)| { + version.cmp_precedence(current_version).is_gt() + || version.cmp_precedence(current_version).is_eq() + && github(candidate) + && !github(current) + }); + if replace { + selected = Some((candidate, version)); + } + } + selected.map(|(candidate, _)| candidate.version.clone()) +} + +/// Compare release precedence first. Equal-precedence conflicting build metadata uses +/// GitHub's canonical version; candidates with another exact full version never mix. +pub fn newest_candidates(candidates: Vec) -> Vec { + let Some(newest) = freshest_full_version(candidates.iter()) else { + return Vec::new(); + }; + candidates + .into_iter() + .filter(|v| v.version == newest) + .collect() +} + +/// Estimated completion = first-byte latency + artifact bytes / sampled throughput. +/// Unknown sizes use the same conservative 100 MiB reference on every source. +/// Invalid/failed samples sort last but remain usable as same-version fallbacks. +pub fn rank_download_candidates( + candidates: Vec<(UpdateCandidate, Option<(f64, f64)>)>, +) -> Vec { + let Some(newest) = freshest_full_version(candidates.iter().map(|(v, _)| v)) else { + return Vec::new(); + }; + let reference_size = candidates + .iter() + .filter(|(v, _)| v.version == newest) + .filter_map(|(v, _)| v.size) + .max() + .unwrap_or(100 * 1024 * 1024) + .max(1); + let mut candidates: Vec<_> = candidates + .into_iter() + .filter(|(v, _)| v.version == newest) + .collect(); + let estimate = |sample: Option<(f64, f64)>| match sample { + Some((latency, throughput)) + if latency.is_finite() + && latency >= 0. + && throughput.is_finite() + && throughput > 0. => + { + latency + reference_size as f64 / throughput + } + _ => f64::INFINITY, + }; + candidates.sort_by(|a, b| estimate(a.1).total_cmp(&estimate(b.1))); + candidates.into_iter().map(|(v, _)| v).collect() +} + #[cfg(test)] mod tests { use super::*; @@ -103,6 +190,22 @@ mod tests { assert!(!version_is_newer("not-a-version", "1.3.18-Beta.7")); } + #[test] + fn build_metadata_alone_does_not_offer_an_upgrade() { + assert!(!version_is_newer( + "2.0.2-Beta.6+build.20261110-new", + "2.0.2-Beta.6+build.20261109-old", + )); + assert!(!version_is_newer( + "2.0.2-Beta.6+build.20261109-old", + "2.0.2-Beta.6+build.20261110-new", + )); + assert!(version_is_newer( + "2.0.2-Beta.7+build.20261109-new", + "2.0.2-Beta.6+build.20261110-old", + )); + } + #[test] fn map_abi_to_arch_maps_arm64() { assert_eq!(map_abi_to_arch("arm64-v8a"), "aarch64"); @@ -172,3 +275,77 @@ mod tests { assert!(decode_tauri_minisign_text(" ").is_err()); } } + +#[cfg(test)] +mod source_selection_tests { + use super::*; + fn candidate(version: &str, source: &str) -> UpdateCandidate { + UpdateCandidate { + version: version.into(), + url: format!("https://{source}"), + signature: format!("signature-{source}"), + notes: None, + pub_date: None, + size: Some(50_000_000), + } + } + #[test] + fn a_stale_fast_cdn_cannot_beat_the_new_github_release() { + let result = rank_download_candidates(vec![ + (candidate("2.0.2-Beta.5", "cdn"), Some((0.01, 1e10))), + (candidate("2.0.2-Beta.6", "github"), Some((0.5, 1e5))), + ]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].url, "https://github"); + assert_eq!(result[0].signature, "signature-github"); + } + #[test] + fn either_source_can_stand_alone_and_full_versions_stay_separate() { + for source in ["cdn", "github"] { + let result = newest_candidates(vec![candidate("2.0.2-Beta.6", source)]); + assert_eq!(result[0].url, format!("https://{source}")); + } + assert_eq!( + newest_candidates(vec![ + candidate("2.0.2-Beta.6+build.20261109-a", "old"), + candidate("2.0.2-Beta.6+build.20261110-a", "new") + ]) + .len(), + 1 + ); + assert!(newest_candidates(vec![candidate("not-semver", "bad")]).is_empty()); + } + #[test] + fn equal_precedence_prefers_github_metadata_without_mixing_builds() { + let mut github = candidate("2.0.2-Beta.6+build.20261109-canonical", "github"); + github.url = + "https://github.com/Open-Less/openless/releases/download/v2.0.2-Beta.6-tauri/app.apk" + .into(); + let cdn = candidate("2.0.2-Beta.6+build.99999999-stale", "cdn"); + let result = newest_candidates(vec![cdn.clone(), github.clone()]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].version, github.version); + let result = + rank_download_candidates(vec![(github.clone(), None), (cdn, Some((0.001, 1e10)))]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].version, github.version); + let mirror = candidate(&github.version, "cdn"); + assert_eq!(newest_candidates(vec![mirror, github]).len(), 2); + } + + #[test] + fn conflicting_latency_and_throughput_use_completion_estimate() { + let version = "2.0.2-Beta.6"; + let result = rank_download_candidates(vec![ + (candidate(version, "low-latency"), Some((0.01, 1e6))), + (candidate(version, "fast-transfer"), Some((0.5, 10e6))), + ]); + assert_eq!(result[0].url, "https://fast-transfer"); + let result = rank_download_candidates(vec![ + (candidate(version, "failed"), None), + (candidate(version, "works"), Some((0.5, 1e6))), + ]); + assert_eq!(result[0].url, "https://works"); + assert_eq!(result.len(), 2); + } +} diff --git a/openless-all/app/src-tauri/src/update_source.rs b/openless-all/app/src-tauri/src/update_source.rs index cabbd0909..ac60abdfc 100644 --- a/openless-all/app/src-tauri/src/update_source.rs +++ b/openless-all/app/src-tauri/src/update_source.rs @@ -375,9 +375,15 @@ async fn github_web_manifest(channel: UpdateChannel) -> Result Option<&str> { - feed.split("") + github_feed_tags(feed, channel).into_iter().next() +} + +#[cfg(any(not(mobile), target_os = "android", test))] +fn github_feed_tags(feed: &str, channel: UpdateChannel) -> Vec<&str> { + let mut tags: Vec<_> = feed + .split("") .skip(1) .filter_map(|entry| { let entry = entry.split("").next()?; @@ -402,8 +408,10 @@ fn github_feed_tag(feed: &str, channel: UpdateChannel) -> Option<&str> { }; matches.then_some((version, tag)) }) - .max_by(|a, b| a.0.cmp(&b.0)) - .map(|(_, tag)| tag) + .collect(); + tags.sort_by(|a, b| b.0.cmp_precedence(&a.0)); + tags.dedup_by(|a, b| a.1 == b.1); + tags.into_iter().map(|(_, tag)| tag).collect() } #[cfg(not(mobile))] @@ -481,3 +489,9 @@ mod public_release_tests { .is_none()); } } + +#[cfg(any(target_os = "android", test))] +mod android; +#[cfg(any(target_os = "android", test))] +#[allow(unused_imports)] +pub(crate) use android::*; diff --git a/openless-all/app/src-tauri/src/update_source/android.rs b/openless-all/app/src-tauri/src/update_source/android.rs new file mode 100644 index 000000000..5c4ed2d7e --- /dev/null +++ b/openless-all/app/src-tauri/src/update_source/android.rs @@ -0,0 +1,817 @@ +//! Android GitHub discovery uses official versioned APKs and their detached signatures. +use crate::types::UpdateChannel; +use futures_util::StreamExt; +use serde_json::{json, Value}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; +use url::Url; + +const DISCOVERY_BUDGET: Duration = Duration::from_secs(24); +const METADATA_REQUEST_BUDGET: Duration = Duration::from_secs(8); +const PROBE_BUDGET: Duration = Duration::from_secs(4); +const METADATA_LIMIT: usize = 2 * 1024 * 1024; +const API_METADATA_LIMIT: usize = 8 * 1024 * 1024; +const SIGNATURE_LIMIT: usize = 16 * 1024; + +fn github_url(url: &Url) -> bool { + url.host_str() == Some("github.com") + && url + .path() + .starts_with("/Open-Less/openless/releases/download/") +} + +pub(crate) fn allowed_update_download_url(url: &Url) -> bool { + safe_https(url) + && (github_url(url) + || url.host_str() == Some("dl.aeoform.com") && url.path().starts_with("/releases/")) +} + +fn safe_https(url: &Url) -> bool { + url.scheme() == "https" + && url.username().is_empty() + && url.password().is_none() + && url.port_or_known_default() == Some(443) +} + +fn allowed_request(url: &Url, redirected: bool) -> bool { + if !safe_https(url) { + return false; + } + match url.host_str() { + Some("github.com") => url.path().starts_with("/Open-Less/openless/releases"), + Some("api.github.com") => url.path().starts_with("/repos/Open-Less/openless/releases"), + Some("dl.aeoform.com") => { + url.path().starts_with("/releases/") || url.path().starts_with("/updates/") + } + Some("release-assets.githubusercontent.com" | "objects.githubusercontent.com") => { + redirected + } + _ => false, + } +} + +/// Preserve the shared proxy/TLS policy while checking every redirect before sending it. +async fn update_get( + mut url: Url, + budget: Duration, + range: bool, +) -> Result { + tokio::time::timeout(budget, async { + for hop in 0..=5 { + if !allowed_request(&url, hop > 0) { + return Err("update URL is not an allowed HTTPS origin".into()); + } + let mut request = crate::net::anonymous_no_redirect_http() + .get(url.clone()) + .header(reqwest::header::USER_AGENT, "OpenLess-Updater") + .timeout(budget); + if range { + request = request.header(reqwest::header::RANGE, "bytes=0-65535"); + } + let response = request.send().await.map_err(|e| e.to_string())?; + if response.status().is_redirection() { + let next = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .ok_or("redirect missing location")?; + url = url.join(next).map_err(|e| e.to_string())?; + } else { + return if response.status().is_success() { + Ok(response) + } else { + Err(format!("update HTTP status {}", response.status().as_u16())) + }; + } + } + Err("too many update redirects".into()) + }) + .await + .map_err(|_| "update request timed out".to_string())? +} + +pub(crate) async fn bounded_update_get( + url: Url, + budget: Duration, +) -> Result { + update_get(url, budget, false).await +} + +async fn bounded_text(response: reqwest::Response, limit: usize) -> Result { + if response + .content_length() + .is_some_and(|length| length > limit as u64) + { + return Err("update metadata exceeds size limit".into()); + } + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| e.to_string())?; + if body.len().saturating_add(chunk.len()) > limit { + return Err("update metadata exceeds size limit".into()); + } + body.extend_from_slice(&chunk); + } + String::from_utf8(body).map_err(|e| e.to_string()) +} + +async fn metadata_response_text( + response: reqwest::Response, + limit: usize, + budget: Duration, +) -> Result { + tokio::time::timeout(budget, bounded_text(response, limit)) + .await + .map_err(|_| "update metadata body timed out".to_string())? +} + +async fn text(url: &str, limit: usize) -> Result { + let url = Url::parse(url).map_err(|e| e.to_string())?; + tokio::time::timeout(METADATA_REQUEST_BUDGET, async { + metadata_response_text( + bounded_update_get(url, METADATA_REQUEST_BUDGET).await?, + limit, + METADATA_REQUEST_BUDGET, + ) + .await + }) + .await + .map_err(|_| "update metadata timed out".to_string())? +} + +pub(crate) async fn probe_android_download(url: &str) -> Option<(f64, f64)> { + let url = Url::parse(url).ok()?; + if !allowed_update_download_url(&url) { + return None; + } + tokio::time::timeout(PROBE_BUDGET, async { + let started = std::time::Instant::now(); + let response = update_get(url, PROBE_BUDGET, true).await.ok()?; + let transfer = std::time::Instant::now(); + let mut stream = response.bytes_stream(); + let first = stream.next().await?.ok()?; + let latency = started.elapsed().as_secs_f64(); + let mut bytes = first.len().min(65536); + while bytes < 65536 { + match stream.next().await { + Some(Ok(chunk)) => bytes = bytes.saturating_add(chunk.len()).min(65536), + Some(Err(_)) => return None, + None => break, + } + } + (bytes > 0).then(|| { + ( + latency, + bytes as f64 / transfer.elapsed().as_secs_f64().max(0.001), + ) + }) + }) + .await + .ok() + .flatten() +} + +fn abi(arch: &str) -> Option<&'static str> { + match arch { + "aarch64" => Some("arm64-v8a"), + "armv7" => Some("armeabi-v7a"), + "i686" => Some("x86"), + "x86_64" => Some("x86_64"), + _ => None, + } +} + +fn artifact_version(name: &str, arch: &str) -> Option { + let suffix = format!("_{}.apk", abi(arch)?); + let value = name.strip_prefix("OpenLess_")?.strip_suffix(&suffix)?; + semver::Version::parse(value).ok() +} + +fn tag_version(tag: &str, channel: UpdateChannel) -> Option { + let version = semver::Version::parse(tag.strip_prefix('v')?.strip_suffix("-tauri")?).ok()?; + let matches = match channel { + UpdateChannel::Stable => version.pre.is_empty(), + UpdateChannel::Beta => version + .pre + .as_str() + .to_ascii_lowercase() + .starts_with("beta."), + }; + matches.then_some(version) +} + +/// Filename metadata is authenticated only when the APK's minisign verification succeeds. +pub(crate) fn android_signature_version( + signature: &str, + artifact_name: &str, + tag: &str, + arch: &str, +) -> Result { + let decoded = crate::android::updater_logic::decode_tauri_minisign_text(signature)?; + let trusted = decoded + .lines() + .find_map(|line| line.strip_prefix("trusted comment: ")) + .ok_or("missing APK trusted comment")?; + let signed_file = trusted + .split('\t') + .find_map(|field| field.strip_prefix("file:")) + .ok_or("missing signed APK filename")?; + if signed_file != artifact_name { + return Err("signed APK filename differs from release asset".into()); + } + let signed = artifact_version(signed_file, arch).ok_or("invalid signed APK version or ABI")?; + let tag = semver::Version::parse(tag).map_err(|e| e.to_string())?; + if signed.major != tag.major + || signed.minor != tag.minor + || signed.patch != tag.patch + || signed.pre != tag.pre + { + return Err("signed APK version differs from release tag".into()); + } + Ok(signed.to_string()) +} + +fn ordered_releases(value: &Value, channel: UpdateChannel) -> Vec<&Value> { + let values: Vec<&Value> = match value.as_array() { + Some(values) => values.iter().collect(), + None => vec![value], + }; + let mut releases: Vec<_> = values + .into_iter() + .filter_map(|release| { + if release["draft"] != false + || release["prerelease"] != matches!(channel, UpdateChannel::Beta) + { + return None; + } + let version = tag_version(release["tag_name"].as_str()?, channel)?; + Some((version, release)) + }) + .collect(); + releases.sort_by(|a, b| b.0.cmp_precedence(&a.0)); + releases.into_iter().map(|(_, release)| release).collect() +} + +#[cfg(test)] +fn latest_release(value: &Value, channel: UpdateChannel) -> Option<&Value> { + ordered_releases(value, channel).into_iter().next() +} + +fn latest_android_release<'a>( + value: &'a Value, + channel: UpdateChannel, + arch: &str, +) -> Option<&'a Value> { + ordered_releases(value, channel) + .into_iter() + .find(|release| api_artifact(release, arch).is_some()) +} + +fn decode_asset_name(encoded: &str) -> Option { + let mut bytes = Vec::with_capacity(encoded.len()); + let mut chars = encoded.as_bytes().iter().copied(); + while let Some(ch) = chars.next() { + bytes.push(if ch == b'%' { + let high = (chars.next()? as char).to_digit(16)?; + let low = (chars.next()? as char).to_digit(16)?; + (high * 16 + low) as u8 + } else { + ch + }); + } + let result = String::from_utf8(bytes).ok()?; + (!result.contains(['/', '\\', '\0'])).then_some(result) +} + +fn api_artifact<'a>( + release: &'a Value, + arch: &str, +) -> Option<(&'a str, &'a str, &'a str, Option)> { + let tag = release["tag_name"].as_str()?; + let assets = release["assets"].as_array()?; + let artifact = assets + .iter() + .filter_map(|a| Some((artifact_version(a["name"].as_str()?, arch)?, a))) + .max_by(|a, b| a.0.cmp(&b.0))? + .1; + let name = artifact["name"].as_str()?; + let signature = assets + .iter() + .find(|a| a["name"].as_str() == Some(&format!("{name}.sig")))?; + let artifact_url = artifact["browser_download_url"].as_str()?; + let signature_url = signature["browser_download_url"].as_str()?; + let expected = format!("/Open-Less/openless/releases/download/{tag}/"); + for (value, filename) in [ + (artifact_url, name), + (signature_url, signature["name"].as_str()?), + ] { + let url = Url::parse(value).ok()?; + if !safe_https(&url) || !github_url(&url) || !url.path().starts_with(&expected) { + return None; + } + let decoded = decode_asset_name(url.path().strip_prefix(&expected)?)?; + if decoded != filename { + return None; + } + } + Some((name, artifact_url, signature_url, artifact["size"].as_u64())) +} + +enum ApiDiscoveryError { + Unavailable(String), + Candidate(String), +} +impl From for ApiDiscoveryError { + fn from(value: String) -> Self { + Self::Unavailable(value) + } +} +impl From<&str> for ApiDiscoveryError { + fn from(value: &str) -> Self { + Self::Unavailable(value.to_string()) + } +} + +async fn api_manifest( + channel: UpdateChannel, + arch: &str, + candidate_seen: &AtomicBool, +) -> Result { + let endpoint = match channel { + UpdateChannel::Stable => "https://api.github.com/repos/Open-Less/openless/releases/latest", + UpdateChannel::Beta => { + "https://api.github.com/repos/Open-Less/openless/releases?per_page=30" + } + }; + let mut value: Value = serde_json::from_str(&text(endpoint, API_METADATA_LIMIT).await?) + .map_err(|e| e.to_string())?; + if matches!(channel, UpdateChannel::Stable) + && latest_android_release(&value, channel, arch).is_none() + { + value = serde_json::from_str( + &text( + "https://api.github.com/repos/Open-Less/openless/releases?per_page=30", + API_METADATA_LIMIT, + ) + .await?, + ) + .map_err(|e| e.to_string())?; + } + let release = latest_android_release(&value, channel, arch) + .ok_or("no published signed APK for device ABI")?; + let tag = release["tag_name"].as_str().ok_or("missing release tag")?; + let version = tag_version(tag, channel).ok_or("invalid release tag")?; + let (name, url, sig_url, size) = + api_artifact(release, arch).ok_or("no versioned APK and signature for device ABI")?; + candidate_seen.store(true, Ordering::Release); + let signature = text(sig_url, SIGNATURE_LIMIT) + .await + .map_err(ApiDiscoveryError::Candidate)?; + let version = android_signature_version(signature.trim(), name, &version.to_string(), arch) + .map_err(ApiDiscoveryError::Candidate)?; + Ok( + json!({"version":version,"url":url,"signature":signature.trim(),"pub_date":release["published_at"],"notes":release["body"],"size":size}), + ) +} + +fn web_artifact(page: &str, tag: &str, arch: &str) -> Option<(String, String, String)> { + let prefix = format!("/Open-Less/openless/releases/download/{tag}/"); + let paths: Vec<_> = page + .split("href=\"") + .skip(1) + .filter_map(|p| { + let path = p.split('"').next()?; + let name = decode_asset_name(path.strip_prefix(&prefix)?)?; + (!name.contains('/')).then_some((path, name)) + }) + .collect(); + let (path, name) = paths + .iter() + .filter_map(|(p, n)| Some((artifact_version(n, arch)?, p, n))) + .max_by(|a, b| a.0.cmp(&b.0)) + .map(|(_, p, n)| (p, n))?; + let signature = paths.iter().find(|(_, n)| *n == format!("{name}.sig"))?.0; + Some(( + name.clone(), + format!("https://github.com{path}"), + format!("https://github.com{signature}"), + )) +} + +async fn web_manifest_from_feed( + feed: &str, + channel: UpdateChannel, + arch: &str, + candidate_seen: &AtomicBool, + mut fetch: F, +) -> Result +where + F: FnMut(String, usize) -> Fut, + Fut: std::future::Future>, +{ + for tag in super::github_feed_tags(feed, channel).into_iter().take(3) { + let version = tag_version(tag, channel).ok_or("invalid public release tag")?; + let page = match fetch( + format!("https://github.com/Open-Less/openless/releases/expanded_assets/{tag}"), + METADATA_LIMIT, + ) + .await + { + Ok(page) => page, + Err(error) if error == "update HTTP status 404" => continue, + Err(error) => return Err(format!("public release assets: {error}")), + }; + let Some((name, url, sig_url)) = web_artifact(&page, tag, arch) else { + continue; + }; + // Once a matching pair exists, a transport or metadata failure cannot downgrade it. + candidate_seen.store(true, Ordering::Release); + let signature = fetch(sig_url, SIGNATURE_LIMIT) + .await + .map_err(|e| format!("GitHub release candidate: public signature: {e}"))?; + let version = + android_signature_version(signature.trim(), &name, &version.to_string(), arch) + .map_err(|e| format!("GitHub release candidate: {e}"))?; + return Ok( + json!({"version":version,"url":url,"signature":signature.trim(),"notes":format!("https://github.com/Open-Less/openless/releases/tag/{tag}")}), + ); + } + Err("public releases have no matching signed APK in the newest channel tags".into()) +} + +async fn web_manifest( + channel: UpdateChannel, + arch: &str, + candidate_seen: &AtomicBool, +) -> Result { + let feed = text( + "https://github.com/Open-Less/openless/releases.atom", + METADATA_LIMIT, + ) + .await + .map_err(|e| format!("public feed: {e}"))?; + web_manifest_from_feed( + &feed, + channel, + arch, + candidate_seen, + |url, limit| async move { text(&url, limit).await }, + ) + .await +} + +fn combine_discovery_errors(api: String, web: String) -> String { + if web.starts_with("GitHub release candidate:") { + web + } else { + format!("GitHub API: {api}; public releases: {web}") + } +} + +async fn discovery_with_deadline( + budget: Duration, + candidate_seen: &AtomicBool, + discovery: F, +) -> Result +where + F: std::future::Future>, +{ + tokio::time::timeout(budget, discovery).await.map_err(|_| { + if candidate_seen.load(Ordering::Acquire) { + "GitHub release candidate: discovery timed out".to_string() + } else { + "GitHub Android discovery timed out".to_string() + } + })? +} + +pub(crate) async fn github_android_manifest( + channel: UpdateChannel, + arch: &str, +) -> Result { + if abi(arch).is_none() { + return Err("unsupported Android updater architecture".into()); + } + let candidate_seen = AtomicBool::new(false); + discovery_with_deadline(DISCOVERY_BUDGET, &candidate_seen, async { + match api_manifest(channel, arch, &candidate_seen).await { + Ok(value) => Ok(value), + Err(ApiDiscoveryError::Candidate(error)) => { + Err(format!("GitHub release candidate: {error}")) + } + Err(ApiDiscoveryError::Unavailable(api)) => { + web_manifest(channel, arch, &candidate_seen) + .await + .map_err(|web| combine_discovery_errors(api, web)) + } + } + }) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + fn signature(name: &str) -> String { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(format!("untrusted comment: fixture\nnot-a-valid-signature\ntrusted comment: timestamp:1\tfile:{name}\nnot-a-valid-global-signature")) + } + #[test] + fn all_abis_use_exact_versioned_artifacts_and_matching_signature_names() { + let tag = "v2.0.2-Beta.6-tauri"; + for (arch, abi) in [ + ("aarch64", "arm64-v8a"), + ("armv7", "armeabi-v7a"), + ("i686", "x86"), + ("x86_64", "x86_64"), + ] { + let name = format!("OpenLess_2.0.2-Beta.6+build.20261109-test_{abi}.apk"); + let release = json!({"tag_name":tag,"assets":[ + {"name":name,"browser_download_url":format!("https://github.com/Open-Less/openless/releases/download/{tag}/{name}"),"size":20}, + {"name":format!("{name}.sig"),"browser_download_url":format!("https://github.com/Open-Less/openless/releases/download/{tag}/{name}.sig")} + ]}); + let (actual, _, _, size) = api_artifact(&release, arch).unwrap(); + assert_eq!(actual, name); + assert_eq!(size, Some(20)); + assert_eq!( + android_signature_version(&signature(&name), &name, "2.0.2-Beta.6", arch).unwrap(), + "2.0.2-Beta.6+build.20261109-test" + ); + assert!( + android_signature_version(&signature(&name), &name, "2.0.2-Beta.5", arch).is_err() + ); + assert!(android_signature_version( + &signature("wrong.apk"), + &name, + "2.0.2-Beta.6", + arch + ) + .is_err()); + let page=format!(""); + assert_eq!(web_artifact(&page, tag, arch).unwrap().0, name); + assert!(web_artifact(&page.replace(".apk.sig", ".apk.unsigned"), tag, arch).is_none()); + } + assert!(artifact_version("OpenLess_2.0.2-Beta.6_x86_64.apk", "i686").is_none()); + assert!(artifact_version("OpenLess_android_aarch64.apk", "aarch64").is_none()); + } + #[test] + fn discovery_excludes_drafts_other_channels_and_uses_semver() { + let releases = json!([ + {"tag_name":"v2.0.2-Beta.9-tauri","draft":false,"prerelease":true}, + {"tag_name":"v2.0.2-Beta.10-tauri","draft":false,"prerelease":true}, + {"tag_name":"v3.0.0-Beta.1-tauri","draft":true,"prerelease":true}, + {"tag_name":"v9.0.0-RC.1-tauri","draft":false,"prerelease":true}, + {"tag_name":"v4.0.0-tauri","draft":false,"prerelease":false} + ]); + assert_eq!( + latest_release(&releases, UpdateChannel::Beta).unwrap()["tag_name"], + "v2.0.2-Beta.10-tauri" + ); + assert_eq!( + latest_release(&releases, UpdateChannel::Stable).unwrap()["tag_name"], + "v4.0.0-tauri" + ); + assert!(latest_release( + &json!({"tag_name":"v3.0.0-tauri","draft":true,"prerelease":false}), + UpdateChannel::Stable + ) + .is_none()); + } + #[test] + fn request_origins_are_fixed_and_redirects_cannot_escape_https() { + for value in [ + "http://github.com/Open-Less/openless/releases/download/tag/file.apk", + "https://attacker.invalid/releases/file.apk", + "https://user:password@github.com/Open-Less/openless/releases/download/tag/file.apk", + "https://github.com:444/Open-Less/openless/releases/download/tag/file.apk", + "https://github.com/other/repo/releases/download/tag/file.apk", + ] { + let url = Url::parse(value).unwrap(); + assert!(!allowed_update_download_url(&url)); + assert!(!allowed_request(&url, true)); + } + assert!(allowed_update_download_url( + &Url::parse("https://dl.aeoform.com/releases/tag/app.apk").unwrap() + )); + let redirect = + Url::parse("https://release-assets.githubusercontent.com/path?sig=fixture").unwrap(); + assert!(!allowed_request(&redirect, false)); + assert!(allowed_request(&redirect, true)); + assert!(decode_asset_name("OpenLess_2.0.2%2Bbuild_x86.apk") + .unwrap() + .contains('+')); + assert!(decode_asset_name("%2fetc%2ffile.apk").is_none()); + } + #[tokio::test] + async fn unsupported_architecture_and_untrusted_probe_fail_without_network() { + assert!(github_android_manifest(UpdateChannel::Beta, "mips") + .await + .is_err()); + assert!(probe_android_download("http://127.0.0.1/private") + .await + .is_none()); + assert!(bounded_update_get( + Url::parse("http://127.0.0.1/private").unwrap(), + Duration::from_millis(20) + ) + .await + .is_err()); + } + #[tokio::test] + async fn metadata_body_size_limit_rejects_an_oversized_local_response() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0; 2048]; + socket.read(&mut request).await.unwrap(); + socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 20\r\nConnection: close\r\n\r\n12345678901234567890").await.unwrap(); + }); + let response = reqwest::Client::builder() + .no_proxy() + .build() + .unwrap() + .get(format!("http://{address}")) + .send() + .await + .unwrap(); + assert!(bounded_text(response, 10).await.is_err()); + server.await.unwrap(); + } + #[tokio::test] + async fn metadata_body_timeout_cancels_a_stalled_local_response() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0; 2048]; + socket.read(&mut request).await.unwrap(); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 20\r\n\r\n") + .await + .unwrap(); + std::future::pending::<()>().await; + }); + let response = reqwest::Client::builder() + .no_proxy() + .build() + .unwrap() + .get(format!("http://{address}")) + .send() + .await + .unwrap(); + let result = metadata_response_text(response, 100, Duration::from_millis(25)).await; + assert!(result.unwrap_err().contains("timed out")); + server.abort(); + } + fn release_fixture(tag: &str, abi: &str, draft: bool) -> Value { + let version = tag + .strip_prefix('v') + .unwrap() + .strip_suffix("-tauri") + .unwrap(); + let name = format!("OpenLess_{version}+build.test_{abi}.apk"); + json!({"tag_name":tag,"draft":draft,"prerelease":true,"assets":[ + {"name":name,"browser_download_url":format!("https://github.com/Open-Less/openless/releases/download/{tag}/{name}")}, + {"name":format!("{name}.sig"),"browser_download_url":format!("https://github.com/Open-Less/openless/releases/download/{tag}/{name}.sig")} + ]}) + } + fn page_fixture(tag: &str, abi: &str) -> (String, String) { + let version = tag + .strip_prefix('v') + .unwrap() + .strip_suffix("-tauri") + .unwrap(); + let name = format!("OpenLess_{version}+build.test_{abi}.apk"); + (format!(""),name) + } + fn two_beta_feed() -> &'static str { + r#""# + } + #[test] + fn api_chooses_latest_published_release_with_a_matching_abi_pair() { + let release = json!([ + release_fixture("v2.0.2-Beta.7-tauri","arm64-v8a",true), + release_fixture("v2.0.2-Beta.6-tauri","x86",false), + {"tag_name":"v2.0.2-Beta.5-tauri","draft":false,"prerelease":true,"assets":[]}, + release_fixture("v2.0.2-Beta.4-tauri","arm64-v8a",false) + ]); + assert_eq!( + latest_android_release(&release, UpdateChannel::Beta, "aarch64").unwrap()["tag_name"], + "v2.0.2-Beta.4-tauri" + ); + assert_eq!( + latest_android_release(&release, UpdateChannel::Beta, "i686").unwrap()["tag_name"], + "v2.0.2-Beta.6-tauri" + ); + } + #[tokio::test] + async fn public_feed_skips_only_missing_or_assetless_newer_tags() { + for missing in [false, true] { + let calls = std::sync::Mutex::new(Vec::new()); + let (page, name) = page_fixture("v2.0.2-Beta.4-tauri", "arm64-v8a"); + let result = web_manifest_from_feed( + two_beta_feed(), + UpdateChannel::Beta, + "aarch64", + &AtomicBool::new(false), + |url, _| { + calls.lock().unwrap().push(url.clone()); + let result = if url.contains("expanded_assets/v2.0.2-Beta.5") { + if missing { + Err("update HTTP status 404".into()) + } else { + Ok("Source code archives only".into()) + } + } else if url.contains("expanded_assets/v2.0.2-Beta.4") { + Ok(page.clone()) + } else { + Ok(signature(&name)) + }; + async move { result } + }, + ) + .await + .unwrap(); + assert_eq!(result["version"], "2.0.2-Beta.4+build.test"); + assert!(calls.lock().unwrap()[0].contains("Beta.5")); + } + } + #[tokio::test] + async fn newer_matching_pair_signature_or_transport_failure_never_uses_older_release() { + for tampered in [false, true] { + let calls = std::sync::Mutex::new(Vec::new()); + let (page, _) = page_fixture("v2.0.2-Beta.5-tauri", "arm64-v8a"); + let result = web_manifest_from_feed( + two_beta_feed(), + UpdateChannel::Beta, + "aarch64", + &AtomicBool::new(false), + |url, _| { + calls.lock().unwrap().push(url.clone()); + let result = if url.contains("expanded_assets/v2.0.2-Beta.5") { + Ok(page.clone()) + } else if tampered { + Ok(signature("OpenLess_2.0.2-Beta.4+build.test_arm64-v8a.apk")) + } else { + Err("update request timed out".into()) + }; + async move { result } + }, + ) + .await; + assert!(result.unwrap_err().starts_with("GitHub release candidate:")); + assert!(calls + .lock() + .unwrap() + .iter() + .all(|url| !url.contains("Beta.4"))); + } + } + #[test] + fn outer_fallback_preserves_known_candidate_error_category() { + let candidate = "GitHub release candidate: signed APK filename differs".to_string(); + assert_eq!( + combine_discovery_errors("API quota exceeded".into(), candidate.clone()), + candidate + ); + assert!(combine_discovery_errors( + "API quota exceeded".into(), + "public feed unavailable".into() + ) + .starts_with("GitHub API:")); + } + + #[tokio::test] + async fn discovery_deadline_distinguishes_known_pair_from_unknown_network() { + for after_pair in [false, true] { + let candidate_seen = AtomicBool::new(false); + let (page, _) = page_fixture("v2.0.2-Beta.5-tauri", "arm64-v8a"); + let discovery = web_manifest_from_feed( + two_beta_feed(), + UpdateChannel::Beta, + "aarch64", + &candidate_seen, + |url, _| { + let page = page.clone(); + async move { + if after_pair && url.contains("expanded_assets") { + Ok(page) + } else { + std::future::pending::>().await + } + } + }, + ); + let error = + discovery_with_deadline(Duration::from_millis(20), &candidate_seen, discovery) + .await + .unwrap_err(); + assert_eq!(candidate_seen.load(Ordering::Acquire), after_pair); + assert_eq!(error.starts_with("GitHub release candidate:"), after_pair); + } + } +} diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 4a1c1af8a..66a88fd74 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenLess", - "version": "2.0.2-Beta.5+build.20261109-47405c88", + "version": "2.0.2-Beta.6+build.20261109-1f057451", "identifier": "com.openless.app", "build": { "beforeDevCommand": "npm run dev", @@ -95,22 +95,15 @@ "installerHooks": "nsis/openless-ime-hooks.nsh" }, "wix": { - "fragmentPaths": [ - "wix/openless-ime.wxs" - ], - "componentRefs": [ - "OpenLessImeDllX64Component", - "OpenLessImeDllX86Component" - ] + "fragmentPaths": ["wix/openless-ime.wxs"], + "componentRefs": ["OpenLessImeDllX64Component", "OpenLessImeDllX86Component"] } } }, "plugins": { "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFERUFBODAzNTY0QzMyM0YKUldRL01reFdBNmpxSGE1K0JadlpONXNWTzhJcGZCRGxjUVdIWExNNFJpeUNsSGZwazdlQThhemkK", - "endpoints": [ - "https://dl.aeoform.com/updates/stable/latest-{{target}}-{{arch}}.json" - ] + "endpoints": ["https://dl.aeoform.com/updates/stable/latest-{{target}}-{{arch}}.json"] } } } From c7e58010e328c1e979cd45b14cf0995853d3617a Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Mon, 9 Nov 2026 18:59:53 +0800 Subject: [PATCH 2/3] fix(android): preserve compatible update fallback sources --- .../src-tauri/src/android/update_pipeline.rs | 43 ++++++- .../src-tauri/src/update_source/android.rs | 116 ++++++++++++++---- 2 files changed, 132 insertions(+), 27 deletions(-) diff --git a/openless-all/app/src-tauri/src/android/update_pipeline.rs b/openless-all/app/src-tauri/src/android/update_pipeline.rs index 1bf12bbfb..e04de8b6d 100644 --- a/openless-all/app/src-tauri/src/android/update_pipeline.rs +++ b/openless-all/app/src-tauri/src/android/update_pipeline.rs @@ -125,7 +125,23 @@ fn merge_sources( // that an older mirror is the newest release. Preserve the failure. if let Err(error) = &github { if error.starts_with("GitHub release candidate:") { - return Err(error.clone()); + let known_version = error + .strip_prefix("GitHub release candidate: ") + .and_then(|rest| rest.split_once(": ")) + .and_then(|(version, _)| semver::Version::parse(version).ok()); + let safe_mirror = known_version.as_ref().is_some_and(|known| { + cdn.as_ref().ok().is_some_and(|candidate| { + semver::Version::parse(&candidate.version) + .ok() + .is_some_and(|mirror| { + candidate.version == known.to_string() + || mirror.cmp_precedence(known).is_gt() + }) + }) + }); + if !safe_mirror { + return Err(error.clone()); + } } } let mut available = Vec::new(); @@ -330,6 +346,31 @@ mod tests { let available = merge_sources(Err("GitHub unavailable".into()), Ok(old)).unwrap(); assert_eq!(available.len(), 1); assert_eq!(available[0].version, "2.0.2-Beta.4"); + let failed = "GitHub release candidate: 2.0.2-Beta.6+build.test: signature unavailable"; + assert!(merge_sources( + Err(failed.into()), + Ok(candidate("dl.aeoform.com", "2.0.2-Beta.4")) + ) + .is_err()); + assert_eq!( + merge_sources(Err(failed.into()), Ok(candidate("dl.aeoform.com", VERSION))).unwrap()[0] + .version, + VERSION + ); + assert!(merge_sources( + Err(failed.into()), + Ok(candidate("dl.aeoform.com", "2.0.2-Beta.6+build.other")) + ) + .is_err()); + assert_eq!( + merge_sources( + Err(failed.into()), + Ok(candidate("dl.aeoform.com", "2.0.2-Beta.7")) + ) + .unwrap()[0] + .version, + "2.0.2-Beta.7" + ); } fn metadata_candidate(version: &str, abi: &str) -> UpdateCandidate { diff --git a/openless-all/app/src-tauri/src/update_source/android.rs b/openless-all/app/src-tauri/src/update_source/android.rs index 5c4ed2d7e..852dac394 100644 --- a/openless-all/app/src-tauri/src/update_source/android.rs +++ b/openless-all/app/src-tauri/src/update_source/android.rs @@ -2,7 +2,9 @@ use crate::types::UpdateChannel; use futures_util::StreamExt; use serde_json::{json, Value}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +type KnownCandidate = Arc>>; use std::time::Duration; use url::Url; @@ -20,9 +22,18 @@ fn github_url(url: &Url) -> bool { .starts_with("/Open-Less/openless/releases/download/") } +fn legacy_github_mirror_url(url: &Url) -> bool { + url.host_str() == Some("fastgit.cc") + && url + .path() + .starts_with("/https://github.com/Open-Less/openless/releases/") +} + pub(crate) fn allowed_update_download_url(url: &Url) -> bool { safe_https(url) - && (github_url(url) + && (url.host_str() == Some("github.com") + && url.path().starts_with("/Open-Less/openless/releases/") + || legacy_github_mirror_url(url) || url.host_str() == Some("dl.aeoform.com") && url.path().starts_with("/releases/")) } @@ -40,6 +51,7 @@ fn allowed_request(url: &Url, redirected: bool) -> bool { match url.host_str() { Some("github.com") => url.path().starts_with("/Open-Less/openless/releases"), Some("api.github.com") => url.path().starts_with("/repos/Open-Less/openless/releases"), + Some("fastgit.cc") => legacy_github_mirror_url(url), Some("dl.aeoform.com") => { url.path().starts_with("/releases/") || url.path().starts_with("/updates/") } @@ -336,7 +348,7 @@ impl From<&str> for ApiDiscoveryError { async fn api_manifest( channel: UpdateChannel, arch: &str, - candidate_seen: &AtomicBool, + candidate_seen: &KnownCandidate, ) -> Result { let endpoint = match channel { UpdateChannel::Stable => "https://api.github.com/repos/Open-Less/openless/releases/latest", @@ -364,12 +376,17 @@ async fn api_manifest( let version = tag_version(tag, channel).ok_or("invalid release tag")?; let (name, url, sig_url, size) = api_artifact(release, arch).ok_or("no versioned APK and signature for device ABI")?; - candidate_seen.store(true, Ordering::Release); + let full_version = artifact_version(name, arch) + .ok_or("invalid APK asset version")? + .to_string(); + *candidate_seen + .lock() + .unwrap_or_else(|error| error.into_inner()) = Some(full_version.clone()); let signature = text(sig_url, SIGNATURE_LIMIT) .await - .map_err(ApiDiscoveryError::Candidate)?; + .map_err(|error| ApiDiscoveryError::Candidate(candidate_error(&full_version, &error)))?; let version = android_signature_version(signature.trim(), name, &version.to_string(), arch) - .map_err(ApiDiscoveryError::Candidate)?; + .map_err(|error| ApiDiscoveryError::Candidate(candidate_error(&full_version, &error)))?; Ok( json!({"version":version,"url":url,"signature":signature.trim(),"pub_date":release["published_at"],"notes":release["body"],"size":size}), ) @@ -403,7 +420,7 @@ async fn web_manifest_from_feed( feed: &str, channel: UpdateChannel, arch: &str, - candidate_seen: &AtomicBool, + candidate_seen: &KnownCandidate, mut fetch: F, ) -> Result where @@ -426,13 +443,18 @@ where continue; }; // Once a matching pair exists, a transport or metadata failure cannot downgrade it. - candidate_seen.store(true, Ordering::Release); - let signature = fetch(sig_url, SIGNATURE_LIMIT) - .await - .map_err(|e| format!("GitHub release candidate: public signature: {e}"))?; + let full_version = artifact_version(&name, arch) + .ok_or("invalid APK asset version")? + .to_string(); + *candidate_seen + .lock() + .unwrap_or_else(|error| error.into_inner()) = Some(full_version.clone()); + let signature = fetch(sig_url, SIGNATURE_LIMIT).await.map_err(|error| { + candidate_error(&full_version, &format!("public signature: {error}")) + })?; let version = android_signature_version(signature.trim(), &name, &version.to_string(), arch) - .map_err(|e| format!("GitHub release candidate: {e}"))?; + .map_err(|error| candidate_error(&full_version, &error))?; return Ok( json!({"version":version,"url":url,"signature":signature.trim(),"notes":format!("https://github.com/Open-Less/openless/releases/tag/{tag}")}), ); @@ -443,7 +465,7 @@ where async fn web_manifest( channel: UpdateChannel, arch: &str, - candidate_seen: &AtomicBool, + candidate_seen: &KnownCandidate, ) -> Result { let feed = text( "https://github.com/Open-Less/openless/releases.atom", @@ -461,6 +483,10 @@ async fn web_manifest( .await } +fn candidate_error(version: &str, reason: &str) -> String { + format!("GitHub release candidate: {version}: {reason}") +} + fn combine_discovery_errors(api: String, web: String) -> String { if web.starts_with("GitHub release candidate:") { web @@ -471,15 +497,19 @@ fn combine_discovery_errors(api: String, web: String) -> String { async fn discovery_with_deadline( budget: Duration, - candidate_seen: &AtomicBool, + candidate_seen: &KnownCandidate, discovery: F, ) -> Result where F: std::future::Future>, { tokio::time::timeout(budget, discovery).await.map_err(|_| { - if candidate_seen.load(Ordering::Acquire) { - "GitHub release candidate: discovery timed out".to_string() + if let Some(version) = candidate_seen + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_ref() + { + candidate_error(version, "discovery timed out") } else { "GitHub Android discovery timed out".to_string() } @@ -493,13 +523,11 @@ pub(crate) async fn github_android_manifest( if abi(arch).is_none() { return Err("unsupported Android updater architecture".into()); } - let candidate_seen = AtomicBool::new(false); + let candidate_seen: KnownCandidate = Arc::new(Mutex::new(None)); discovery_with_deadline(DISCOVERY_BUDGET, &candidate_seen, async { match api_manifest(channel, arch, &candidate_seen).await { Ok(value) => Ok(value), - Err(ApiDiscoveryError::Candidate(error)) => { - Err(format!("GitHub release candidate: {error}")) - } + Err(ApiDiscoveryError::Candidate(error)) => Err(error), Err(ApiDiscoveryError::Unavailable(api)) => { web_manifest(channel, arch, &candidate_seen) .await @@ -717,7 +745,7 @@ mod tests { two_beta_feed(), UpdateChannel::Beta, "aarch64", - &AtomicBool::new(false), + &Arc::new(Mutex::new(None)), |url, _| { calls.lock().unwrap().push(url.clone()); let result = if url.contains("expanded_assets/v2.0.2-Beta.5") { @@ -749,7 +777,7 @@ mod tests { two_beta_feed(), UpdateChannel::Beta, "aarch64", - &AtomicBool::new(false), + &Arc::new(Mutex::new(None)), |url, _| { calls.lock().unwrap().push(url.clone()); let result = if url.contains("expanded_assets/v2.0.2-Beta.5") { @@ -763,7 +791,9 @@ mod tests { }, ) .await; - assert!(result.unwrap_err().starts_with("GitHub release candidate:")); + assert!(result + .unwrap_err() + .starts_with("GitHub release candidate: 2.0.2-Beta.5+build.test: ")); assert!(calls .lock() .unwrap() @@ -773,7 +803,9 @@ mod tests { } #[test] fn outer_fallback_preserves_known_candidate_error_category() { - let candidate = "GitHub release candidate: signed APK filename differs".to_string(); + let candidate = + "GitHub release candidate: 2.0.2-Beta.6+build.test: signed APK filename differs" + .to_string(); assert_eq!( combine_discovery_errors("API quota exceeded".into(), candidate.clone()), candidate @@ -788,7 +820,7 @@ mod tests { #[tokio::test] async fn discovery_deadline_distinguishes_known_pair_from_unknown_network() { for after_pair in [false, true] { - let candidate_seen = AtomicBool::new(false); + let candidate_seen: KnownCandidate = Arc::new(Mutex::new(None)); let (page, _) = page_fixture("v2.0.2-Beta.5-tauri", "arm64-v8a"); let discovery = web_manifest_from_feed( two_beta_feed(), @@ -810,8 +842,40 @@ mod tests { discovery_with_deadline(Duration::from_millis(20), &candidate_seen, discovery) .await .unwrap_err(); - assert_eq!(candidate_seen.load(Ordering::Acquire), after_pair); + assert_eq!(candidate_seen.lock().unwrap().is_some(), after_pair); + if after_pair { + assert!(error.starts_with("GitHub release candidate: 2.0.2-Beta.5+build.test: ")); + } assert_eq!(error.starts_with("GitHub release candidate:"), after_pair); } } + #[test] + fn legacy_release_mirror_urls_keep_exact_origin_and_repository_boundaries() { + for raw in [ + "https://fastgit.cc/https://github.com/Open-Less/openless/releases/latest/download/OpenLess.apk", + "https://fastgit.cc/https://github.com/Open-Less/openless/releases/download/v2.0.2-Beta.6-tauri/OpenLess.apk", + "https://github.com/Open-Less/openless/releases/latest/download/OpenLess.apk", + ] { + let url = Url::parse(raw).unwrap(); + assert!(allowed_update_download_url(&url)); + assert!(allowed_request(&url, false)); + assert!(allowed_request(&url, true)); + } + for raw in [ + "http://fastgit.cc/https://github.com/Open-Less/openless/releases/latest/download/OpenLess.apk", + "https://fastgit.cc.attacker.invalid/https://github.com/Open-Less/openless/releases/latest/download/OpenLess.apk", + "https://fastgit.cc@attacker.invalid/https://github.com/Open-Less/openless/releases/latest/download/OpenLess.apk", + "https://user:password@fastgit.cc/https://github.com/Open-Less/openless/releases/latest/download/OpenLess.apk", + "https://fastgit.cc:444/https://github.com/Open-Less/openless/releases/latest/download/OpenLess.apk", + "https://fastgit.cc/https://github.com/other/repo/releases/latest/download/OpenLess.apk", + "https://fastgit.cc/https://github.com/Open-Less/openless/releases-evil/download/OpenLess.apk", + "https://fastgit.cc/https://attacker.invalid/Open-Less/openless/releases/download/OpenLess.apk", + "https://fastgit.cc/https://github.com/Open-Less/openless/releases/../../other/private.apk", + ] { + let url = Url::parse(raw).unwrap(); + assert!(!allowed_update_download_url(&url)); + assert!(!allowed_request(&url, false)); + assert!(!allowed_request(&url, true)); + } + } } From 871d88df3ecdbdfc8cbe2d214e27097bddc0af65 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Mon, 9 Nov 2026 19:08:43 +0800 Subject: [PATCH 3/3] fix(android): report uncertain update freshness --- .../src-tauri/src/android/update_pipeline.rs | 97 +++++++++++++++++-- 1 file changed, 90 insertions(+), 7 deletions(-) diff --git a/openless-all/app/src-tauri/src/android/update_pipeline.rs b/openless-all/app/src-tauri/src/android/update_pipeline.rs index e04de8b6d..b27cda6ed 100644 --- a/openless-all/app/src-tauri/src/android/update_pipeline.rs +++ b/openless-all/app/src-tauri/src/android/update_pipeline.rs @@ -95,10 +95,13 @@ where Ok(bytes) } -pub(crate) async fn candidates( +async fn source_results( channel: UpdateChannel, arch: &str, -) -> Result, String> { +) -> ( + Result, + Result, +) { let (github, cdn) = tokio::join!( async { let value = update_source::github_android_manifest(channel, arch).await?; @@ -114,6 +117,14 @@ pub(crate) async fn candidates( .map_err(|_| "CDN update check timed out".to_string())? } ); + (github, cdn) +} + +pub(crate) async fn candidates( + channel: UpdateChannel, + arch: &str, +) -> Result, String> { + let (github, cdn) = source_results(channel, arch).await; merge_sources(github, cdn) } @@ -159,16 +170,30 @@ fn merge_sources( Ok(available) } +fn check_sources( + github: Result, + cdn: Result, + current: &str, +) -> Result, String> { + let github_unavailable = github.is_err(); + let update = updater_logic::newest_candidates(merge_sources(github, cdn)?) + .into_iter() + .find(|candidate| updater_logic::version_is_newer(&candidate.version, current)); + // A rolling mirror can lag. An unavailable canonical source is not evidence + // that the installed version is current, even if the mirror is reachable. + if update.is_none() && github_unavailable { + return Err("Unable to confirm the latest version from GitHub. Please retry.".into()); + } + Ok(update) +} + pub(crate) async fn check( channel: UpdateChannel, arch: &str, current: &str, ) -> Result, String> { - Ok( - updater_logic::newest_candidates(candidates(channel, arch).await?) - .into_iter() - .find(|candidate| updater_logic::version_is_newer(&candidate.version, current)), - ) + let (github, cdn) = source_results(channel, arch).await; + check_sources(github, cdn, current) } async fn transfer)>( @@ -373,6 +398,64 @@ mod tests { ); } + #[test] + fn github_unavailable_with_current_or_older_cdn_does_not_claim_up_to_date() { + for mirror_version in [VERSION, "2.0.2-Beta.5"] { + let error = check_sources( + Err("GitHub temporarily unavailable".into()), + Ok(candidate("dl.aeoform.com", mirror_version)), + VERSION, + ) + .unwrap_err(); + assert!(error.contains("Unable to confirm the latest version")); + } + } + + #[test] + fn github_unavailable_can_still_offer_a_known_newer_cdn_update() { + let update = check_sources( + Err("GitHub temporarily unavailable".into()), + Ok(candidate("dl.aeoform.com", VERSION)), + "2.0.2-Beta.5", + ) + .unwrap() + .unwrap(); + assert_eq!(update.version, VERSION); + } + + #[test] + fn successful_canonical_check_can_confirm_the_current_version() { + assert!(check_sources( + Ok(candidate("github.com", VERSION)), + Err("CDN unavailable".into()), + VERSION, + ) + .unwrap() + .is_none()); + } + + #[test] + fn known_candidate_failure_only_uses_a_sufficiently_fresh_mirror_for_check() { + let error = format!("GitHub release candidate: {VERSION}: signature unavailable"); + assert_eq!( + check_sources( + Err(error.clone()), + Ok(candidate("dl.aeoform.com", VERSION)), + "2.0.2-Beta.5", + ) + .unwrap() + .unwrap() + .version, + VERSION + ); + assert!(check_sources( + Err(error), + Ok(candidate("dl.aeoform.com", "2.0.2-Beta.5")), + "2.0.2-Beta.4", + ) + .is_err()); + } + fn metadata_candidate(version: &str, abi: &str) -> UpdateCandidate { let mut candidate = candidate("dl.aeoform.com", version); // Metadata validation precedes cryptographic validation of the downloaded bytes.