diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 7fbe29d4f..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: | @@ -526,7 +525,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 +543,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 +564,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 660194600..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 @@ -704,8 +700,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 +712,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,7 +725,6 @@ 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/* # ── Hand the finished tag to the platform workflows that ride the same Release ── diff --git a/README.md b/README.md index a80e238a1..0d0a74735 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. 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 10215d759..119bd6e41 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}`),国内用户直接从这里下载。清单由发布 workflow 生成为运行产物(run artifact),**不再挂到 GitHub Release**;因此发版后需要维护者手动把清单上传到 CDN,见 `RELEASING.md`。改动前装的客户端读的是旧的 `releases/latest/download/latest-*.json`,因此无法自动更新到最后一个还带这些文件的版本之后,需要手动下载一次。 ## 凭据 diff --git a/RELEASING.md b/RELEASING.md index 0a87893b8..d15806b04 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -165,9 +165,34 @@ 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`) 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 + ``` + + 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 @@ -341,6 +366,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 4a49657e3..57b0ff7b8 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/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']); 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/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/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 739092098..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) ─────────────────────────── @@ -712,20 +734,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")] @@ -742,7 +765,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. @@ -764,10 +787,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 @@ -792,28 +814,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)] @@ -836,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( @@ -844,12 +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 known GitHub direct links and the fastgit - // mirror prefix are allowed. - 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) { + // 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")] diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index c385a1df8..ab628ec7d 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 697be3b27..a73d7ad72 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" ] } }