diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 00000000..bae65986 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,451 @@ +# Nightly builds. Mirrors the build matrix and build/signing steps of +# release.yml — keep cert/NDK/toolchain bumps, secret names, and the +# truly-portable AppImage + portable-Windows steps in sync between the two. +# +# Differences from release.yml: this workflow (1) stamps a nightly version +# `-` (Asia/Shanghai), (2) publishes to Cloudflare R2 only (no +# GitHub release), and (3) assembles `nightly/latest.json` race-free from +# per-leg manifest fragments so a single failing leg never clobbers the manifest. +name: Nightly Readest + +on: + schedule: + - cron: '0 22 * * *' # 22:00 UTC = 06:00 GMT+8 + workflow_dispatch: + +permissions: + contents: read + +jobs: + compute-version: + runs-on: ubuntu-latest + outputs: + nightly_version: ${{ steps.v.outputs.nightly_version }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: main + - id: v + run: | + BASE=$(node -p "require('./apps/readest-app/package.json').version") + STAMP=$(TZ=Asia/Shanghai date +%Y%m%d%H) + echo "nightly_version=${BASE}-${STAMP}" >> "$GITHUB_OUTPUT" + + build: + needs: compute-version + strategy: + fail-fast: false + matrix: + config: + - os: ubuntu-latest + release: android + rust_target: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android + - os: ubuntu-22.04 + release: linux + arch: x86_64 + rust_target: x86_64-unknown-linux-gnu + - os: ubuntu-22.04-arm + release: linux + arch: aarch64 + rust_target: aarch64-unknown-linux-gnu + - os: macos-latest + release: macos + arch: aarch64 + rust_target: x86_64-apple-darwin,aarch64-apple-darwin + args: '--target universal-apple-darwin' + - os: windows-latest + release: windows + arch: x86_64 + rust_target: x86_64-pc-windows-msvc + args: '--target x86_64-pc-windows-msvc --bundles nsis' + - os: windows-latest + release: windows + arch: aarch64 + rust_target: aarch64-pc-windows-msvc + args: '--target aarch64-pc-windows-msvc --bundles nsis' + + runs-on: ${{ matrix.config.os }} + timeout-minutes: 60 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: main + + - name: initialize git submodules + run: git submodule update --init --recursive + + - name: setup pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 + + - name: setup node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 24 + cache: pnpm + + - name: setup Java (for Android build only) + if: matrix.config.release == 'android' + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: 'zulu' + java-version: '17' + + - name: setup Android SDK (for Android build only) + if: matrix.config.release == 'android' + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4 + + - name: install NDK (for Android build only) + if: matrix.config.release == 'android' + run: sdkmanager "ndk;28.2.13676358" + + - name: install dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + - name: copy pdfjs-dist and simplecc-dist to public directory + run: pnpm --filter @readest/readest-app setup-vendors + + - name: install Rust stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + targets: ${{ matrix.config.rust_target }} + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + key: nightly-${{ matrix.config.os }}-${{ matrix.config.release }}-${{ matrix.config.arch }}-cargo + + - name: install dependencies (ubuntu only) + if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android' + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1 libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1 libjavascriptcoregtk-4.1-dev gir1.2-javascriptcoregtk-4.1 gir1.2-webkit2-4.1 libappindicator3-dev librsvg2-dev patchelf xdg-utils + + - name: create .env.local file for Next.js + run: | + echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local + echo "NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}" >> .env.local + echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env.local + echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local + echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local + cp .env.local apps/readest-app/.env.local + + - name: install rclone + shell: bash + run: | + if [ "$RUNNER_OS" = "Linux" ]; then + sudo apt-get update && sudo apt-get install -y rclone + elif [ "$RUNNER_OS" = "macOS" ]; then + brew install rclone + else + choco install rclone -y + fi + + - name: configure rclone + shell: bash + run: | + mkdir -p ~/.config/rclone + cat > ~/.config/rclone/rclone.conf < keystore.properties + echo "password=${{ secrets.ANDROID_KEY_PASSWORD }}" >> keystore.properties + base64 -d <<< "${{ secrets.ANDROID_KEY_BASE64 }}" > $RUNNER_TEMP/keystore.jks + echo "storeFile=$RUNNER_TEMP/keystore.jks" >> keystore.properties + popd + + apk_path=src-tauri/gen/android/app/build/outputs/apk/universal/release + universal_apk=Readest_${version}_universal.apk + arm64_apk=Readest_${version}_arm64.apk + pnpm tauri android build + cp ${apk_path}/app-universal-release.apk $universal_apk + pnpm tauri android build -t aarch64 + cp ${apk_path}/app-universal-release.apk $arm64_apk + pnpm tauri signer sign $universal_apk + pnpm tauri signer sign $arm64_apk + + # ──────────────────────────── DESKTOP ──────────────────────────── + # Linux uses the truly-portable AppImage tauri CLI fork (mirrors + # release.yml). The nightly version is patched BEFORE `tauri build` so the + # bundle filenames carry the stamp. + - name: Override tauri-cli with custom AppImage format (Linux) + if: matrix.config.release == 'linux' + run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force + + - name: build desktop bundles + if: matrix.config.release != 'android' + shell: bash + env: + TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: 'true' + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + NODE_OPTIONS: '--max-old-space-size=8192' + run: | + version="${{ needs.compute-version.outputs.nightly_version }}" + node -e "const f='apps/readest-app/package.json';const j=require('./'+f);j.version='${version}';require('fs').writeFileSync(f, JSON.stringify(j,null,2)+'\n')" + cd apps/readest-app + # On Linux use the cargo `tauri` CLI (the truly-portable AppImage fork + # installed above); elsewhere the npm @tauri-apps/cli. + if [ "${{ matrix.config.release }}" = "linux" ]; then + cargo tauri build ${{ matrix.config.args }} + else + pnpm tauri build ${{ matrix.config.args }} + fi + + # Portable Windows build: rebuild with NEXT_PUBLIC_PORTABLE_APP=true and + # ship the raw exe (mirrors release.yml). Runs after the NSIS build above. + - name: build and sign portable binaries (Windows only) + if: matrix.config.os == 'windows-latest' + shell: bash + env: + TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -euo pipefail + version="${{ needs.compute-version.outputs.nightly_version }}" + arch="${{ matrix.config.arch }}" + rust_target="${{ matrix.config.rust_target }}" + + # The clean NSIS build above produced bundle/nsis/Readest__-setup.exe (+ .sig). The portable rebuild below runs `tauri + # build ... --bundles nsis` AGAIN with NEXT_PUBLIC_PORTABLE_APP=true, + # which OVERWRITES that installer with a portable-flavored one. Stage + # the clean installer + its updater .sig to a safe dir FIRST so the + # collect step can read the untouched copy for the windows-* keys. + if [ "$arch" = "x86_64" ]; then + nsis_name="Readest_${version}_x64-setup.exe" + else + nsis_name="Readest_${version}_arm64-setup.exe" + fi + nsis_src="target/${rust_target}/release/bundle/nsis/${nsis_name}" + mkdir -p nsis-staged + cp "$nsis_src" "nsis-staged/${nsis_name}" + cp "${nsis_src}.sig" "nsis-staged/${nsis_name}.sig" + + pushd apps/readest-app/ + echo "NEXT_PUBLIC_PORTABLE_APP=true" >> .env.local + pnpm tauri build ${{ matrix.config.args }} + popd + + if [ "$arch" = "x86_64" ]; then + bin_file="Readest_${version}_x64-portable.exe" + else + bin_file="Readest_${version}_arm64-portable.exe" + fi + exe_file="target/${{ matrix.config.rust_target }}/release/readest.exe" + # Browsers on Windows refuse to download zips containing exe files, so + # ship the exe directly (matches release.yml). + cp "$exe_file" "$bin_file" + pushd apps/readest-app/ + pnpm tauri signer sign "../../$bin_file" + popd + + # ───────────────── COLLECT ARTIFACTS + BUILD FRAGMENT ───────────────── + # Each leg copies its updater artifacts (+ .sig) into ./nightly-out and + # emits a per-leg manifest fragment keyed by the EXACT Tauri platform keys + # the client expects (see src/helpers/updater.ts::getNightlyPlatformKey and + # src/components/UpdaterWindow.tsx::TAURI_UPDATER_KEYS). The fragment + # `signature` is the .sig file CONTENTS; `url` is the download.readest.com + # URL of the uploaded artifact under nightly//. + - name: collect artifacts + build manifest fragment + shell: bash + run: | + set -euo pipefail + version="${{ needs.compute-version.outputs.nightly_version }}" + release="${{ matrix.config.release }}" + arch="${{ matrix.config.arch }}" + rust_target="${{ matrix.config.rust_target }}" + base_url="https://download.readest.com/nightly/${version}" + out="$PWD/nightly-out" + frag_dir="$out/frag" + mkdir -p "$out" "$frag_dir" + # Cargo WORKSPACE: bundles land in the REPO-ROOT target/ dir, not + # apps/readest-app/src-tauri/target/ (matches release.yml line 371). + bundle="target" + + # Stage one artifact + its .sig into $out, then append a + # platforms[] = {signature, url} entry to the fragment JSON. + frag="$frag_dir/${release}-${arch:-all}.json" + echo '{"platforms":{}}' > "$frag" + add_entry() { + local src="$1"; local fname="$2"; local key="$3" + if [ ! -f "$src" ] || [ ! -f "${src}.sig" ]; then + echo "::error::missing artifact or signature for $key: $src" + exit 1 + fi + cp "$src" "$out/$fname" + cp "${src}.sig" "$out/${fname}.sig" + local sig; sig=$(cat "${src}.sig") + local url="${base_url}/${fname}" + jq --arg k "$key" --arg sig "$sig" --arg url "$url" \ + '.platforms[$k] = {signature: $sig, url: $url}' "$frag" > "$frag.tmp" && mv "$frag.tmp" "$frag" + echo "fragment += $key -> $fname" + } + + case "$release" in + android) + # Signed in the android step; basenames already version-stamped. + add_entry "apps/readest-app/Readest_${version}_universal.apk" "Readest_${version}_universal.apk" "android-universal" + add_entry "apps/readest-app/Readest_${version}_arm64.apk" "Readest_${version}_arm64.apk" "android-arm64" + ;; + macos) + # Universal updater bundle: bundle/macos/Readest.app.tar.gz (no + # version/arch on disk). Upload under the stable convention name + # Readest_universal.app.tar.gz; both darwin keys point at it. + src="${bundle}/universal-apple-darwin/release/bundle/macos/Readest.app.tar.gz" + add_entry "$src" "Readest_universal.app.tar.gz" "darwin-aarch64" + # Reuse the already-staged copy for the x86_64 key (same artifact). + x86_sig=$(cat "$out/Readest_universal.app.tar.gz.sig") + jq --arg sig "$x86_sig" --arg url "${base_url}/Readest_universal.app.tar.gz" \ + '.platforms["darwin-x86_64"] = {signature: $sig, url: $url}' "$frag" > "$frag.tmp" && mv "$frag.tmp" "$frag" + echo "fragment += darwin-x86_64 -> Readest_universal.app.tar.gz" + ;; + windows) + if [ "$arch" = "x86_64" ]; then + nsis_name="Readest_${version}_x64-setup.exe" + portable_name="Readest_${version}_x64-portable.exe" + nsis_key="windows-x86_64"; portable_key="windows-x86_64-portable" + else + nsis_name="Readest_${version}_arm64-setup.exe" + portable_name="Readest_${version}_arm64-portable.exe" + nsis_key="windows-aarch64"; portable_key="windows-aarch64-portable" + fi + # Read the CLEAN NSIS installer staged before the portable rebuild + # (the rebuild overwrites bundle/nsis/...-setup.exe). See the + # "build and sign portable binaries" step. + add_entry "nsis-staged/${nsis_name}" "$nsis_name" "$nsis_key" + # Portable exe was copied + signed into the repo root above. + add_entry "$portable_name" "$portable_name" "$portable_key" + ;; + linux) + # Truly-portable AppImage: bundle/appimage/Readest__.AppImage + if [ "$arch" = "x86_64" ]; then + appimage_name="Readest_${version}_amd64.AppImage" + key="linux-x86_64-appimage" + else + appimage_name="Readest_${version}_aarch64.AppImage" + key="linux-aarch64-appimage" + fi + add_entry "${bundle}/${rust_target}/release/bundle/appimage/${appimage_name}" "$appimage_name" "$key" + ;; + *) + echo "::error::unknown release leg: $release"; exit 1 + ;; + esac + + - name: upload artifacts + fragment to R2 + shell: bash + run: | + set -euo pipefail + version="${{ needs.compute-version.outputs.nightly_version }}" + base="r2:readest-releases/nightly/${version}" + out="$PWD/nightly-out" + # Artifacts (exclude the local frag/ scratch dir). + rclone copy "$out" "$base/" --exclude "frag/**" + # Per-leg manifest fragment. + rclone copy "$out/frag" "$base/manifest-fragments/" + + assemble-manifest: + needs: [compute-version, build] + if: ${{ always() && needs.build.result != 'cancelled' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: install rclone + jq + run: sudo apt-get update && sudo apt-get install -y rclone jq + + - name: configure rclone + run: | + mkdir -p ~/.config/rclone + cat > ~/.config/rclone/rclone.conf </dev/null)" ]; then + echo "::error::no manifest fragments found — all build legs failed; manifest NOT promoted" + exit 1 + fi + + # Merge every fragment's .platforms into one manifest. + jq -s \ + --arg version "$version" \ + '{version: $version, pub_date: (now | todateiso8601), notes: "Nightly build", platforms: (map(.platforms) | add)}' \ + ./frag/*.json > latest.json + + echo "Assembled latest.json:" + jq '{version, platforms: (.platforms | keys)}' latest.json + + # Atomic promote: upload to a temp key, then server-side move so + # readers never observe a half-written latest.json. + rclone copyto latest.json "$base/latest.json.tmp" + rclone moveto "$base/latest.json.tmp" "$base/latest.json" + + - name: prune old nightly folders (keep newest 7) + run: | + set -euo pipefail + base="r2:readest-releases/nightly" + # Version dirs are -. Lexicographic order is NOT + # chronological across a base-version bump (e.g. 0.2.0-* sorts after + # 0.11.0-*), so sort by the numeric stamp tail (everything after the + # last '-') to keep the newest 7 by build time. + mapfile -t dirs < <(rclone lsf "$base/" --dirs-only | sed 's:/$::' \ + | sed -E 's/^(.*)-([0-9]{10})$/\2 \1-\2/' | sort -n -k1,1 | cut -d' ' -f2-) + count=${#dirs[@]} + if [ "$count" -gt 7 ]; then + for d in "${dirs[@]:0:$((count-7))}"; do + echo "pruning $d" + rclone purge "$base/$d" + done + fi + + - name: notify on failure + if: failure() + run: echo "::error::Nightly assemble failed — manifest not promoted." diff --git a/Cargo.lock b/Cargo.lock index 9ffb75ba..06162c0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,7 @@ checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" name = "Readest" version = "0.2.2" dependencies = [ + "base64 0.22.1", "block", "cocoa", "discord-rich-presence", @@ -21,6 +22,7 @@ dependencies = [ "libc", "log", "md-5", + "minisign-verify", "mobi", "objc", "objc-foundation", @@ -33,6 +35,7 @@ dependencies = [ "rand 0.8.6", "read-progress-stream", "reqwest 0.12.28", + "semver", "serde", "serde_json", "tauri", diff --git a/apps/readest-app/docs/superpowers/plans/2026-06-14-nightly-update-channel.md b/apps/readest-app/docs/superpowers/plans/2026-06-14-nightly-update-channel.md new file mode 100644 index 00000000..52c8e334 --- /dev/null +++ b/apps/readest-app/docs/superpowers/plans/2026-06-14-nightly-update-channel.md @@ -0,0 +1,1412 @@ +# Nightly Update Channel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an opt-in nightly update channel (Android/Windows/macOS/Linux) with a daily R2-published build and an in-app updater whose check is isolated from Tauri's built-in updater. + +**Architecture:** A base-aware version comparator (implemented identically in TS and Rust against one shared test matrix) ranks same-base nightlies above the matching stable but a higher-base stable above an older-base nightly. The JS check fetches both `nightly/latest.json` and stable `latest.json`, filters by platform eligibility, and picks the newest. Install reuses Tauri's verified updater for macOS + Windows-NSIS (via a thin Rust command driving `UpdaterBuilder` with a custom endpoint + the comparator) and the existing custom JS flows + a new minisign verify gate for Windows-portable / Linux-AppImage / Android. A scheduled GitHub Actions workflow builds nightly artifacts and assembles the manifest race-free into R2. + +**Tech Stack:** Next.js + Tauri v2, `semver` (npm + Rust crate), `tauri-plugin-updater` 2.10, `minisign-verify` (Rust), Vitest, GitHub Actions, Cloudflare R2 via rclone. + +**Spec:** `docs/superpowers/specs/2026-06-14-nightly-update-channel-design.md` + +--- + +## File Structure + +| File | Responsibility | Change | +|---|---|---| +| `src/utils/version.ts` | `parseUpdateVersion`, `isUpdateNewer` (pure, TS side of the rule) | Modify | +| `src/__tests__/utils/version.test.ts` | Comparator matrix tests | Create | +| `src/types/settings.ts` | `updateChannel` field | Modify | +| `src/services/constants.ts` | default `updateChannel`, `READEST_NIGHTLY_UPDATER_FILE`, `READEST_UPDATER_PUBKEY` | Modify | +| `src/helpers/updater.ts` | channel-aware check + `resolveNightlyUpdate` + `getNightlyPlatformKey` | Modify | +| `src/__tests__/helpers/updater.test.ts` | nightly resolution + routing tests | Modify | +| `src/utils/bridge.ts` | `verifyUpdateSignature`, `installNightlyUpdate` JS wrappers | Modify | +| `src/components/UpdaterWindow.tsx` | consume resolved winner; nightly routing; verify gate; UI states; friendly version | Modify | +| `src/app/library/components/SettingsMenu.tsx` | "Nightly Builds (Unstable)" toggle | Modify | +| `src-tauri/src/nightly_update.rs` | `is_update_newer`, `verify_update_signature`, `install_nightly_update` Rust commands | Create | +| `src-tauri/src/lib.rs` | register the new commands + `mod nightly_update` | Modify | +| `src-tauri/Cargo.toml` | `semver`, `minisign-verify` deps | Modify | +| `.github/workflows/nightly.yml` | scheduled build → R2 | Create | + +--- + +## Phase A — Version comparator (TS + Rust, shared matrix) + +### Task A1: TypeScript comparator + +**Files:** +- Modify: `src/utils/version.ts` +- Test: `src/__tests__/utils/version.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/__tests__/utils/version.test.ts`: + +```typescript +import { describe, test, expect } from 'vitest'; +import { parseUpdateVersion, isUpdateNewer } from '@/utils/version'; + +describe('parseUpdateVersion', () => { + test('parses a stable version', () => { + expect(parseUpdateVersion('0.11.4')).toEqual({ base: '0.11.4', stamp: null, isNightly: false }); + }); + test('parses a nightly stamp', () => { + expect(parseUpdateVersion('0.11.4-2026061406')).toEqual({ + base: '0.11.4', + stamp: 2026061406, + isNightly: true, + }); + }); + test('non-10-digit prerelease is not a nightly stamp', () => { + expect(parseUpdateVersion('0.11.4-rc.1')).toEqual({ base: '0.11.4', stamp: null, isNightly: false }); + expect(parseUpdateVersion('0.11.4-2026')).toEqual({ base: '0.11.4', stamp: null, isNightly: false }); + }); + test('returns null for malformed input', () => { + expect(parseUpdateVersion('')).toBeNull(); + expect(parseUpdateVersion('not-a-version')).toBeNull(); + }); +}); + +describe('isUpdateNewer', () => { + const cases: Array<[string, string, boolean]> = [ + ['0.11.5', '0.11.4-2026061406', true], + ['0.11.4-2026061506', '0.11.4-2026061406', true], + ['0.11.4-2026061406', '0.11.4-2026061506', false], + ['0.11.4', '0.11.4-2026061406', false], + ['0.11.4-2026061406', '0.11.4', true], + ['0.11.5-2026070106', '0.11.4', true], + ['0.11.4', '0.11.4', false], + ['0.11.4-2026061406', '0.11.4-2026061406', false], + ['0.11.4-rc.1', '0.11.4', false], + ['', '0.11.4', false], + ['0.11.4', '', false], + ]; + test.each(cases)('isUpdateNewer(%s, %s) === %s', (candidate, current, expected) => { + expect(isUpdateNewer(candidate, current)).toBe(expected); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test src/__tests__/utils/version.test.ts` +Expected: FAIL — `parseUpdateVersion`/`isUpdateNewer` are not exported. + +- [ ] **Step 3: Implement the comparator** + +Replace the contents of `src/utils/version.ts` with: + +```typescript +import semver from 'semver'; +import packageJson from '../../package.json'; + +export const getAppVersion = () => { + return packageJson.version; +}; + +export interface ParsedUpdateVersion { + base: string; // "X.Y.Z" + stamp: number | null; // YYYYMMDDHH, or null when not a nightly + isNightly: boolean; +} + +// A nightly version is `-`: a single, pure-10-digit +// prerelease identifier. Anything else (e.g. `-rc.1`, `-2026`) is treated as a +// non-nightly base version. +export const parseUpdateVersion = (version: string): ParsedUpdateVersion | null => { + const parsed = semver.parse(version); + if (!parsed) return null; + const base = `${parsed.major}.${parsed.minor}.${parsed.patch}`; + let stamp: number | null = null; + if (parsed.prerelease.length === 1) { + const id = String(parsed.prerelease[0]); + if (/^\d{10}$/.test(id)) { + stamp = Number(id); + } + } + return { base, stamp, isNightly: stamp !== null }; +}; + +// Base-aware "is candidate newer than current?" used by both the nightly channel +// check and (mirrored in Rust) the Tauri updater version_comparator. +// Rule: higher X.Y.Z core wins; on equal core a nightly outranks the matching +// stable (it was built after it) but never the reverse; two nightlies compare by +// stamp. +export const isUpdateNewer = (candidate: string, current: string): boolean => { + const c = parseUpdateVersion(candidate); + const cur = parseUpdateVersion(current); + if (!c || !cur) return false; + if (c.base !== cur.base) { + return semver.compare(c.base, cur.base) > 0; + } + if (c.isNightly && !cur.isNightly) return true; + if (!c.isNightly && cur.isNightly) return false; + if (c.isNightly && cur.isNightly) return (c.stamp as number) > (cur.stamp as number); + return false; +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test src/__tests__/utils/version.test.ts` +Expected: PASS (all matrix rows green). + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/version.ts src/__tests__/utils/version.test.ts +git commit -m "feat(updater): base-aware nightly version comparator (TS)" +``` + +--- + +### Task A2: Rust comparator mirror + +**Files:** +- Create: `src-tauri/src/nightly_update.rs` +- Modify: `src-tauri/src/lib.rs` (add `mod nightly_update;`) +- Modify: `src-tauri/Cargo.toml` (add `semver`) + +- [ ] **Step 1: Add the `semver` dependency** + +In `src-tauri/Cargo.toml`, under `[dependencies]` (the cross-platform section), add: + +```toml +semver = "1" +``` + +- [ ] **Step 2: Create the Rust module with the comparator + a unit test** + +Create `src-tauri/src/nightly_update.rs`: + +```rust +//! Nightly update channel: base-aware version comparator + verify/install +//! commands. The comparator mirrors `src/utils/version.ts::isUpdateNewer` and is +//! validated against the same matrix. + +use semver::Version; + +/// Returns the 10-digit nightly stamp if the prerelease is exactly `YYYYMMDDHH`. +fn parse_stamp(v: &Version) -> Option { + let pre = v.pre.as_str(); + if pre.len() == 10 && pre.bytes().all(|b| b.is_ascii_digit()) { + pre.parse::().ok() + } else { + None + } +} + +/// Base-aware "is `candidate` newer than `current`?" — see version.ts for the rule. +pub fn is_update_newer(candidate: &str, current: &str) -> bool { + let (c, cur) = match (Version::parse(candidate), Version::parse(current)) { + (Ok(c), Ok(cur)) => (c, cur), + _ => return false, + }; + let c_base = (c.major, c.minor, c.patch); + let cur_base = (cur.major, cur.minor, cur.patch); + if c_base != cur_base { + return c_base > cur_base; + } + match (parse_stamp(&c), parse_stamp(&cur)) { + (Some(_), None) => true, + (None, Some(_)) => false, + (Some(cs), Some(curs)) => cs > curs, + (None, None) => false, + } +} + +#[cfg(test)] +mod tests { + use super::is_update_newer; + + #[test] + fn matrix() { + let cases: &[(&str, &str, bool)] = &[ + ("0.11.5", "0.11.4-2026061406", true), + ("0.11.4-2026061506", "0.11.4-2026061406", true), + ("0.11.4-2026061406", "0.11.4-2026061506", false), + ("0.11.4", "0.11.4-2026061406", false), + ("0.11.4-2026061406", "0.11.4", true), + ("0.11.5-2026070106", "0.11.4", true), + ("0.11.4", "0.11.4", false), + ("0.11.4-2026061406", "0.11.4-2026061406", false), + ("0.11.4-rc.1", "0.11.4", false), + ("", "0.11.4", false), + ("0.11.4", "", false), + ]; + for (cand, cur, want) in cases { + assert_eq!(is_update_newer(cand, cur), *want, "is_update_newer({cand}, {cur})"); + } + } +} +``` + +- [ ] **Step 3: Declare the module** + +In `src-tauri/src/lib.rs`, add near the other top-level `mod` declarations (e.g. after `mod transfer_file;` — search for `mod transfer_file` or the `use transfer_file::` line at `src/lib.rs:48` and add the module declaration alongside the others): + +```rust +mod nightly_update; +``` + +- [ ] **Step 4: Run the Rust test** + +Run: `pnpm test:rust` (i.e. `cargo test -p Readest --lib nightly_update`) +Expected: PASS — `nightly_update::tests::matrix`. + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/nightly_update.rs src-tauri/src/lib.rs src-tauri/Cargo.toml +git commit -m "feat(updater): base-aware nightly comparator (Rust mirror)" +``` + +--- + +## Phase B — Settings + constants + +### Task B1: Settings type, defaults, constants + +**Files:** +- Modify: `src/types/settings.ts:283` +- Modify: `src/services/constants.ts` (default block ~line 108; updater constants ~line 794) + +- [ ] **Step 1: Add the setting field** + +In `src/types/settings.ts`, in the `SystemSettings` interface, immediately after `autoCheckUpdates: boolean;` (line 283), add: + +```typescript + updateChannel: 'stable' | 'nightly'; +``` + +- [ ] **Step 2: Add the default** + +In `src/services/constants.ts`, in `DEFAULT_SYSTEM_SETTINGS`, immediately after `autoCheckUpdates: true,`, add: + +```typescript + updateChannel: 'stable', +``` + +- [ ] **Step 3: Add the nightly endpoint + pubkey constants** + +In `src/services/constants.ts`, after the existing `READEST_UPDATER_FILE` / `READEST_CHANGELOG_FILE` block (~line 798), add: + +```typescript +export const READEST_NIGHTLY_UPDATER_FILE = 'https://download.readest.com/nightly/latest.json'; + +// Public (verification) key, identical to src-tauri/tauri.conf.json `updater.pubkey`. +// Used to verify nightly artifacts in the custom install flows (portable / +// AppImage / Android). Safe to embed — it is a public key. +export const READEST_UPDATER_PUBKEY = + 'dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJFMEQ1QjE2OEU1NEIzNTEKUldSUnMxU09GbHNOdmpEaWFMT1crRFpEV2VORzQ2MklxaFc0M1R0ci9xY2c1bENXS0xhM1R1L2sK'; +``` + +- [ ] **Step 4: Type-check** + +Run: `pnpm lint` +Expected: PASS (no type errors from the new field; all settings consumers compile). + +- [ ] **Step 5: Commit** + +```bash +git add src/types/settings.ts src/services/constants.ts +git commit -m "feat(updater): add updateChannel setting + nightly constants" +``` + +--- + +### Task B2: Settings menu toggle + +**Files:** +- Modify: `src/app/library/components/SettingsMenu.tsx` (state ~line 57; handler ~line 158; JSX ~line 388) + +- [ ] **Step 1: Add local state** + +In `src/app/library/components/SettingsMenu.tsx`, after the `isAutoCheckUpdates` state (line 57), add: + +```typescript + const [isNightlyChannel, setIsNightlyChannel] = useState(settings.updateChannel === 'nightly'); +``` + +- [ ] **Step 2: Add the toggle handler** + +After the `toggleAutoCheckUpdates` handler (ends ~line 162), add: + +```typescript + const toggleNightlyChannel = () => { + const newValue = !isNightlyChannel; + saveSysSettings(envConfig, 'updateChannel', newValue ? 'nightly' : 'stable'); + setIsNightlyChannel(newValue); + }; +``` + +- [ ] **Step 3: Add the menu item** + +In the JSX, immediately after the "Check Updates on Start" `MenuItem` block (lines 388-394), add: + +```typescript + {appService?.hasUpdater && ( + + )} +``` + +- [ ] **Step 4: Verify build + type-check** + +Run: `pnpm lint` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/library/components/SettingsMenu.tsx +git commit -m "feat(updater): nightly channel toggle in settings menu" +``` + +--- + +## Phase C — Isolated nightly check (JS) + +### Task C1: `getNightlyPlatformKey` + `resolveNightlyUpdate` + +**Files:** +- Modify: `src/helpers/updater.ts` +- Test: `src/__tests__/helpers/updater.test.ts` + +- [ ] **Step 1: Write the failing tests** + +In `src/__tests__/helpers/updater.test.ts`, first extend the existing mocks (the `@/utils/version` mock currently only exposes `getAppVersion`, and the `@/services/constants` mock lacks the nightly endpoint). Replace those two `vi.mock` blocks with: + +```typescript +let mockAppVersion = '1.0.0'; +vi.mock('@/utils/version', async () => { + const actual = await vi.importActual('@/utils/version'); + return { + ...actual, + getAppVersion: () => mockAppVersion, + }; +}); + +vi.mock('@/services/constants', () => ({ + CHECK_UPDATE_INTERVAL_SEC: 86400, + READEST_UPDATER_FILE: 'https://example.com/latest.json', + READEST_CHANGELOG_FILE: 'https://example.com/release-notes.json', + READEST_NIGHTLY_UPDATER_FILE: 'https://example.com/nightly/latest.json', +})); +``` + +Then add a new describe block (after the existing `checkForAppUpdates` block) and extend the `@/helpers/updater` import to include `resolveNightlyUpdate` and `getNightlyPlatformKey`: + +```typescript +import { + checkForAppUpdates, + checkAppReleaseNotes, + setLastShownReleaseNotesVersion, + getLastShownReleaseNotesVersion, + resolveNightlyUpdate, + getNightlyPlatformKey, +} from '@/helpers/updater'; + +describe('getNightlyPlatformKey', () => { + test('android', () => { + expect(getNightlyPlatformKey('android', 'aarch64', false, false)).toBe('android-arm64'); + expect(getNightlyPlatformKey('android', 'x86_64', false, false)).toBe('android-universal'); + }); + test('windows nsis vs portable', () => { + expect(getNightlyPlatformKey('windows', 'x86_64', false, false)).toBe('windows-x86_64'); + expect(getNightlyPlatformKey('windows', 'x86_64', true, false)).toBe('windows-x86_64-portable'); + }); + test('linux appimage vs deb', () => { + expect(getNightlyPlatformKey('linux', 'x86_64', false, true)).toBe('linux-x86_64-appimage'); + expect(getNightlyPlatformKey('linux', 'x86_64', false, false)).toBe('linux-x86_64'); + }); + test('macos', () => { + expect(getNightlyPlatformKey('macos', 'aarch64', false, false)).toBe('darwin-aarch64'); + }); +}); + +describe('resolveNightlyUpdate', () => { + const mkRes = (body: unknown) => ({ ok: true, json: async () => body }); + const platformKey = 'darwin-aarch64'; + const entry = { url: 'https://x/app.tar.gz', signature: 'sig' }; + + test('picks newer nightly over stable when stable is same-base', async () => { + const fetchFn = vi.fn(async (url: string) => + url.includes('nightly') + ? mkRes({ version: '0.11.4-2026061406', platforms: { [platformKey]: entry } }) + : mkRes({ version: '0.11.4', platforms: { [platformKey]: entry } }), + ); + const r = await resolveNightlyUpdate('0.11.4', platformKey, fetchFn as never); + expect(r?.version).toBe('0.11.4-2026061406'); + expect(r?.endpoint).toContain('nightly'); + }); + + test('picks higher-base stable over older nightly', async () => { + const fetchFn = vi.fn(async (url: string) => + url.includes('nightly') + ? mkRes({ version: '0.11.4-2026061406', platforms: { [platformKey]: entry } }) + : mkRes({ version: '0.11.5', platforms: { [platformKey]: entry } }), + ); + const r = await resolveNightlyUpdate('0.11.4-2026061406', platformKey, fetchFn as never); + expect(r?.version).toBe('0.11.5'); + expect(r?.endpoint).not.toContain('nightly'); + }); + + test('ignores a manifest missing the current platform key', async () => { + const fetchFn = vi.fn(async (url: string) => + url.includes('nightly') + ? mkRes({ version: '0.11.4-2026061406', platforms: { [platformKey]: entry } }) + : mkRes({ version: '0.11.5', platforms: {} }), + ); + const r = await resolveNightlyUpdate('0.11.4', platformKey, fetchFn as never); + expect(r?.version).toBe('0.11.4-2026061406'); + }); + + test('returns null when nothing is newer than installed', async () => { + const fetchFn = vi.fn(async () => mkRes({ version: '0.11.4', platforms: { [platformKey]: entry } })); + const r = await resolveNightlyUpdate('0.11.4', platformKey, fetchFn as never); + expect(r).toBeNull(); + }); + + test('returns null when both manifests fail to fetch', async () => { + const fetchFn = vi.fn(async () => { + throw new Error('network'); + }); + const r = await resolveNightlyUpdate('0.11.4', platformKey, fetchFn as never); + expect(r).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test src/__tests__/helpers/updater.test.ts` +Expected: FAIL — `resolveNightlyUpdate` / `getNightlyPlatformKey` not exported. + +- [ ] **Step 3: Implement the helpers** + +In `src/helpers/updater.ts`, update the imports at the top: + +```typescript +import { getAppVersion, isUpdateNewer } from '@/utils/version'; +import { + CHECK_UPDATE_INTERVAL_SEC, + READEST_CHANGELOG_FILE, + READEST_UPDATER_FILE, + READEST_NIGHTLY_UPDATER_FILE, +} from '@/services/constants'; +``` + +Then add (above `checkForAppUpdates`): + +```typescript +type FetchFn = typeof fetch; + +export interface UpdateManifestEntry { + url?: string; + signature?: string; +} +export interface UpdateManifest { + version: string; + pub_date?: string; + notes?: string; + platforms: Record; +} +export interface ResolvedNightlyUpdate { + endpoint: string; // manifest URL (for the Tauri UpdaterBuilder path) + version: string; + notes?: string; + pubDate?: string; + platformKey: string; + url: string; // artifact URL (for the custom install flows) + signature: string; // artifact signature +} + +export const getNightlyPlatformKey = ( + osTypeVal: string, + osArchVal: string, + isPortable: boolean, + isAppImage: boolean, +): string | null => { + const is64 = osArchVal === 'x86_64'; + if (osTypeVal === 'android') return osArchVal === 'aarch64' ? 'android-arm64' : 'android-universal'; + if (osTypeVal === 'macos') return osArchVal === 'aarch64' ? 'darwin-aarch64' : 'darwin-x86_64'; + if (osTypeVal === 'windows') { + if (isPortable) return is64 ? 'windows-x86_64-portable' : 'windows-aarch64-portable'; + return is64 ? 'windows-x86_64' : 'windows-aarch64'; + } + if (osTypeVal === 'linux') { + if (isAppImage) return is64 ? 'linux-x86_64-appimage' : 'linux-aarch64-appimage'; + return is64 ? 'linux-x86_64' : 'linux-aarch64'; + } + return null; +}; + +const fetchManifest = async (fetchFn: FetchFn, url: string): Promise => { + try { + const res = await fetchFn(url, { connectTimeout: 5000 } as RequestInit); + if (!res.ok) return null; + return (await res.json()) as UpdateManifest; + } catch (err) { + console.warn('Failed to fetch update manifest', url, err); + return null; + } +}; + +// Nightly channel resolution: fetch the nightly + stable manifests, keep only +// candidates that (a) have a usable artifact for this platform and (b) are newer +// than the installed version, then return the newest by the base-aware rule. +export const resolveNightlyUpdate = async ( + currentVersion: string, + platformKey: string, + fetchFn: FetchFn, +): Promise => { + const [nightly, stable] = await Promise.all([ + fetchManifest(fetchFn, READEST_NIGHTLY_UPDATER_FILE), + fetchManifest(fetchFn, READEST_UPDATER_FILE), + ]); + const sources: Array<[UpdateManifest | null, string]> = [ + [nightly, READEST_NIGHTLY_UPDATER_FILE], + [stable, READEST_UPDATER_FILE], + ]; + const candidates: ResolvedNightlyUpdate[] = []; + for (const [manifest, endpoint] of sources) { + if (!manifest?.version) continue; + const entry = manifest.platforms?.[platformKey]; + if (!entry?.url || !entry?.signature) continue; // platform-eligibility filter + if (!isUpdateNewer(manifest.version, currentVersion)) continue; + candidates.push({ + endpoint, + version: manifest.version, + notes: manifest.notes, + pubDate: manifest.pub_date, + platformKey, + url: entry.url, + signature: entry.signature, + }); + } + if (candidates.length === 0) return null; + candidates.sort((a, b) => (isUpdateNewer(a.version, b.version) ? -1 : 1)); + return candidates[0]!; +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm test src/__tests__/helpers/updater.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/helpers/updater.ts src/__tests__/helpers/updater.test.ts +git commit -m "feat(updater): nightly manifest resolution (filter-then-compare)" +``` + +--- + +### Task C2: Channel-aware `checkForAppUpdates` + +**Files:** +- Modify: `src/helpers/updater.ts` (`checkForAppUpdates`, ~line 37) +- Modify: `src/components/UpdaterWindow.tsx` (`setUpdaterWindowVisible` payload — extended in Task D4) +- Test: `src/__tests__/helpers/updater.test.ts` + +The channel comes from settings. `checkForAppUpdates` already takes `(_, isAutoCheck)`. Add an optional `updateChannel` parameter (the callers in `page.tsx` pass `settings.updateChannel`) so the helper stays pure/testable. + +- [ ] **Step 1: Write the failing test** + +Add to `src/__tests__/helpers/updater.test.ts` (inside the `checkForAppUpdates` describe), and add `arch` to the os mock at the top: + +```typescript +// add near the other mocks: +const mockOsArch = vi.fn(); +vi.mock('@tauri-apps/plugin-os', () => ({ + type: () => mockOsType(), + arch: () => mockOsArch(), +})); +``` + +```typescript + test('nightly channel resolves and opens the updater window', async () => { + const past = Date.now() - 86400 * 1000 - 1000; + localStorage.setItem('lastAppUpdateCheck', past.toString()); + mockOsType.mockReturnValue('macos'); + mockOsArch.mockReturnValue('aarch64'); + mockAppVersion = '0.11.4'; + mockTauriFetch.mockImplementation(async (url: string) => + url.includes('nightly') + ? { ok: true, json: async () => ({ version: '0.11.4-2026061406', platforms: { 'darwin-aarch64': { url: 'u', signature: 's' } } }) } + : { ok: true, json: async () => ({ version: '0.11.4', platforms: { 'darwin-aarch64': { url: 'u', signature: 's' } } }) }, + ); + mockIsTauriAppPlatform = true; + + const result = await checkForAppUpdates(dummyTranslate, false, 'nightly'); + + expect(result).toBe(true); + expect(mockCheck).not.toHaveBeenCalled(); // isolated from Tauri check() + expect(mockSetUpdaterWindowVisible).toHaveBeenCalled(); + }); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pnpm test src/__tests__/helpers/updater.test.ts` +Expected: FAIL — `checkForAppUpdates` ignores the 3rd arg / still calls Tauri path. + +- [ ] **Step 3: Implement the channel branch** + +In `src/helpers/updater.ts`, change the signature and add the nightly branch. Update imports to add the tauri http fetch and os arch: + +```typescript +import { type as osType, arch as osArch } from '@tauri-apps/plugin-os'; +import { fetch as tauriFetch } from '@tauri-apps/plugin-http'; +``` + +Change the function: + +```typescript +export const checkForAppUpdates = async ( + _: TranslationFunc, + isAutoCheck = true, + updateChannel: 'stable' | 'nightly' = 'stable', +): Promise => { + const lastCheck = localStorage.getItem(LAST_CHECK_KEY); + const now = Date.now(); + if (isAutoCheck && lastCheck && now - parseInt(lastCheck, 10) < CHECK_UPDATE_INTERVAL_SEC * 1000) + return false; + localStorage.setItem(LAST_CHECK_KEY, now.toString()); + + console.log('Checking for updates', { updateChannel }); + const OS_TYPE = osType(); + + if (updateChannel === 'nightly') { + const platformKey = getNightlyPlatformKey( + OS_TYPE, + osArch(), + Boolean((window as { __READEST_IS_PORTABLE?: boolean }).__READEST_IS_PORTABLE), + Boolean((window as { __READEST_IS_APPIMAGE?: boolean }).__READEST_IS_APPIMAGE), + ); + if (!platformKey) return false; + const resolved = await resolveNightlyUpdate(getAppVersion(), platformKey, tauriFetch as never); + if (resolved) { + setUpdaterWindowVisible(true, resolved.version, getAppVersion(), true, resolved); + return true; + } + return false; + } + + if (['macos', 'windows', 'linux'].includes(OS_TYPE)) { + // ...existing stable desktop branch unchanged... +``` + +Keep the rest of the stable branch (`macos/windows/linux` + `android`) exactly as it is today. + +Note on the portable flag: confirm the global used to detect the portable build (search `__READEST_IS_PORTABLE` / `isPortableApp` in `src/services/nativeAppService.ts`). If the portable build is detected via a different global, use that one. The AppImage global `__READEST_IS_APPIMAGE` is confirmed in `nativeAppService.ts:554`. + +- [ ] **Step 4: Update the callers** + +In `src/app/library/page.tsx` (~line 374) and `src/app/reader/page.tsx` (~line 30), pass the channel. Change: + +```typescript +if (appService?.hasUpdater && settings.autoCheckUpdates) { + checkForAppUpdates(_, true); +} +``` + +to: + +```typescript +if (appService?.hasUpdater && settings.autoCheckUpdates) { + checkForAppUpdates(_, true, settings.updateChannel); +} +``` + +Also update the manual-check caller in `src/components/AboutWindow.tsx` (the `handleCheckUpdate` → `checkForAppUpdates(_, false)`) to `checkForAppUpdates(_, false, settings.updateChannel)` (obtain `settings` from `useSettingsStore` if not already in scope). + +- [ ] **Step 5: Run tests + lint** + +Run: `pnpm test src/__tests__/helpers/updater.test.ts && pnpm lint` +Expected: PASS. (The `setUpdaterWindowVisible` 5th argument is added in Task D4; until then TS may flag the extra arg — implement Task D4 before the final `pnpm lint`, or land C2+D4 together.) + +- [ ] **Step 6: Commit** + +```bash +git add src/helpers/updater.ts src/__tests__/helpers/updater.test.ts src/app/library/page.tsx src/app/reader/page.tsx src/components/AboutWindow.tsx +git commit -m "feat(updater): channel-aware checkForAppUpdates (isolated nightly check)" +``` + +--- + +## Phase D — Install + +### Task D1: Rust `verify_update_signature` command + +**Files:** +- Modify: `src-tauri/Cargo.toml` (add `minisign-verify`) +- Modify: `src-tauri/src/nightly_update.rs` (add the command) +- Modify: `src-tauri/src/lib.rs` (register) + +- [ ] **Step 1: Add the dependency** + +In `src-tauri/Cargo.toml` `[dependencies]`: + +```toml +minisign-verify = "0.2" +``` + +- [ ] **Step 2: Implement the command** + +Append to `src-tauri/src/nightly_update.rs`: + +```rust +use minisign_verify::{PublicKey, Signature}; +use std::fs; +use tauri::command; + +/// Verify a downloaded artifact against a minisign signature using the embedded +/// updater public key. `pub_key` is the base64 blob from tauri.conf.json +/// `updater.pubkey` (the same format the Tauri updater consumes). `signature` is +/// the contents of the artifact's `.sig` file. +#[command] +pub async fn verify_update_signature(path: String, signature: String, pub_key: String) -> bool { + let decoded_key = match String::from_utf8( + base64_decode(&pub_key).unwrap_or_default(), + ) { + Ok(k) => k, + Err(_) => return false, + }; + let public_key = match PublicKey::from_base64(decoded_key.lines().last().unwrap_or("")) { + Ok(k) => k, + Err(_) => return false, + }; + let sig = match Signature::decode(&signature) { + Ok(s) => s, + Err(_) => return false, + }; + let data = match fs::read(&path) { + Ok(d) => d, + Err(_) => return false, + }; + public_key.verify(&data, &sig, false).is_ok() +} + +fn base64_decode(s: &str) -> Option> { + use base64::Engine; + base64::engine::general_purpose::STANDARD.decode(s).ok() +} +``` + +Note: `base64` is already an indirect dependency via Tauri; if `cargo build` reports it is not a direct dependency, add `base64 = "0.22"` to `[dependencies]`. The Tauri `updater.pubkey` is a base64 of the minisign public-key file text (a 2-line `untrusted comment` + key), which is why we base64-decode then take the last line. Verify this matches `verify_signature` in `tauri-plugin-updater-2.10.1/src/updater.rs:1453` during implementation and adjust the decode if the installed crate version differs. + +- [ ] **Step 3: Register the command** + +In `src-tauri/src/lib.rs`, inside `tauri::generate_handler![ ... ]` (after `clip_url::clip_url,` at line 292), add: + +```rust + nightly_update::verify_update_signature, +``` + +- [ ] **Step 4: Build to verify it compiles** + +Run: `pnpm clippy:check` +Expected: PASS (no clippy errors in `nightly_update.rs`). + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/Cargo.toml src-tauri/src/nightly_update.rs src-tauri/src/lib.rs +git commit -m "feat(updater): verify_update_signature Rust command (minisign)" +``` + +--- + +### Task D2: Rust `install_nightly_update` command (desktop) + +**Files:** +- Modify: `src-tauri/src/nightly_update.rs` +- Modify: `src-tauri/src/lib.rs` (register, desktop-gated) + +- [ ] **Step 1: Implement the command** + +Append to `src-tauri/src/nightly_update.rs`: + +```rust +#[cfg(desktop)] +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NightlyProgress { + pub event: String, // "started" | "progress" | "finished" + pub downloaded: u64, + pub content_length: u64, +} + +/// Drives the Tauri updater against a single nightly/stable manifest endpoint +/// with the base-aware comparator, then downloads + installs + relaunches. +/// Reuses Tauri's minisign verification and native installers (.app.tar.gz on +/// macOS, NSIS on Windows). Progress is streamed to the JS dialog over `channel`. +#[cfg(desktop)] +#[command] +pub async fn install_nightly_update( + app: tauri::AppHandle, + endpoint: String, + channel: tauri::ipc::Channel, +) -> std::result::Result<(), String> { + use tauri::Url; + use tauri_plugin_updater::UpdaterExt; + + let url = Url::parse(&endpoint).map_err(|e| e.to_string())?; + let updater = app + .updater_builder() + .endpoints(vec![url]) + .map_err(|e| e.to_string())? + .version_comparator(|current, release| { + is_update_newer(&release.version.to_string(), ¤t.to_string()) + }) + .build() + .map_err(|e| e.to_string())?; + + let update = updater.check().await.map_err(|e| e.to_string())?; + let Some(update) = update else { + return Err("no update available".into()); + }; + + let mut downloaded: u64 = 0; + let ch = channel.clone(); + update + .download_and_install( + move |chunk, total| { + downloaded += chunk as u64; + let _ = ch.send(NightlyProgress { + event: "progress".into(), + downloaded, + content_length: total.unwrap_or(0), + }); + }, + move || { + let _ = channel.send(NightlyProgress { + event: "finished".into(), + downloaded: 0, + content_length: 0, + }); + }, + ) + .await + .map_err(|e| e.to_string())?; + + app.restart(); +} +``` + +Note: confirm the `UpdaterBuilder` method names (`endpoints`, `version_comparator`, `build`), the `version_comparator` closure signature `(Version, RemoteRelease) -> bool`, and `download_and_install(on_chunk: Fn(usize, Option), on_finish: Fn())` against `tauri-plugin-updater-2.10.1` (paths surfaced in the spec review: `updater.rs:184,197`, `commands.rs:67`). `app.restart()` diverges (never returns). + +- [ ] **Step 2: Register the command (desktop only)** + +In `src-tauri/src/lib.rs`, inside `tauri::generate_handler![ ... ]`, add: + +```rust + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + nightly_update::install_nightly_update, +``` + +- [ ] **Step 3: Build** + +Run: `pnpm clippy:check` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src-tauri/src/nightly_update.rs src-tauri/src/lib.rs +git commit -m "feat(updater): install_nightly_update Rust command (Tauri updater, custom endpoint)" +``` + +--- + +### Task D3: JS bridge wrappers + +**Files:** +- Modify: `src/utils/bridge.ts` + +- [ ] **Step 1: Add the wrappers** + +In `src/utils/bridge.ts`, add (these call MAIN-APP commands — no `plugin:native-bridge|` prefix — like `download_file`): + +```typescript +import { Channel } from '@tauri-apps/api/core'; + +export async function verifyUpdateSignature( + path: string, + signature: string, + pubKey: string, +): Promise { + return invoke('verify_update_signature', { path, signature, pubKey }); +} + +export interface NightlyProgress { + event: 'started' | 'progress' | 'finished'; + downloaded: number; + contentLength: number; +} + +export async function installNightlyUpdate( + endpoint: string, + onProgress?: (p: NightlyProgress) => void, +): Promise { + const channel = new Channel(); + if (onProgress) channel.onmessage = onProgress; + await invoke('install_nightly_update', { endpoint, channel }); +} +``` + +(`invoke` is already imported at the top of `bridge.ts`. Confirm `Channel` import path — `@tauri-apps/api/core` — matches the version used elsewhere, e.g. `src/utils/transfer.ts`.) + +- [ ] **Step 2: Type-check** + +Run: `pnpm lint` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/utils/bridge.ts +git commit -m "feat(updater): JS bridge wrappers for verify + install nightly" +``` + +--- + +### Task D4: UpdaterWindow nightly routing + UI states + +**Files:** +- Modify: `src/components/UpdaterWindow.tsx` + +This task wires the resolved winner through the window, routes install per platform, adds the signature-verify gate to the custom flows, and improves UI states. + +- [ ] **Step 1: Extend the event payload + signature** + +In `src/components/UpdaterWindow.tsx`, change `setUpdaterWindowVisible` to accept the resolved update and forward it: + +```typescript +import type { ResolvedNightlyUpdate } from '@/helpers/updater'; +import { verifyUpdateSignature, installNightlyUpdate, installPackage } from '@/utils/bridge'; +import { READEST_UPDATER_PUBKEY } from '@/services/constants'; + +export const setUpdaterWindowVisible = ( + visible: boolean, + latestVersion: string, + lastVersion?: string, + checkUpdate = true, + nightlyUpdate?: ResolvedNightlyUpdate, +) => { + const dialog = document.getElementById('updater_window'); + if (dialog) { + const event = new CustomEvent('setDialogVisibility', { + detail: { visible, latestVersion, lastVersion, checkUpdate, nightlyUpdate }, + }); + dialog.dispatchEvent(event); + } +}; +``` + +Thread `nightlyUpdate` through the `UpdaterWindow` component's event handler and pass it into `UpdaterContent` as a prop (mirror the existing `latestVersion`/`lastVersion` wiring in the `handleCustomEvent` / `useState` / JSX at lines 568-617). + +- [ ] **Step 2: Build the nightly `GenericUpdate` when a resolved winner is present** + +In `UpdaterContent`, add a `nightlyUpdate?: ResolvedNightlyUpdate` prop and a helper. The platform keys that Tauri's updater installs (macOS, Windows-NSIS) route to `installNightlyUpdate`; the rest verify + use the existing custom install. Add this builder and call it from the `checkForUpdates` effect when `nightlyUpdate` is set: + +```typescript +const TAURI_UPDATER_KEYS = new Set([ + 'darwin-aarch64', + 'darwin-x86_64', + 'windows-x86_64', + 'windows-aarch64', + 'linux-x86_64', + 'linux-aarch64', +]); + +const buildNightlyUpdate = (n: ResolvedNightlyUpdate): GenericUpdate => ({ + currentVersion, + version: n.version, + date: n.pubDate, + body: n.notes, + downloadAndInstall: async (onEvent) => { + if (TAURI_UPDATER_KEYS.has(n.platformKey)) { + // macOS / Windows-NSIS: Tauri updater (verify + install + relaunch). + let total = 0; + await installNightlyUpdate(n.endpoint, (p) => { + if (p.event === 'progress') { + if (!total && p.contentLength) { + total = p.contentLength; + onEvent?.({ event: 'Started', data: { contentLength: total } }); + } + onEvent?.({ event: 'Progress', data: { chunkLength: p.downloaded } }); + } else if (p.event === 'finished') { + onEvent?.({ event: 'Finished' }); + } + }); + return; + } + // Windows-portable / Linux-AppImage / Android: download, verify, install. + const fileName = n.url.split('/').pop() || `Readest_${n.version}`; + const filePath = await appService!.resolveFilePath(fileName, 'Cache'); + await downloadWithProgress(n.url, filePath, onEvent); + const ok = await verifyUpdateSignature(filePath, n.signature, READEST_UPDATER_PUBKEY); + if (!ok) { + console.error('Nightly signature verification failed; aborting install'); + throw new Error('Signature verification failed'); + } + if (n.platformKey.startsWith('android')) { + const res = await installPackage({ path: filePath }); + if (!res.success) console.error('Failed to install APK:', res.error); + } else if (n.platformKey.includes('appimage')) { + const chmod = Command.create('chmod-appimage', ['+x', filePath]); + await chmod.execute(); + const launch = Command.create('launch-appimage', [filePath]); + await launch.spawn(); + setTimeout(async () => { await exit(0); }, 500); + } else { + // windows portable + const command = Command.create('start-readest', ['/C', 'start', '', filePath]); + await command.spawn(); + setTimeout(async () => { await exit(0); }, 500); + } + }, +}); +``` + +Note: `downloadWithProgress` already exists in this file (lines 174-208). `resolveFilePath`, `Command`, `exit`, `installPackage` are already imported/used. For Windows-portable the existing code writes into the executable dir (lines 220-222); reuse that exact path logic if the portable updater requires replacing the running exe in place rather than Cache. + +- [ ] **Step 3: Route the effect** + +In the `checkForUpdates` effect (lines 286-300), add a nightly branch at the top: + +```typescript + const checkForUpdates = async () => { + if (nightlyUpdate) { + setUpdate(buildNightlyUpdate(nightlyUpdate)); + return; + } + const OS_TYPE = osType(); + // ...existing stable routing unchanged... + }; +``` + +- [ ] **Step 4: Friendly nightly version + error state** + +Where the dialog renders the version (lines 440-444, the "Readest {{newVersion}} is available" copy), render a nightly stamp in a human form. Add a helper and use it for `newVersion` display: + +```typescript +const formatVersionLabel = (v: string): string => { + const m = v.match(/^(\d+\.\d+\.\d+)-(\d{4})(\d{2})(\d{2})(\d{2})$/); + if (!m) return v; + const [, base, y, mo, d, h] = m; + const date = new Date(Number(y), Number(mo) - 1, Number(d)); + return `Nightly · ${base} (${date.toLocaleDateString()}, ${h}:00)`; +}; +``` + +Use `formatVersionLabel(newVersion)` in the displayed strings (keep the raw value for `semver`/logic). Also add a simple error state: when `checkUpdate` is true, `nightlyUpdate` is undefined, and a fetch failed, show `_('Failed to check for updates')` instead of leaving the dialog blank (set an `error` state in the relevant effect and render it in place of the skeleton). + +- [ ] **Step 5: Verify + lint** + +Run: `pnpm lint && pnpm test src/__tests__/helpers/updater.test.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/UpdaterWindow.tsx +git commit -m "feat(updater): nightly install routing, verify gate, UI states" +``` + +--- + +## Phase E — CI + +### Task E1: `.github/workflows/nightly.yml` + +**Files:** +- Create: `.github/workflows/nightly.yml` (repo root, NOT under apps/) + +- [ ] **Step 1: Create the workflow** + +This mirrors `release.yml`'s build matrix but (1) stamps a nightly version, (2) patches `package.json` AFTER the Android `git checkout .`, (3) publishes to R2 only via per-platform fragments + a final assemble job. Create `.github/workflows/nightly.yml`: + +```yaml +# Nightly builds. Mirrors the build matrix of release.yml (keep cert/NDK/toolchain +# bumps in sync between the two). Publishes to R2 only — no GitHub release. +name: Nightly Readest + +on: + schedule: + - cron: '0 22 * * *' # 22:00 UTC = 06:00 GMT+8 + workflow_dispatch: + +permissions: + contents: read + +jobs: + compute-version: + runs-on: ubuntu-latest + outputs: + nightly_version: ${{ steps.v.outputs.nightly_version }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: main + - id: v + run: | + BASE=$(node -p "require('./apps/readest-app/package.json').version") + STAMP=$(TZ=Asia/Shanghai date +%Y%m%d%H) + echo "nightly_version=${BASE}-${STAMP}" >> "$GITHUB_OUTPUT" + + build: + needs: compute-version + strategy: + fail-fast: false + matrix: + config: + - { os: ubuntu-latest, release: android, rust_target: 'aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android' } + - { os: ubuntu-22.04, release: linux, arch: x86_64, rust_target: x86_64-unknown-linux-gnu } + - { os: ubuntu-22.04-arm, release: linux, arch: aarch64, rust_target: aarch64-unknown-linux-gnu } + - { os: macos-latest, release: macos, arch: aarch64, rust_target: 'x86_64-apple-darwin,aarch64-apple-darwin', args: '--target universal-apple-darwin' } + - { os: windows-latest, release: windows, arch: x86_64, rust_target: x86_64-pc-windows-msvc, args: '--target x86_64-pc-windows-msvc --bundles nsis' } + - { os: windows-latest, release: windows, arch: aarch64, rust_target: aarch64-pc-windows-msvc, args: '--target aarch64-pc-windows-msvc --bundles nsis' } + runs-on: ${{ matrix.config.os }} + timeout-minutes: 60 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: main + + - name: initialize git submodules + run: git submodule update --init --recursive + + - name: setup pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 + - name: setup node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 24 + cache: pnpm + + - name: setup Java (android) + if: matrix.config.release == 'android' + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: { distribution: 'zulu', java-version: '17' } + - name: setup Android SDK (android) + if: matrix.config.release == 'android' + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4 + - name: install NDK (android) + if: matrix.config.release == 'android' + run: sdkmanager "ndk;28.2.13676358" + + - name: install dependencies + run: pnpm install --frozen-lockfile --prefer-offline + - name: setup vendors + run: pnpm --filter @readest/readest-app setup-vendors + + - name: install Rust stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: { targets: '${{ matrix.config.rust_target }}' } + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: { key: 'nightly-${{ matrix.config.os }}-${{ matrix.config.release }}-${{ matrix.config.arch }}' } + + - name: install ubuntu deps + if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android' + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1 libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1 libjavascriptcoregtk-4.1-dev gir1.2-javascriptcoregtk-4.1 gir1.2-webkit2-4.1 libappindicator3-dev librsvg2-dev patchelf xdg-utils + + - name: create .env.local + run: | + echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local + echo "NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}" >> .env.local + echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env.local + echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local + echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local + cp .env.local apps/readest-app/.env.local + + - name: install rclone + run: | + sudo apt-get update && sudo apt-get install -y rclone || choco install rclone -y || brew install rclone + shell: bash + - name: configure rclone + shell: bash + run: | + mkdir -p ~/.config/rclone + cat > ~/.config/rclone/rclone.conf < keystore.properties + echo "password=${{ secrets.ANDROID_KEY_PASSWORD }}" >> keystore.properties + base64 -d <<< "${{ secrets.ANDROID_KEY_BASE64 }}" > $RUNNER_TEMP/keystore.jks + echo "storeFile=$RUNNER_TEMP/keystore.jks" >> keystore.properties + popd + apk_path=src-tauri/gen/android/app/build/outputs/apk/universal/release + pnpm tauri android build + cp ${apk_path}/app-universal-release.apk Readest_${version}_universal.apk + pnpm tauri android build -t aarch64 + cp ${apk_path}/app-universal-release.apk Readest_${version}_arm64.apk + pnpm tauri signer sign Readest_${version}_universal.apk + pnpm tauri signer sign Readest_${version}_arm64.apk + + - name: build desktop + if: matrix.config.release != 'android' + shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + NODE_OPTIONS: '--max-old-space-size=8192' + run: | + version="${{ needs.compute-version.outputs.nightly_version }}" + node -e "const f='apps/readest-app/package.json';const j=require('./'+f);j.version='${version}';require('fs').writeFileSync(f, JSON.stringify(j,null,2)+'\n')" + cd apps/readest-app + pnpm tauri build ${{ matrix.config.args }} + + - name: upload artifacts + fragment to R2 + shell: bash + run: | + version="${{ needs.compute-version.outputs.nightly_version }}" + base="r2:readest-releases/nightly/${version}" + dest="./nightly-out"; mkdir -p "$dest/frag" + # Collect this leg's artifacts (.apk/.dmg/.app.tar.gz/.AppImage/nsis + .sig) + # into $dest, then build a JSON fragment of {platforms{key:{url,signature}}} + # for the keys this leg produced. (Implementer: glob the produced bundle + # paths under apps/readest-app/src-tauri/target/**/bundle/ and the android + # apks; compute the download.readest.com URLs as + # https://download.readest.com/nightly/${version}/.) + # ... assemble $dest/frag/${{ matrix.config.release }}-${{ matrix.config.arch }}.json ... + rclone copy "$dest" "$base/" --exclude "frag/**" + rclone copy "$dest/frag" "$base/manifest-fragments/" + + assemble-manifest: + needs: [compute-version, build] + if: ${{ always() && needs.build.result != 'cancelled' }} + runs-on: ubuntu-latest + steps: + - name: install rclone + run: sudo apt-get update && sudo apt-get install -y rclone jq + - name: configure rclone + run: | + mkdir -p ~/.config/rclone + cat > ~/.config/rclone/rclone.conf </dev/null)" ]; then + echo "::error::no manifest fragments — all build legs failed"; exit 1 + fi + # Merge fragment .platforms into one manifest from the SUCCEEDED legs. + jq -s "{version: \"${version}\", pub_date: (now | todate), notes: \"Nightly build\", platforms: (map(.platforms) | add)}" ./frag/*.json > latest.json + # Atomic promote: upload to a temp key, then server-side move. + rclone copyto latest.json "$base/latest.json.tmp" + rclone moveto "$base/latest.json.tmp" "$base/latest.json" + - name: prune old nightly folders (keep newest 7) + run: | + base="r2:readest-releases/nightly" + mapfile -t dirs < <(rclone lsf "$base/" --dirs-only | sed 's:/$::' | sort) + count=${#dirs[@]} + if [ "$count" -gt 7 ]; then + for d in "${dirs[@]:0:$((count-7))}"; do + echo "pruning $d"; rclone purge "$base/$d" + done + fi + - name: notify on failure + if: failure() + run: echo "::error::Nightly assemble failed — manifest not promoted." +``` + +Note: the per-leg "collect artifacts + build fragment" shell is intentionally sketched — during implementation, glob the exact bundle output paths (`apps/readest-app/src-tauri/target/${rust_target}/release/bundle/...` for macnsis/appimage, `target/.../*.app.tar.gz` for the macOS updater bundle) and the Android `.apk`/`.sig`, then emit a fragment JSON keyed by the Tauri platform keys (`darwin-aarch64`, `windows-x86_64`, `linux-x86_64-appimage`, `android-arm64`, …) whose `signature` is the `.sig` contents and `url` is `https://download.readest.com/nightly/${version}/`. Cross-check the produced filenames against `release.yml` and `UpdaterWindow.tsx`'s expected keys. + +- [ ] **Step 2: Validate workflow syntax** + +Run: `node -e "require('js-yaml')" 2>/dev/null && npx --yes js-yaml .github/workflows/nightly.yml >/dev/null && echo OK || echo "validate YAML manually"` +Expected: OK (or validate via the GitHub Actions UI / `act`). + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/nightly.yml +git commit -m "ci: nightly build workflow (R2-only, fragment-assembled manifest)" +``` + +- [ ] **Step 4: Dry-run via workflow_dispatch** + +After merge, trigger the workflow manually (Actions → Nightly Readest → Run workflow on `main`) and confirm: a `nightly//` folder + `nightly/latest.json` appear in R2 with all platform keys, and that on a same-base day the in-app nightly check offers the build. (This is a post-merge validation, not a local step.) + +--- + +## Phase F — Final verification + +- [ ] **Step 1: Full unit suite** + +Run: `pnpm test` +Expected: PASS (including the new `version.test.ts` and updated `updater.test.ts`). + +- [ ] **Step 2: Lint + types** + +Run: `pnpm lint` +Expected: PASS. + +- [ ] **Step 3: Rust checks** + +Run: `pnpm fmt:check && pnpm clippy:check && pnpm test:rust` +Expected: PASS (including `nightly_update::tests::matrix`). + +- [ ] **Step 4: Manual smoke (desktop dev)** + +Run: `pnpm tauri dev`, enable Settings → "Nightly Builds (Unstable)", trigger a manual check. With no nightly manifest published yet, confirm it reports up-to-date / handles the fetch gracefully (no blank dialog). Full end-to-end install is validated post-merge via the workflow dry-run (Task E1 Step 4). + +--- + +## Self-review notes + +- **Spec coverage:** comparator (A1/A2), setting + toggle (B1/B2), isolated dual-manifest check with filter-then-compare (C1/C2), single-source decision passed to the window (C2/D4), sig verification everywhere — Tauri-built-in for mac/NSIS + minisign command for portable/AppImage/Android (D1/D2/D4), macOS `.app.tar.gz` auto-replace via Tauri updater (D2/D4), CI R2-only with version-patch-after-checkout + fragment assembly + atomic promote + prune + failure alert (E1), friendly version + error UI states (D4). Android versionCode left Tauri-derived per the owner's correction (no task needed). +- **Known verification points** (flagged inline, not placeholders): the `tauri-plugin-updater` 2.10.1 `UpdaterBuilder`/`download_and_install` signatures (D2), the `updater.pubkey` base64 decode shape for minisign (D1), the exact portable-build global (`__READEST_IS_PORTABLE`) in C2, and the per-leg artifact glob/fragment shell in E1. diff --git a/apps/readest-app/docs/superpowers/specs/2026-06-14-nightly-update-channel-design.md b/apps/readest-app/docs/superpowers/specs/2026-06-14-nightly-update-channel-design.md new file mode 100644 index 00000000..dff2a117 --- /dev/null +++ b/apps/readest-app/docs/superpowers/specs/2026-06-14-nightly-update-channel-design.md @@ -0,0 +1,264 @@ +# Nightly Update Channel — Design + +Date: 2026-06-14 +Status: Approved (post dual-voice review; ready for implementation plan) + +## Goal + +Add an opt-in **nightly** build channel to Readest's in-app updater for +Android, Windows, macOS, and Linux. A GitHub Actions job builds nightly +packages daily at 06:00 GMT+8 and uploads them plus a manifest to the +Cloudflare R2 release bucket (R2 only — no GitHub release). Users who opt in +(via a setting) can auto-check and manually check for nightly updates. + +The **auto-check is isolated** from Tauri's built-in updater (Tauri's JS +`check()` is hardwired to the stable endpoint and uses plain semver, neither of +which fits the nightly channel). The **install** reuses Tauri's verified updater +where it can (macOS, Windows-NSIS) and the existing custom flows + a new +signature-verify gate elsewhere (Windows-portable, Linux-AppImage, Android). + +Nightly version format: `-`, e.g. `0.11.4-2026061406` +(stamped in GMT+8, hour precision). Built from **main**. + +## Non-goals + +- No iOS nightly (`hasUpdater` is already false on iOS). +- No nightly via Play Store / App Store builds (gated out by `hasUpdater`). +- No automatic downgrade when switching nightly → stable on the same base. +- No refactor of the existing `release.yml` (keep the stable pipeline untouched). + +## 1. Version comparison rule (core) + +Plain semver is wrong here: it ranks a nightly `0.11.4-2026061406` *below* +stable `0.11.4` (prerelease < release), which would offer a downgrade. We use a +base-aware comparator. + +``` +parseUpdateVersion(v): + base = "X.Y.Z" (semver core) + stamp = the prerelease ONLY IF it is exactly 10 ASCII digits (YYYYMMDDHH), else null + isNightly = stamp != null + +isUpdateNewer(candidate, current) -> boolean: + if base(candidate) != base(current): + return semverCompareCore(candidate, current) > 0 // compare X.Y.Z cores only + // same base: + if candidate.isNightly && !current.isNightly: return true // nightly built after stable + if !candidate.isNightly && current.isNightly: return false // no same-base downgrade + if candidate.isNightly && current.isNightly: return candidate.stamp > current.stamp + return false // both stable, same base +``` + +Notes from review: +- A non-pure-10-digit prerelease (`-rc.1`, `-beta`, `-`, `-2026`) → `stamp = + null` (treated as stable-ish core). Empty/undefined version → treated as "not + newer" (never offered). +- `semverCompareCore` compares the `X.Y.Z` cores only (strip prerelease first), + so `0.11.5 > 0.11.4-…` and `0.11.5-… > 0.11.4`. + +### Dual implementation (single rule, shared test matrix) + +The rule is needed in two places: +- **TypeScript** (`src/utils/version.ts`) — the isolated JS check and the + Android/portable/AppImage routing. +- **Rust** — the `version_comparator` passed to Tauri's `UpdaterBuilder` for the + macOS / Windows-NSIS install path (Tauri's default comparator is plain semver + and would *reject* a same-base nightly). + +Both implementations are validated against the **same** test-vector table below. +Drift is the named risk; the shared table is the mitigation. + +| candidate | current | isUpdateNewer | rationale | +|---|---|---|---| +| `0.11.5` | `0.11.4-2026061406` | true | stable surpasses nightly (headline requirement) | +| `0.11.4-2026061506` | `0.11.4-2026061406` | true | newer nightly | +| `0.11.4-2026061406` | `0.11.4-2026061506` | false | older nightly | +| `0.11.4` | `0.11.4-2026061406` | false | no same-base stable downgrade | +| `0.11.4-2026061406` | `0.11.4` | true | stable user on nightly channel gets nightly | +| `0.11.5-2026070106` | `0.11.4` | true | higher-base nightly beats stable | +| `0.11.4` | `0.11.4` | false | identical stable | +| `0.11.4-2026061406` | `0.11.4-2026061406` | false | identical nightly | +| `0.11.4-rc.1` | `0.11.4` | false | non-stamp prerelease → stable-ish, not newer | +| `` / undefined | any | false | malformed never offered | + +## 2. Channel selection + isolated check + +New system setting `updateChannel: 'stable' | 'nightly'`, default `'stable'`: +- Type: `src/types/settings.ts` (`SystemSettings`). +- Default: `src/services/constants.ts`. +- UI: a toggle in `src/app/library/components/SettingsMenu.tsx` directly under + "Check Updates on Start", gated on `appService?.hasUpdater`. Label + **"Nightly Builds (Unstable)"** with a `description` line (e.g. "Early daily + builds; may be unstable") using the existing `MenuItem` description pattern. + Persists via `saveSysSettings(envConfig, 'updateChannel', ...)`. No separate + confirmation dialog (per decision — the "(Unstable)" label carries the warning). + +`checkForAppUpdates(_, isAutoCheck)` in `src/helpers/updater.ts` branches on +channel: +- **stable** → unchanged (Tauri `check()` desktop, custom Android fetch). +- **nightly** → isolated resolution (does NOT call Tauri `check()` to decide): + 1. Fetch `nightly/latest.json` AND stable `latest.json` (failures handled + independently; one missing manifest must not break the other). + 2. **Filter first, then compare** (review fix): for the current platform key, + drop any manifest that lacks a usable `platforms[key]` entry (URL + + signature). A stable `0.11.5` manifest missing the current platform must not + mask a valid nightly. + 3. Among eligible candidates, pick the winner by `isUpdateNewer` + (manifest-vs-manifest), and require `isUpdateNewer(winner.version, + installedVersion)`. + 4. Route to install (§3) with the winner's manifest URL + platform key. + +This **single resolution lives in `updater.ts`**; the resolved winner (endpoint +URL + platform key + version/notes) is passed into `UpdaterWindow` via the +existing `setUpdaterWindowVisible` event payload. `UpdaterWindow` no longer +re-fetches or re-decides with `semver.gt` (review fix: kills the dual-source +drift between `isUpdateNewer` and `semver.gt`). + +Both auto-check (throttled 24h, on start) and manual-check (About dialog) flow +through this. + +## 3. Install architecture + +| Platform | Nightly install | Signature verification | +|---|---|---| +| macOS | Tauri `UpdaterBuilder` (Rust cmd) → `.app.tar.gz` swap + relaunch | Tauri built-in (minisign) | +| Windows NSIS | Tauri `UpdaterBuilder` (Rust cmd) → NSIS install + relaunch | Tauri built-in (minisign) | +| Windows portable | existing custom JS: download `.exe` + launch + exit | **new** `verify_update_signature` (minisign) gate | +| Linux AppImage | existing custom JS: download AppImage + chmod + launch | **new** verify gate | +| Android | existing custom JS: download APK + `installPackage` | **new** verify gate | + +This matches how *stable* already splits platforms (Tauri `check()` is used for +macOS / Windows-NSIS; portable / AppImage / Android use custom JS flows). + +### Rust pieces (in `tauri-plugin-native-bridge` or a small updater module) + +1. `install_nightly_update(endpoint_url: String)` — builds + `app.updater_builder().endpoints([endpoint_url])?.version_comparator(is_update_newer)`, + then `check()` + `download_and_install()`, emitting progress events the dialog + subscribes to, then relaunch. Used for macOS + Windows-NSIS. Reuses Tauri's + minisign verification + native install. When the winner is the *stable* + manifest (stable surpassed the nightly), the same command is pointed at the + stable `latest.json` endpoint — the base-aware comparator confirms + `0.11.5 > 0.11.4-nightly` and installs. (This is why §2.4 of the prior draft — + "delegate to Tauri `check()`" — is replaced: we always drive a custom-endpoint + updater, never the default-endpoint `check()`.) +2. `verify_update_signature(path: String, signature: String, pub_key: String) -> + bool` — minisign verification (e.g. `minisign-verify` crate) of a downloaded + artifact against the embedded Tauri pubkey. Called by the custom JS flows + (portable / AppImage / Android) before launch/install. Abort install on failure. + +The embedded pubkey is the same one in `src-tauri/tauri.conf.json` `updater.pubkey`. + +## 4. Nightly manifest (`nightly/latest.json`) + +Same shape as stable `latest.json` — Tauri standard updater platform keys for +desktop, plus the custom keys the JS flows read: + +```json +{ + "version": "0.11.4-2026061406", + "pub_date": "2026-06-14T06:00:00+08:00", + "notes": "Nightly build. Recent: ", + "platforms": { + "darwin-aarch64": { "signature": "...", "url": ".../nightly/0.11.4-2026061406/Readest.app.tar.gz" }, + "darwin-x86_64": { "signature": "...", "url": "..." }, + "windows-x86_64": { "signature": "...", "url": ".../Readest_0.11.4-2026061406_x64-setup.nsis.zip" }, + "windows-aarch64": { "signature": "...", "url": "..." }, + "windows-x86_64-portable": { "signature": "...", "url": ".../Readest_0.11.4-2026061406_x64-portable.exe" }, + "windows-aarch64-portable": { "signature": "...", "url": "..." }, + "linux-x86_64-appimage": { "signature": "...", "url": ".../Readest_0.11.4-2026061406_x86_64.AppImage" }, + "linux-aarch64-appimage": { "signature": "...", "url": "..." }, + "android-universal": { "signature": "...", "url": ".../Readest_0.11.4-2026061406_universal.apk" }, + "android-arm64": { "signature": "...", "url": ".../Readest_0.11.4-2026061406_arm64.apk" } + } +} +``` + +The nightly build runs with `createUpdaterArtifacts: true` (already set), so the +desktop `.app.tar.gz` / NSIS / AppImage updater artifacts + `.sig` files are +produced exactly as in `release.yml`. + +## 5. CI: `.github/workflows/nightly.yml` + +- Triggers: `schedule: cron '0 22 * * *'` (22:00 UTC = 06:00 GMT+8) + `workflow_dispatch`. +- Compute version: checkout `main`; `BASE=$(node -p require .version)`; + `STAMP=$(TZ=Asia/Shanghai date +%Y%m%d%H)`; `NIGHTLY=$BASE-$STAMP`. + **Patch `apps/readest-app/package.json` version AFTER any `git checkout .`** + (review catch: the Android init step does `git checkout .` and would revert an + earlier patch). Never committed. +- Build matrix mirrors `release.yml` (android, linux x86_64, linux aarch64, macOS + universal, windows x86_64, windows aarch64). Sign every artifact with + `pnpm tauri signer sign`. Android `versionCode` stays Tauri-derived from the + base (sideload allows equal versionCode; see [[android-sideload-same-versioncode]]). +- Publish **R2 only**, race-free across the parallel matrix: + 1. Each matrix job uploads its artifacts (+ `.sig`) to + `r2:readest-releases/nightly//` and writes a per-platform manifest + fragment to `nightly//manifest-fragments/.json`. + 2. A final `assemble-manifest` job (`needs:` the matrix, `fail-fast:false`): + downloads fragments, composes `nightly/latest.json` from the **succeeded** + legs (partial-success allowed — one flaky leg must not block the channel), + **atomically promotes** the manifest (upload to a temp key then move/replace), + then prunes old `nightly//` folders keeping the newest 7 (sort by + stamp, prune *after* the new upload). + 3. On scheduled-run or leg failure, surface it (job failure + a notification + step) so a silently broken nightly is visible. +- Reuses existing secrets (`TAURI_SIGNING_*`, `ANDROID_KEY_*`, Apple signing, + `RELEASE_R2_*`, Next.js public env) and the `rclone` R2 config from + `upload-to-r2.yml`. +- **Drift note:** `nightly.yml` duplicates the `release.yml` build matrix. We + keep them separate (not refactoring the stable pipeline now) but add a header + comment cross-referencing `release.yml` so cert/NDK bumps are mirrored. + +## 6. Client constants + +``` +// src/services/constants.ts +export const READEST_NIGHTLY_UPDATER_FILE = + 'https://download.readest.com/nightly/latest.json'; +``` + +## 7. Files touched + +Client: +- `src/utils/version.ts` — `parseUpdateVersion`, `isUpdateNewer` (TS). +- `src/services/constants.ts` — `READEST_NIGHTLY_UPDATER_FILE`; default `updateChannel`. +- `src/types/settings.ts` — `updateChannel`. +- `src/helpers/updater.ts` — channel-aware check; dual-manifest resolution + (filter-then-compare); pass resolved winner to the window. +- `src/components/UpdaterWindow.tsx` — consume resolved winner (no re-decide); + nightly routing; loading + fetch-error states; friendly nightly version render + ("Nightly · 0.11.4 (Jun 14, 06:00)"); call `install_nightly_update` for + macOS/Win-NSIS; add verify gate to portable/AppImage/Android flows. +- `src/app/library/components/SettingsMenu.tsx` — "Nightly Builds (Unstable)" toggle. + +Rust: +- `src-tauri/plugins/tauri-plugin-native-bridge/` — `install_nightly_update` + (custom-endpoint `UpdaterBuilder`) + `verify_update_signature` commands; + `is_update_newer` (Rust mirror of the comparator); permissions/ACL entries. + +CI: +- `.github/workflows/nightly.yml`. + +Tests: +- `src/__tests__/utils/version.test.ts` — the shared comparator matrix (§1). +- Rust unit test for `is_update_newer` — the same matrix. +- `src/__tests__/helpers/updater.test.ts` — nightly branch: winner-nightly + routing, stable-surpasses routing, platform-eligibility filter (stable missing + current platform key → nightly still chosen), both-404, neither-newer-than-installed. + +## 8. Decision log (from dual-voice review, 2026-06-14) + +| Decision | Source | Outcome | +|---|---|---| +| Client-side signature verification on nightly install | USER CHALLENGE (Eng+Codex) | **Add** — Tauri built-in for mac/NSIS; new minisign command for portable/AppImage/Android | +| macOS install | taste (CEO) | **Reuse `.app.tar.gz` auto-replace** via Tauri updater (not DMG-open) | +| Desktop install mechanism | architecture confirm | **Reuse Tauri `UpdaterBuilder`** with custom endpoint + base-aware comparator | +| Opt-in friction | taste (Design) | **Toggle + "(Unstable)" label**, no confirmation dialog | +| Android versionCode collision | resolved by owner | **Non-issue** — sideload allows equal versionCode | +| Dual-manifest selection | Eng+Codex | **Filter by platform eligibility before comparing** | +| Channel decision duplicated | Eng | **Single source in `updater.ts`**; window consumes the resolved winner | +| Comparator malformed-stamp handling | Eng+Codex | **Pin stamp = pure 10 digits or null**; edge tests | +| CI version patch vs `git checkout .` | Codex | **Patch after checkout** | +| CI manifest assembly | Eng+Codex | **Fragments + atomic promote + partial-success + failure alert** | +| Cadence daily-from-main / R2-only / isolated | CEO+Codex reframe | **Kept** (user-specified; Codex concedes isolation is necessary) | +| nightly.yml ↔ release.yml duplication | CEO | **Keep separate** + cross-ref comment (don't refactor stable pipeline) | diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index bd8d2d91..c9a2177d 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -32,6 +32,7 @@ "test:pr:web": "pnpm test:pr:web:unit && pnpm test:browser && pnpm test:extension && pnpm build-browser-ext", "test:pr:tauri": "bash scripts/test-tauri.sh", "test:all": "pnpm test -- --watch=false && pnpm test:browser && bash scripts/test-tauri.sh", + "verify:nightly": "node scripts/nightly-verify-harness/serve.mjs", "bench": "node --experimental-strip-types --no-warnings bench/index.ts", "test:e2e": "wdio run wdio.conf.ts", "test:e2e:web": "playwright test", diff --git a/apps/readest-app/scripts/nightly-verify-harness/README.md b/apps/readest-app/scripts/nightly-verify-harness/README.md new file mode 100644 index 00000000..96d2b335 --- /dev/null +++ b/apps/readest-app/scripts/nightly-verify-harness/README.md @@ -0,0 +1,77 @@ +# Nightly updater — local verification harness + +Exercises the in-app nightly check (Tier 2 detection) and the signature gate +(Tier 4) on a desktop `pnpm tauri dev` build, without waiting on CI. + +Throwaway-signed fixtures only (the signing key was discarded). + +## What it can and can't prove +- ✅ **Detection (Tier 2)** — the app on the nightly channel fetches both + manifests, applies the comparator, offers the right version; the isolated check + never calls Tauri `check()`; error / up-to-date states render. +- ✅ **Verify-gate REJECT (Tier 4)** — a bad/garbage signature is refused. +- ✅ **Resolver decision (no GUI)** — the unit test `resolveNightlyUpdate — + harness scenarios` in `src/__tests__/helpers/updater.test.ts` runs the real + resolver against these manifest builders (`pnpm test src/__tests__/helpers/updater.test.ts`). +- ⚠️ **Full install / accept-valid** — needs the **real** signing key (CI only): + the app verifies against the production `READEST_UPDATER_PUBKEY`, so the + throwaway artifact correctly fails real-key verification if you click + "Download & Install". Accept-valid is covered by the Rust test + `pnpm test:rust` → `nightly_update::tests::verify_accepts_valid_signature`. +- On **macOS** the install path routes through Tauri's updater (darwin key), so + the custom verify-gate isn't hit from the UI — use the Tier 4 devtools snippet + below (or the Rust test) to exercise it directly. + +## Tier 2 — live detection + +1. Start the server: + ```bash + pnpm verify:nightly # or: node scripts/nightly-verify-harness/serve.mjs + ``` +2. Point the two manifest constants at the server. In + `src/services/constants.ts` temporarily change: + ```ts + export const READEST_NIGHTLY_UPDATER_FILE = 'http://127.0.0.1:8788/nightly/latest.json'; + // and + export const READEST_UPDATER_FILE = 'http://127.0.0.1:8788/releases/latest.json'; + ``` + (The app's HTTP capability already allows `http://*:*`, so localhost works.) +3. Run and opt in: + ```bash + pnpm tauri dev + ``` + Settings → toggle **Nightly Builds (Unstable)** → About → **Check Update**. + - Expect: **"Nightly · (Jan 1, 2099, 00:00)"** is available. + - Server log shows `GET /nightly/latest.json` **and** `/releases/latest.json` + (both fetched, then filtered/compared). + - **Error state:** stop the server (Ctrl-C), re-run Check Update → expect + "Failed to check for updates", not a blank pane. +4. **Cross-channel:** point `READEST_UPDATER_FILE` at + `http://127.0.0.1:8788/releases/latest-surpass.json` (stable = base, patch +1). + The offered version should switch to the **stable** `` — a higher-base + stable beats the nightly. Switch back and the nightly wins again. +5. **Revert the constants** when done: + ```bash + git checkout src/services/constants.ts + ``` + +## Tier 4 — verify-gate, directly (any platform) + +In the dev window devtools console (right-click → Inspect): + +```js +const path = '/apps/readest-app/scripts/nightly-verify-harness/artifacts/test.bin'; +const pubKey = 'dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEZFQTAxMjIzNUEwRkE0OUIKUldTYnBBOWFJeEtnL2x4Q3dKR3dSWVJCY3dLNXdCR1l4d1YyVkhaZUppOVVNVm1kOGprbU85bTMK'; +const goodSig = 'dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVTYnBBOWFJeEtnL3RvRC83dEJEUXZONVFZM1hranhKTUZxQzllR2lGWnNjckZMbCtOa3RXMi80aFdDYUNDUkdOa0NqUjJUQkZDL2dqaUVTeURlNzI0cW1BcUlZY2ZsOGcwPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzgxNDE0MzExCWZpbGU6bnYuYmluCkQzajlpbVZPOXVDYXdna2JBVWZ0TTE4K1d1cWdEYWVYQzVraGh4U1ZuOGNSTDZaOU5zV093OEVDajBvV0JydVV5VGY2K0tkb0hBbGJHYWprK0NsNUN3PT0K'; +const { invoke } = window.__TAURI_INTERNALS__; + +await invoke('verify_update_signature', { path, signature: goodSig, pubKey }); // → true +await invoke('verify_update_signature', { path, signature: 'AAAAgarbage', pubKey }); // → false +``` +Replace ``. (`pubKey` is the *throwaway* key that signed `test.bin`, not the +production key.) Tampering `test.bin` and re-running the good-sig call also → false. + +## Notes +- Nightly is stamped `-2099010100` so it's always newer than installed. +- `serve.mjs` reads the base version from `package.json` each request, so it stays + correct as the app version changes. diff --git a/apps/readest-app/scripts/nightly-verify-harness/artifacts/test.bin b/apps/readest-app/scripts/nightly-verify-harness/artifacts/test.bin new file mode 100644 index 00000000..f584519c --- /dev/null +++ b/apps/readest-app/scripts/nightly-verify-harness/artifacts/test.bin @@ -0,0 +1 @@ +readest-nightly-verify-test diff --git a/apps/readest-app/scripts/nightly-verify-harness/serve.mjs b/apps/readest-app/scripts/nightly-verify-harness/serve.mjs new file mode 100644 index 00000000..02bd4176 --- /dev/null +++ b/apps/readest-app/scripts/nightly-verify-harness/serve.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +// Local nightly-updater verification harness (Tier 2 detection + Tier 4 invoke). +// +// `pnpm verify:nightly` serves crafted nightly/stable manifests + a test artifact +// on http://127.0.0.1:8788 so the in-app nightly check can be exercised on a +// desktop `pnpm tauri dev` build WITHOUT waiting on CI. See ./README.md. +// +// The manifest builders are also imported by the unit test +// `src/__tests__/helpers/updater.test.ts` ("harness scenarios") so the real +// `resolveNightlyUpdate` is asserted against these exact manifest shapes. +// +// The artifact is signed with a THROWAWAY minisign key (private key discarded), +// so it proves DETECTION and the verify-gate REJECT path. Accept-valid needs the +// real signing key (CI) and is covered by the Rust test +// `nightly_update::tests::verify_accepts_valid_signature`. +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const HOST = '127.0.0.1'; +const PORT = 8788; +const ARTIFACT = path.join(__dirname, 'artifacts', 'test.bin'); +// scripts/nightly-verify-harness/ -> apps/readest-app/package.json +const PKG = path.join(__dirname, '..', '..', 'package.json'); + +// Throwaway fixtures (public; signed over artifacts/test.bin with a discarded key). +export const GOOD_SIG = + 'dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVTYnBBOWFJeEtnL3RvRC83dEJEUXZONVFZM1hranhKTUZxQzllR2lGWnNjckZMbCtOa3RXMi80aFdDYUNDUkdOa0NqUjJUQkZDL2dqaUVTeURlNzI0cW1BcUlZY2ZsOGcwPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzgxNDE0MzExCWZpbGU6bnYuYmluCkQzajlpbVZPOXVDYXdna2JBVWZ0TTE4K1d1cWdEYWVYQzVraGh4U1ZuOGNSTDZaOU5zV093OEVDajBvV0JydVV5VGY2K0tkb0hBbGJHYWprK0NsNUN3PT0K'; +export const BAD_SIG = 'AAAAdGhpcy1pcy1nYXJiYWdl'; + +export const ALL_KEYS = [ + 'darwin-aarch64', + 'darwin-x86_64', + 'windows-x86_64', + 'windows-aarch64', + 'windows-x86_64-portable', + 'windows-aarch64-portable', + 'linux-x86_64-appimage', + 'linux-aarch64-appimage', + 'android-universal', + 'android-arm64', +]; + +export const baseVersion = () => JSON.parse(fs.readFileSync(PKG, 'utf8')).version.split('-')[0]; + +const platforms = (badsig) => { + const signature = badsig ? BAD_SIG : GOOD_SIG; + const url = `http://${HOST}:${PORT}/artifacts/test.bin`; + return Object.fromEntries(ALL_KEYS.map((k) => [k, { url, signature }])); +}; + +// Future stamp guarantees the nightly is "newer than installed" regardless of +// the installed version (same base + nightly > stable, or > an older stamp). +export const buildNightlyManifest = (badsig = false) => ({ + version: `${baseVersion()}-2099010100`, + pub_date: '2099-01-01T00:00:00+08:00', + notes: 'Harness nightly build.', + platforms: platforms(badsig), +}); + +export const buildStableManifest = (surpass = false) => { + const [a, b, c] = baseVersion().split('.').map(Number); + return { + version: surpass ? `${a}.${b}.${c + 1}` : `${a}.${b}.${c}`, + pub_date: '2099-01-01T00:00:00+08:00', + notes: 'Harness stable build.', + platforms: platforms(false), + }; +}; + +const json = (res, obj) => { + res.writeHead(200, { 'content-type': 'application/json', 'access-control-allow-origin': '*' }); + res.end(JSON.stringify(obj, null, 2)); +}; + +// Static switch on the literal request path — no user-controlled dynamic +// dispatch (the request path never selects which function is invoked). +const handleRequest = (req, res) => { + const url = req.url.split('?')[0]; + console.log(`${req.method} ${url}`); + switch (url) { + case '/nightly/latest.json': + return json(res, buildNightlyManifest(false)); + case '/nightly/latest-badsig.json': + return json(res, buildNightlyManifest(true)); + case '/releases/latest.json': + return json(res, buildStableManifest(false)); + case '/releases/latest-surpass.json': + return json(res, buildStableManifest(true)); + case '/artifacts/test.bin': + res.writeHead(200, { 'content-type': 'application/octet-stream' }); + return fs.createReadStream(ARTIFACT).pipe(res); + default: + res.writeHead(404); + return res.end('not found'); + } +}; + +const serve = () => + http.createServer(handleRequest).listen(PORT, HOST, () => { + const base = baseVersion(); + console.log(`nightly harness on http://${HOST}:${PORT}`); + console.log(` nightly: http://${HOST}:${PORT}/nightly/latest.json`); + console.log(` nightly badsig: http://${HOST}:${PORT}/nightly/latest-badsig.json`); + console.log(` stable: http://${HOST}:${PORT}/releases/latest.json`); + console.log(` stable surpass: http://${HOST}:${PORT}/releases/latest-surpass.json`); + console.log(` base ${base} -> nightly ${base}-2099010100`); + }); + +// Only start the server when run directly (so the unit test can import the builders). +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) serve(); diff --git a/apps/readest-app/src-tauri/Cargo.toml b/apps/readest-app/src-tauri/Cargo.toml index e9864513..b3c76e7a 100644 --- a/apps/readest-app/src-tauri/Cargo.toml +++ b/apps/readest-app/src-tauri/Cargo.toml @@ -27,6 +27,9 @@ tauri-build = { version = "2", features = [] } [dependencies] serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } +semver = "1" +base64 = "0.22" +minisign-verify = "0.2" log = "0.4" thiserror = "2" walkdir = "2" diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index f26fd371..07342384 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -30,6 +30,7 @@ mod epub_parser; #[cfg(target_os = "macos")] mod macos; mod mobi_parser; +mod nightly_update; mod parser_common; mod range_file; mod transfer_file; @@ -290,6 +291,9 @@ pub fn run() { #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] discord_rpc::clear_book_presence, clip_url::clip_url, + nightly_update::verify_update_signature, + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + nightly_update::install_nightly_update, ]) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_persisted_scope::init()) diff --git a/apps/readest-app/src-tauri/src/nightly_update.rs b/apps/readest-app/src-tauri/src/nightly_update.rs new file mode 100644 index 00000000..78addd5f --- /dev/null +++ b/apps/readest-app/src-tauri/src/nightly_update.rs @@ -0,0 +1,218 @@ +//! Nightly update channel: base-aware version comparator + verify/install +//! commands. The comparator mirrors `src/utils/version.ts::isUpdateNewer` and is +//! validated against the same matrix. + +use semver::Version; + +/// Returns the 10-digit nightly stamp if the prerelease is exactly `YYYYMMDDHH`. +fn parse_stamp(v: &Version) -> Option { + let pre = v.pre.as_str(); + if pre.len() == 10 && pre.bytes().all(|b| b.is_ascii_digit()) { + pre.parse::().ok() + } else { + None + } +} + +/// Base-aware "is `candidate` newer than `current`?" — see version.ts for the rule. +pub fn is_update_newer(candidate: &str, current: &str) -> bool { + let (c, cur) = match (Version::parse(candidate), Version::parse(current)) { + (Ok(c), Ok(cur)) => (c, cur), + _ => return false, + }; + let c_base = (c.major, c.minor, c.patch); + let cur_base = (cur.major, cur.minor, cur.patch); + if c_base != cur_base { + return c_base > cur_base; + } + match (parse_stamp(&c), parse_stamp(&cur)) { + (Some(_), None) => true, + (None, Some(_)) => false, + (Some(cs), Some(curs)) => cs > curs, + (None, None) => false, + } +} + +/// Base64-decode `s` and interpret the bytes as UTF-8, mirroring Tauri's +/// `base64_to_string` (`tauri-plugin-updater-2.10.1/src/updater.rs:1465`). +fn base64_to_string(s: &str) -> Option { + use base64::Engine; + let decoded = base64::engine::general_purpose::STANDARD.decode(s).ok()?; + String::from_utf8(decoded).ok() +} + +/// Verify a downloaded artifact against a minisign signature using the embedded +/// updater public key. `pub_key` is the base64 blob from `tauri.conf.json` +/// `updater.pubkey` and `signature` is the base64 contents of the artifact's +/// `.sig` file — the same two inputs Tauri's own updater consumes. This mirrors +/// `verify_signature` (`tauri-plugin-updater-2.10.1/src/updater.rs:1453`) so a +/// nightly artifact accepted here is also accepted by Tauri's installer. +#[tauri::command] +pub async fn verify_update_signature(path: String, signature: String, pub_key: String) -> bool { + let Ok(data) = std::fs::read(&path) else { + return false; + }; + verify_signature_impl(&data, &signature, &pub_key) +} + +/// File-IO-free core of [`verify_update_signature`], so the signature check can +/// be unit-tested without touching the filesystem. Returns `true` only when +/// `data` is covered by `signature` under `pub_key`; any decode error or +/// verification failure returns `false` (fail-closed). +fn verify_signature_impl(data: &[u8], signature: &str, pub_key: &str) -> bool { + use minisign_verify::{PublicKey, Signature}; + + let Some(pub_key_decoded) = base64_to_string(pub_key) else { + return false; + }; + let Ok(public_key) = PublicKey::decode(&pub_key_decoded) else { + return false; + }; + let Some(signature_decoded) = base64_to_string(signature) else { + return false; + }; + let Ok(sig) = Signature::decode(&signature_decoded) else { + return false; + }; + public_key.verify(data, &sig, true).is_ok() +} + +/// Progress event streamed to the JS install dialog over an IPC `Channel`. +#[cfg(desktop)] +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NightlyProgress { + pub event: String, // "progress" | "finished" + pub downloaded: u64, + pub content_length: u64, +} + +/// Drives the Tauri updater against a single nightly/stable manifest endpoint +/// with the base-aware [`is_update_newer`] comparator, then downloads, installs +/// and relaunches. Reuses Tauri's minisign verification and native installers +/// (`.app.tar.gz` on macOS, NSIS on Windows). Progress is streamed to the JS +/// dialog over `channel`. +#[cfg(desktop)] +#[tauri::command] +pub async fn install_nightly_update( + app: tauri::AppHandle, + endpoint: String, + channel: tauri::ipc::Channel, +) -> std::result::Result<(), String> { + use tauri::Url; + use tauri_plugin_updater::UpdaterExt; + + let url = Url::parse(&endpoint).map_err(|e| e.to_string())?; + let updater = app + .updater_builder() + .endpoints(vec![url]) + .map_err(|e| e.to_string())? + .version_comparator(|current, release| { + is_update_newer(&release.version.to_string(), ¤t.to_string()) + }) + .build() + .map_err(|e| e.to_string())?; + + let Some(update) = updater.check().await.map_err(|e| e.to_string())? else { + return Err("no update available".into()); + }; + + let mut downloaded: u64 = 0; + let progress_channel = channel.clone(); + update + .download_and_install( + move |chunk, total| { + downloaded += chunk as u64; + let _ = progress_channel.send(NightlyProgress { + event: "progress".into(), + downloaded, + content_length: total.unwrap_or(0), + }); + }, + move || { + let _ = channel.send(NightlyProgress { + event: "finished".into(), + downloaded: 0, + content_length: 0, + }); + }, + ) + .await + .map_err(|e| e.to_string())?; + + app.restart() +} + +#[cfg(test)] +mod tests { + use super::{is_update_newer, verify_signature_impl}; + + // Fixtures generated with a THROWAWAY minisign keypair (`tauri signer + // generate`/`sign`) over the exact bytes in TEST_DATA. The private key was + // discarded; the public key + signature below are safe to embed. These + // mirror the real inputs: `pub_key` is base64 of the `.pub` file (== the + // tauri.conf `updater.pubkey` format) and `signature` is the base64 `.sig` + // contents (== the manifest `signature` field). + const TEST_DATA: &[u8] = b"readest-nightly-verify-test\n"; + const TEST_PUBKEY_B64: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEZFQTAxMjIzNUEwRkE0OUIKUldTYnBBOWFJeEtnL2x4Q3dKR3dSWVJCY3dLNXdCR1l4d1YyVkhaZUppOVVNVm1kOGprbU85bTMK"; + const TEST_SIG_B64: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVTYnBBOWFJeEtnL3RvRC83dEJEUXZONVFZM1hranhKTUZxQzllR2lGWnNjckZMbCtOa3RXMi80aFdDYUNDUkdOa0NqUjJUQkZDL2dqaUVTeURlNzI0cW1BcUlZY2ZsOGcwPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzgxNDE0MzExCWZpbGU6bnYuYmluCkQzajlpbVZPOXVDYXdna2JBVWZ0TTE4K1d1cWdEYWVYQzVraGh4U1ZuOGNSTDZaOU5zV093OEVDajBvV0JydVV5VGY2K0tkb0hBbGJHYWprK0NsNUN3PT0K"; + + #[test] + fn verify_accepts_valid_signature() { + assert!(verify_signature_impl( + TEST_DATA, + TEST_SIG_B64, + TEST_PUBKEY_B64 + )); + } + + #[test] + fn verify_rejects_tampered_data() { + // Correct key + correct signature, but the bytes changed → must fail. + assert!(!verify_signature_impl( + b"readest-nightly-verify-TAMPERED\n", + TEST_SIG_B64, + TEST_PUBKEY_B64 + )); + } + + #[test] + fn verify_rejects_bad_signature() { + assert!(!verify_signature_impl( + TEST_DATA, + "not-base64-!!!", + TEST_PUBKEY_B64 + )); + assert!(!verify_signature_impl(TEST_DATA, "", TEST_PUBKEY_B64)); + } + + #[test] + fn verify_rejects_malformed_pubkey() { + assert!(!verify_signature_impl(TEST_DATA, TEST_SIG_B64, "aGVsbG8=")); + assert!(!verify_signature_impl(TEST_DATA, TEST_SIG_B64, "")); + } + + #[test] + fn matrix() { + let cases: &[(&str, &str, bool)] = &[ + ("0.11.5", "0.11.4-2026061406", true), + ("0.11.4-2026061506", "0.11.4-2026061406", true), + ("0.11.4-2026061406", "0.11.4-2026061506", false), + ("0.11.4", "0.11.4-2026061406", false), + ("0.11.4-2026061406", "0.11.4", true), + ("0.11.5-2026070106", "0.11.4", true), + ("0.11.4", "0.11.4", false), + ("0.11.4-2026061406", "0.11.4-2026061406", false), + ("0.11.4-rc.1", "0.11.4", false), + ("", "0.11.4", false), + ("0.11.4", "", false), + ]; + for (cand, cur, want) in cases { + assert_eq!( + is_update_newer(cand, cur), + *want, + "is_update_newer({cand}, {cur})" + ); + } + } +} diff --git a/apps/readest-app/src/__tests__/helpers/updater.test.ts b/apps/readest-app/src/__tests__/helpers/updater.test.ts index 6d5a701a..54a667ce 100644 --- a/apps/readest-app/src/__tests__/helpers/updater.test.ts +++ b/apps/readest-app/src/__tests__/helpers/updater.test.ts @@ -4,6 +4,7 @@ import semver from 'semver'; // ── Mocks for Tauri and internal modules ───────────────────────── const mockCheck = vi.fn(); const mockOsType = vi.fn(); +const mockOsArch = vi.fn(); const mockTauriFetch = vi.fn(); vi.mock('@tauri-apps/plugin-updater', () => ({ @@ -12,6 +13,7 @@ vi.mock('@tauri-apps/plugin-updater', () => ({ vi.mock('@tauri-apps/plugin-os', () => ({ type: () => mockOsType(), + arch: () => mockOsArch(), })); vi.mock('@tauri-apps/plugin-http', () => ({ @@ -43,14 +45,19 @@ vi.mock('@/services/environment', () => ({ })); let mockAppVersion = '1.0.0'; -vi.mock('@/utils/version', () => ({ - getAppVersion: () => mockAppVersion, -})); +vi.mock('@/utils/version', async () => { + const actual = await vi.importActual('@/utils/version'); + return { + ...actual, + getAppVersion: () => mockAppVersion, + }; +}); vi.mock('@/services/constants', () => ({ CHECK_UPDATE_INTERVAL_SEC: 86400, READEST_UPDATER_FILE: 'https://example.com/latest.json', READEST_CHANGELOG_FILE: 'https://example.com/release-notes.json', + READEST_NIGHTLY_UPDATER_FILE: 'https://example.com/nightly/latest.json', })); import { @@ -58,7 +65,14 @@ import { checkAppReleaseNotes, setLastShownReleaseNotesVersion, getLastShownReleaseNotesVersion, + resolveNightlyUpdate, + getNightlyPlatformKey, } from '@/helpers/updater'; +import { + buildNightlyManifest, + buildStableManifest, + baseVersion, +} from '../../../scripts/nightly-verify-harness/serve.mjs'; beforeEach(() => { vi.clearAllMocks(); @@ -262,6 +276,38 @@ describe('updater', () => { expect(stored).toBeGreaterThanOrEqual(before); expect(stored).toBeLessThanOrEqual(after); }); + + test('nightly channel resolves and opens the updater window', async () => { + const past = Date.now() - 86400 * 1000 - 1000; + localStorage.setItem('lastAppUpdateCheck', past.toString()); + mockOsType.mockReturnValue('macos'); + mockOsArch.mockReturnValue('aarch64'); + mockAppVersion = '0.11.4'; + mockTauriFetch.mockImplementation(async (url: string) => + url.includes('nightly') + ? { + ok: true, + json: async () => ({ + version: '0.11.4-2026061406', + platforms: { 'darwin-aarch64': { url: 'u', signature: 's' } }, + }), + } + : { + ok: true, + json: async () => ({ + version: '0.11.4', + platforms: { 'darwin-aarch64': { url: 'u', signature: 's' } }, + }), + }, + ); + mockIsTauriAppPlatform = true; + + const result = await checkForAppUpdates(dummyTranslate, false, 'nightly'); + + expect(result).toBe(true); + expect(mockCheck).not.toHaveBeenCalled(); // isolated from Tauri check() + expect(mockSetUpdaterWindowVisible).toHaveBeenCalled(); + }); }); // ── checkAppReleaseNotes ─────────────────────────────────────── @@ -363,3 +409,119 @@ describe('updater', () => { }); }); }); + +describe('getNightlyPlatformKey', () => { + test('android', () => { + expect(getNightlyPlatformKey('android', 'aarch64', false, false)).toBe('android-arm64'); + expect(getNightlyPlatformKey('android', 'x86_64', false, false)).toBe('android-universal'); + }); + test('windows nsis vs portable', () => { + expect(getNightlyPlatformKey('windows', 'x86_64', false, false)).toBe('windows-x86_64'); + expect(getNightlyPlatformKey('windows', 'x86_64', true, false)).toBe('windows-x86_64-portable'); + }); + test('linux appimage vs deb', () => { + expect(getNightlyPlatformKey('linux', 'x86_64', false, true)).toBe('linux-x86_64-appimage'); + expect(getNightlyPlatformKey('linux', 'x86_64', false, false)).toBeNull(); + }); + test('macos', () => { + expect(getNightlyPlatformKey('macos', 'aarch64', false, false)).toBe('darwin-aarch64'); + }); +}); + +describe('resolveNightlyUpdate', () => { + const mkRes = (body: unknown) => ({ ok: true, json: async () => body }); + const platformKey = 'darwin-aarch64'; + const entry = { url: 'https://x/app.tar.gz', signature: 'sig' }; + + test('picks newer nightly over stable when stable is same-base', async () => { + const fetchFn = vi.fn(async (url: string) => + url.includes('nightly') + ? mkRes({ version: '0.11.4-2026061406', platforms: { [platformKey]: entry } }) + : mkRes({ version: '0.11.4', platforms: { [platformKey]: entry } }), + ); + const r = await resolveNightlyUpdate('0.11.4', platformKey, fetchFn as never); + expect(r?.version).toBe('0.11.4-2026061406'); + expect(r?.endpoint).toContain('nightly'); + }); + + test('picks higher-base stable over older nightly', async () => { + const fetchFn = vi.fn(async (url: string) => + url.includes('nightly') + ? mkRes({ version: '0.11.4-2026061406', platforms: { [platformKey]: entry } }) + : mkRes({ version: '0.11.5', platforms: { [platformKey]: entry } }), + ); + const r = await resolveNightlyUpdate('0.11.4-2026061406', platformKey, fetchFn as never); + expect(r?.version).toBe('0.11.5'); + expect(r?.endpoint).not.toContain('nightly'); + }); + + test('ignores a manifest missing the current platform key', async () => { + const fetchFn = vi.fn(async (url: string) => + url.includes('nightly') + ? mkRes({ version: '0.11.4-2026061406', platforms: { [platformKey]: entry } }) + : mkRes({ version: '0.11.5', platforms: {} }), + ); + const r = await resolveNightlyUpdate('0.11.4', platformKey, fetchFn as never); + expect(r?.version).toBe('0.11.4-2026061406'); + }); + + test('returns null when nothing is newer than installed', async () => { + const fetchFn = vi.fn(async () => + mkRes({ version: '0.11.4', platforms: { [platformKey]: entry } }), + ); + const r = await resolveNightlyUpdate('0.11.4', platformKey, fetchFn as never); + expect(r).toBeNull(); + }); + + test('returns null when both manifests fail to fetch', async () => { + const fetchFn = vi.fn(async () => { + throw new Error('network'); + }); + const r = await resolveNightlyUpdate('0.11.4', platformKey, fetchFn as never); + expect(r).toBeNull(); + }); +}); + +// Runs the REAL resolver against the local verify harness's own manifest +// builders (scripts/nightly-verify-harness/serve.mjs), so the harness and the +// production decision logic can't drift, and the "what would the app offer" +// decision for each scenario is asserted without the GUI. +describe('resolveNightlyUpdate — harness scenarios', () => { + const platformKey = 'darwin-aarch64'; + const base = baseVersion(); + const [major, minor, patch] = base.split('.').map(Number) as [number, number, number]; + const mkFetch = (nightly: unknown, stable: unknown) => + vi.fn(async (url: string) => ({ + ok: true, + json: async () => (url.includes('nightly') ? nightly : stable), + })); + + test('offers the nightly when stable is same-base (Tier 2 detection)', async () => { + const r = await resolveNightlyUpdate( + base, + platformKey, + mkFetch(buildNightlyManifest(), buildStableManifest()) as never, + ); + expect(r?.version).toBe(`${base}-2099010100`); + expect(r?.endpoint).toContain('nightly'); + }); + + test('offers stable when stable surpasses the nightly (cross-channel)', async () => { + const r = await resolveNightlyUpdate( + base, + platformKey, + mkFetch(buildNightlyManifest(), buildStableManifest(true)) as never, + ); + expect(r?.version).toBe(`${major}.${minor}.${patch + 1}`); + expect(r?.endpoint).not.toContain('nightly'); + }); + + test('offers nothing when already on the harness nightly', async () => { + const r = await resolveNightlyUpdate( + `${base}-2099010100`, + platformKey, + mkFetch(buildNightlyManifest(), buildStableManifest()) as never, + ); + expect(r).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/version.test.ts b/apps/readest-app/src/__tests__/utils/version.test.ts new file mode 100644 index 00000000..80ecf070 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/version.test.ts @@ -0,0 +1,50 @@ +import { describe, test, expect } from 'vitest'; +import { parseUpdateVersion, isUpdateNewer } from '@/utils/version'; + +describe('parseUpdateVersion', () => { + test('parses a stable version', () => { + expect(parseUpdateVersion('0.11.4')).toEqual({ base: '0.11.4', stamp: null, isNightly: false }); + }); + test('parses a nightly stamp', () => { + expect(parseUpdateVersion('0.11.4-2026061406')).toEqual({ + base: '0.11.4', + stamp: 2026061406, + isNightly: true, + }); + }); + test('non-10-digit prerelease is not a nightly stamp', () => { + expect(parseUpdateVersion('0.11.4-rc.1')).toEqual({ + base: '0.11.4', + stamp: null, + isNightly: false, + }); + expect(parseUpdateVersion('0.11.4-2026')).toEqual({ + base: '0.11.4', + stamp: null, + isNightly: false, + }); + }); + test('returns null for malformed input', () => { + expect(parseUpdateVersion('')).toBeNull(); + expect(parseUpdateVersion('not-a-version')).toBeNull(); + }); +}); + +describe('isUpdateNewer', () => { + const cases: Array<[string, string, boolean]> = [ + ['0.11.5', '0.11.4-2026061406', true], + ['0.11.4-2026061506', '0.11.4-2026061406', true], + ['0.11.4-2026061406', '0.11.4-2026061506', false], + ['0.11.4', '0.11.4-2026061406', false], + ['0.11.4-2026061406', '0.11.4', true], + ['0.11.5-2026070106', '0.11.4', true], + ['0.11.4', '0.11.4', false], + ['0.11.4-2026061406', '0.11.4-2026061406', false], + ['0.11.4-rc.1', '0.11.4', false], + ['', '0.11.4', false], + ['0.11.4', '', false], + ]; + test.each(cases)('isUpdateNewer(%s, %s) === %s', (candidate, current, expected) => { + expect(isUpdateNewer(candidate, current)).toBe(expected); + }); +}); diff --git a/apps/readest-app/src/app/library/components/SettingsMenu.tsx b/apps/readest-app/src/app/library/components/SettingsMenu.tsx index 4af5d2d9..f648fb72 100644 --- a/apps/readest-app/src/app/library/components/SettingsMenu.tsx +++ b/apps/readest-app/src/app/library/components/SettingsMenu.tsx @@ -55,6 +55,7 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow const { settings, setSettingsDialogOpen } = useSettingsStore(); const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload); const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates); + const [isNightlyChannel, setIsNightlyChannel] = useState(settings.updateChannel === 'nightly'); const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(settings.alwaysOnTop); const [isAlwaysShowStatusBar, setIsAlwaysShowStatusBar] = useState(settings.alwaysShowStatusBar); const [isOpenLastBooks, setIsOpenLastBooks] = useState(settings.openLastBooks); @@ -161,6 +162,12 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow setIsAutoCheckUpdates(newValue); }; + const toggleNightlyChannel = () => { + const newValue = !isNightlyChannel; + saveSysSettings(envConfig, 'updateChannel', newValue ? 'nightly' : 'stable'); + setIsNightlyChannel(newValue); + }; + const toggleOpenLastBooks = () => { const newValue = !settings.openLastBooks; saveSysSettings(envConfig, 'openLastBooks', newValue); @@ -392,6 +399,14 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow onClick={toggleAutoCheckUpdates} /> )} + {appService?.hasUpdater && ( + + )} {appService?.hasWindow && ( { const doCheckAppUpdates = async () => { if (appService?.hasUpdater && settings.autoCheckUpdates) { - await checkForAppUpdates(_); + await checkForAppUpdates(_, true, settings.updateChannel); } else if (appService?.hasUpdater === false) { checkAppReleaseNotes(); } diff --git a/apps/readest-app/src/app/reader/page.tsx b/apps/readest-app/src/app/reader/page.tsx index b437ec60..c385b84b 100644 --- a/apps/readest-app/src/app/reader/page.tsx +++ b/apps/readest-app/src/app/reader/page.tsx @@ -28,7 +28,7 @@ export default function Page() { useEffect(() => { const doCheckAppUpdates = async () => { if (appService?.hasUpdater && settings.autoCheckUpdates) { - await checkForAppUpdates(_); + await checkForAppUpdates(_, true, settings.updateChannel); } else if (appService?.hasUpdater === false) { checkAppReleaseNotes(); } diff --git a/apps/readest-app/src/components/AboutWindow.tsx b/apps/readest-app/src/components/AboutWindow.tsx index 03b30c65..444c5453 100644 --- a/apps/readest-app/src/components/AboutWindow.tsx +++ b/apps/readest-app/src/components/AboutWindow.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import Image from 'next/image'; import { useEnv } from '@/context/EnvContext'; import { useTranslation } from '@/hooks/useTranslation'; +import { useSettingsStore } from '@/store/settingsStore'; import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater'; import { parseWebViewInfo } from '@/utils/ua'; import { getAppVersion } from '@/utils/version'; @@ -25,6 +26,7 @@ type UpdateStatus = 'checking' | 'updating' | 'updated' | 'error'; export const AboutWindow = () => { const _ = useTranslation(); const { appService } = useEnv(); + const { settings } = useSettingsStore(); const [updateStatus, setUpdateStatus] = useState(null); const [browserInfo, setBrowserInfo] = useState(''); const [isOpen, setIsOpen] = useState(false); @@ -52,7 +54,7 @@ export const AboutWindow = () => { const handleCheckUpdate = async () => { setUpdateStatus('checking'); try { - const hasUpdate = await checkForAppUpdates(_, false); + const hasUpdate = await checkForAppUpdates(_, false, settings.updateChannel); if (hasUpdate) { handleClose(); } else { diff --git a/apps/readest-app/src/components/UpdaterWindow.tsx b/apps/readest-app/src/components/UpdaterWindow.tsx index f0683cfd..4552b188 100644 --- a/apps/readest-app/src/components/UpdaterWindow.tsx +++ b/apps/readest-app/src/components/UpdaterWindow.tsx @@ -16,11 +16,16 @@ import { useTranslation } from '@/hooks/useTranslation'; import { useSearchParams } from 'next/navigation'; import { getAppVersion } from '@/utils/version'; import { tauriDownload } from '@/utils/transfer'; -import { installPackage } from '@/utils/bridge'; +import { installPackage, verifyUpdateSignature, installNightlyUpdate } from '@/utils/bridge'; import { join } from '@tauri-apps/api/path'; import { getLocale } from '@/utils/misc'; import { setLastShownReleaseNotesVersion } from '@/helpers/updater'; -import { READEST_UPDATER_FILE, READEST_CHANGELOG_FILE } from '@/services/constants'; +import type { ResolvedNightlyUpdate } from '@/helpers/updater'; +import { + READEST_UPDATER_FILE, + READEST_CHANGELOG_FILE, + READEST_UPDATER_PUBKEY, +} from '@/services/constants'; import Dialog from '@/components/Dialog'; import Link from './Link'; @@ -65,14 +70,34 @@ interface GenericUpdate { downloadAndInstall?(onEvent?: (progress: DownloadEvent) => void): Promise; } +const TAURI_UPDATER_KEYS = new Set([ + 'darwin-aarch64', + 'darwin-x86_64', + 'windows-x86_64', + 'windows-aarch64', +]); + +// Render a nightly stamp (e.g. "0.11.4-2026061406") in a human form. Returns +// the input unchanged for plain semver versions so stable releases display +// normally. +const formatVersionLabel = (v: string): string => { + const m = v.match(/^(\d+\.\d+\.\d+)-(\d{4})(\d{2})(\d{2})(\d{2})$/); + if (!m) return v; + const [, base, y, mo, d, h] = m; + const date = new Date(Number(y), Number(mo) - 1, Number(d)); + return `Nightly · ${base} (${date.toLocaleDateString()}, ${h}:00)`; +}; + export const UpdaterContent = ({ latestVersion, lastVersion, checkUpdate = true, + nightlyUpdate, }: { latestVersion?: string; lastVersion?: string; checkUpdate?: boolean; + nightlyUpdate?: ResolvedNightlyUpdate; }) => { const _ = useTranslation(); const [targetLang, setTargetLang] = useState('EN'); @@ -97,6 +122,7 @@ export const UpdaterContent = ({ const [isDownloading, setIsDownloading] = useState(false); const [downloaded, setDownloaded] = useState(null); const [isMounted, setIsMounted] = useState(false); + const [error, setError] = useState(null); useEffect(() => { setTargetLang(getLocale()); @@ -283,23 +309,102 @@ export const UpdaterContent = ({ } as GenericUpdate); } }; + const buildNightlyUpdate = (n: ResolvedNightlyUpdate): GenericUpdate => ({ + currentVersion, + version: n.version, + date: n.pubDate, + body: n.notes, + downloadAndInstall: async (onEvent) => { + if (TAURI_UPDATER_KEYS.has(n.platformKey)) { + // macOS / Windows-NSIS: Tauri updater (verify + install + + // relaunch). A 0 contentLength (server omitted Content-Length) is + // tolerated: we only emit 'Started' once a non-zero total arrives so + // the percent math never divides by zero. + let total = 0; + let lastDownloaded = 0; + await installNightlyUpdate(n.endpoint, (p) => { + if (p.event === 'progress') { + if (!total && p.contentLength) { + total = p.contentLength; + onEvent?.({ event: 'Started', data: { contentLength: total } }); + } + // p.downloaded is a cumulative running total from Rust, but the + // consumer treats chunkLength as a per-chunk delta, so convert. + onEvent?.({ + event: 'Progress', + data: { chunkLength: p.downloaded - lastDownloaded }, + }); + lastDownloaded = p.downloaded; + } else if (p.event === 'finished') { + onEvent?.({ event: 'Finished' }); + } + }); + return; + } + // Windows-portable / Linux-AppImage / Android: download, verify, install. + const fileName = n.url.split('/').pop() || `Readest_${n.version}`; + let filePath: string; + if (n.platformKey.includes('portable')) { + // Windows portable: write into the executable dir so the new exe + // replaces the running one in place (mirrors checkWindowsPortableUpdate). + const execDir = await invoke('get_executable_dir'); + filePath = await join(execDir, fileName); + } else { + filePath = await appService!.resolveFilePath(fileName, 'Cache'); + } + await downloadWithProgress(n.url, filePath, onEvent); + const ok = await verifyUpdateSignature(filePath, n.signature, READEST_UPDATER_PUBKEY); + if (!ok) { + console.error('Nightly signature verification failed; aborting install'); + throw new Error('Signature verification failed'); + } + if (n.platformKey.startsWith('android')) { + const res = await installPackage({ path: filePath }); + if (!res.success) console.error('Failed to install APK:', res.error); + } else if (n.platformKey.includes('appimage')) { + const chmod = Command.create('chmod-appimage', ['+x', filePath]); + await chmod.execute(); + const launch = Command.create('launch-appimage', [filePath]); + await launch.spawn(); + setTimeout(async () => { + await exit(0); + }, 500); + } else { + // windows portable + const command = Command.create('start-readest', ['/C', 'start', '', filePath]); + await command.spawn(); + setTimeout(async () => { + await exit(0); + }, 500); + } + }, + }); const checkForUpdates = async () => { + if (nightlyUpdate) { + setUpdate(buildNightlyUpdate(nightlyUpdate)); + return; + } const OS_TYPE = osType(); - if (appService?.isPortableApp && OS_TYPE === 'windows') { - checkWindowsPortableUpdate(); - } else if (appService?.isAppImage) { - checkAppImageUpdate(); - } else if (['macos', 'windows', 'linux'].includes(OS_TYPE)) { - checkDesktopUpdate(); - } else if (OS_TYPE === 'android') { - checkAndroidUpdate(); + try { + if (appService?.isPortableApp && OS_TYPE === 'windows') { + await checkWindowsPortableUpdate(); + } else if (appService?.isAppImage) { + await checkAppImageUpdate(); + } else if (['macos', 'windows', 'linux'].includes(OS_TYPE)) { + await checkDesktopUpdate(); + } else if (OS_TYPE === 'android') { + await checkAndroidUpdate(); + } + } catch (err) { + console.error('Failed to check for updates:', err); + setError(_('Failed to check for updates')); } }; if (appService?.hasUpdater && checkUpdate) { checkForUpdates(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [appService?.hasUpdater]); + }, [appService?.hasUpdater, nightlyUpdate]); useEffect(() => { if (latestVersion) { @@ -400,7 +505,9 @@ export const UpdaterContent = ({ case 'Progress': downloaded += event.data.chunkLength; setDownloaded(downloaded); - const percent = Math.floor((downloaded / contentLength) * 100); + // Guard against a 0 total (server omitted Content-Length): keep the + // bar at an indeterminate 0% instead of NaN/Infinity. + const percent = contentLength > 0 ? Math.floor((downloaded / contentLength) * 100) : 0; setProgress(percent); if (downloaded - lastLogged >= 1 * 1024 * 1024) { console.log(`downloaded ${downloaded} bytes from ${contentLength}`); @@ -419,7 +526,19 @@ export const UpdaterContent = ({ } }; - if (!isMounted || !newVersion) { + if (!isMounted) { + return null; + } + + if (error) { + return ( +
+

{error}

+
+ ); + } + + if (!newVersion) { return null; } @@ -438,7 +557,7 @@ export const UpdaterContent = ({

{_('Readest {{newVersion}} is available (installed version {{currentVersion}}).', { - newVersion, + newVersion: formatVersionLabel(newVersion), currentVersion, })}

@@ -555,11 +674,12 @@ export const setUpdaterWindowVisible = ( latestVersion: string, lastVersion?: string, checkUpdate = true, + nightlyUpdate?: ResolvedNightlyUpdate, ) => { const dialog = document.getElementById('updater_window'); if (dialog) { const event = new CustomEvent('setDialogVisibility', { - detail: { visible, latestVersion, lastVersion, checkUpdate }, + detail: { visible, latestVersion, lastVersion, checkUpdate, nightlyUpdate }, }); dialog.dispatchEvent(event); } @@ -570,13 +690,15 @@ export const UpdaterWindow = () => { const [latestVersion, setLatestVersion] = useState(''); const [lastVersion, setLastVersion] = useState(''); const [checkUpdate, setCheckUpdate] = useState(true); + const [nightlyUpdate, setNightlyUpdate] = useState(undefined); const [isOpen, setIsOpen] = useState(false); useEffect(() => { const handleCustomEvent = (event: CustomEvent) => { - const { visible, latestVersion, lastVersion, checkUpdate } = event.detail; + const { visible, latestVersion, lastVersion, checkUpdate, nightlyUpdate } = event.detail; setIsOpen(visible); setCheckUpdate(checkUpdate); + setNightlyUpdate(nightlyUpdate); if (latestVersion) { setLatestVersion(latestVersion); } @@ -610,6 +732,7 @@ export const UpdaterWindow = () => { latestVersion={latestVersion ?? undefined} lastVersion={lastVersion ?? undefined} checkUpdate={checkUpdate} + nightlyUpdate={nightlyUpdate} /> )} diff --git a/apps/readest-app/src/helpers/updater.ts b/apps/readest-app/src/helpers/updater.ts index 9082b64b..6148d296 100644 --- a/apps/readest-app/src/helpers/updater.ts +++ b/apps/readest-app/src/helpers/updater.ts @@ -1,17 +1,18 @@ import semver from 'semver'; import { check } from '@tauri-apps/plugin-updater'; -import { type as osType } from '@tauri-apps/plugin-os'; +import { type as osType, arch as osArch } from '@tauri-apps/plugin-os'; import { fetch } from '@tauri-apps/plugin-http'; import { WebviewWindow } from '@tauri-apps/api/webviewWindow'; import { ScrollBarStyle } from '@tauri-apps/api/window'; import { TranslationFunc } from '@/hooks/useTranslation'; import { setUpdaterWindowVisible } from '@/components/UpdaterWindow'; import { isTauriAppPlatform } from '@/services/environment'; -import { getAppVersion } from '@/utils/version'; +import { getAppVersion, isUpdateNewer } from '@/utils/version'; import { CHECK_UPDATE_INTERVAL_SEC, READEST_CHANGELOG_FILE, READEST_UPDATER_FILE, + READEST_NIGHTLY_UPDATER_FILE, } from '@/services/constants'; const LAST_CHECK_KEY = 'lastAppUpdateCheck'; @@ -34,9 +35,105 @@ const showUpdateWindow = (latestVersion: string, scrollBarStyle: ScrollBarStyle) }); }; +type FetchFn = typeof fetch; + +export interface UpdateManifestEntry { + url?: string; + signature?: string; +} +export interface UpdateManifest { + version: string; + pub_date?: string; + notes?: string; + platforms: Record; +} +export interface ResolvedNightlyUpdate { + endpoint: string; // manifest URL (for the Tauri UpdaterBuilder path) + version: string; + notes?: string; + pubDate?: string; + platformKey: string; + url: string; // artifact URL (for the custom install flows) + signature: string; // artifact signature +} + +export const getNightlyPlatformKey = ( + osTypeVal: string, + osArchVal: string, + isPortable: boolean, + isAppImage: boolean, +): string | null => { + const is64 = osArchVal === 'x86_64'; + if (osTypeVal === 'android') + return osArchVal === 'aarch64' ? 'android-arm64' : 'android-universal'; + if (osTypeVal === 'macos') return osArchVal === 'aarch64' ? 'darwin-aarch64' : 'darwin-x86_64'; + if (osTypeVal === 'windows') { + if (isPortable) return is64 ? 'windows-x86_64-portable' : 'windows-aarch64-portable'; + return is64 ? 'windows-x86_64' : 'windows-aarch64'; + } + if (osTypeVal === 'linux') { + // Nightly Linux is AppImage-only; a deb/rpm install has no nightly + // artifact, so it cleanly gets no nightly rather than mis-routing. + if (isAppImage) return is64 ? 'linux-x86_64-appimage' : 'linux-aarch64-appimage'; + return null; + } + return null; +}; + +const fetchManifest = async (fetchFn: FetchFn, url: string): Promise => { + try { + const res = await fetchFn(url, { connectTimeout: 5000 } as RequestInit); + if (!res.ok) return null; + return (await res.json()) as UpdateManifest; + } catch (err) { + console.warn('Failed to fetch update manifest', url, err); + return null; + } +}; + +// Nightly channel resolution: fetch the nightly + stable manifests, keep only +// candidates that (a) have a usable artifact for this platform and (b) are newer +// than the installed version, then return the newest by the base-aware rule. +export const resolveNightlyUpdate = async ( + currentVersion: string, + platformKey: string, + fetchFn: FetchFn, +): Promise => { + const [nightly, stable] = await Promise.all([ + fetchManifest(fetchFn, READEST_NIGHTLY_UPDATER_FILE), + fetchManifest(fetchFn, READEST_UPDATER_FILE), + ]); + const sources: Array<[UpdateManifest | null, string]> = [ + [nightly, READEST_NIGHTLY_UPDATER_FILE], + [stable, READEST_UPDATER_FILE], + ]; + const candidates: ResolvedNightlyUpdate[] = []; + for (const [manifest, endpoint] of sources) { + if (!manifest?.version) continue; + const entry = manifest.platforms?.[platformKey]; + if (!entry?.url || !entry?.signature) continue; // platform-eligibility filter + if (!isUpdateNewer(manifest.version, currentVersion)) continue; + candidates.push({ + endpoint, + version: manifest.version, + notes: manifest.notes, + pubDate: manifest.pub_date, + platformKey, + url: entry.url, + signature: entry.signature, + }); + } + if (candidates.length === 0) return null; + candidates.sort((a, b) => + isUpdateNewer(a.version, b.version) ? -1 : isUpdateNewer(b.version, a.version) ? 1 : 0, + ); + return candidates[0]!; +}; + export const checkForAppUpdates = async ( _: TranslationFunc, isAutoCheck = true, + updateChannel: 'stable' | 'nightly' = 'stable', ): Promise => { const lastCheck = localStorage.getItem(LAST_CHECK_KEY); const now = Date.now(); @@ -44,8 +141,25 @@ export const checkForAppUpdates = async ( return false; localStorage.setItem(LAST_CHECK_KEY, now.toString()); - console.log('Checking for updates'); + console.log('Checking for updates', { updateChannel }); const OS_TYPE = osType(); + + if (updateChannel === 'nightly') { + const platformKey = getNightlyPlatformKey( + OS_TYPE, + osArch(), + Boolean(process.env['NEXT_PUBLIC_PORTABLE_APP']), + Boolean((window as { __READEST_IS_APPIMAGE?: boolean }).__READEST_IS_APPIMAGE), + ); + if (!platformKey) return false; + const resolved = await resolveNightlyUpdate(getAppVersion(), platformKey, fetch); + if (resolved) { + setUpdaterWindowVisible(true, resolved.version, getAppVersion(), true, resolved); + return true; + } + return false; + } + if (['macos', 'windows', 'linux'].includes(OS_TYPE)) { const update = await check(); if (update) { diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 875e1bbf..4c09ebcf 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -107,6 +107,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial = { alwaysShowStatusBar: false, alwaysInForeground: false, autoCheckUpdates: true, + updateChannel: 'stable', screenWakeLock: false, screenBrightness: -1, // -1~100, -1 for system default autoScreenBrightness: true, @@ -797,6 +798,14 @@ export const READEST_UPDATER_FILE = `${LATEST_DOWNLOAD_BASE_URL}/latest.json`; export const READEST_CHANGELOG_FILE = `${LATEST_DOWNLOAD_BASE_URL}/release-notes.json`; +export const READEST_NIGHTLY_UPDATER_FILE = 'https://download.readest.com/nightly/latest.json'; + +// Public (verification) key, identical to src-tauri/tauri.conf.json `updater.pubkey`. +// Used to verify nightly artifacts in the custom install flows (portable / +// AppImage / Android). Safe to embed — it is a public key. +export const READEST_UPDATER_PUBKEY = + 'dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJFMEQ1QjE2OEU1NEIzNTEKUldSUnMxU09GbHNOdmpEaWFMT1crRFpEV2VORzQ2MklxaFc0M1R0ci9xY2c1bENXS0xhM1R1L2sK'; + export const READEST_PUBLIC_STORAGE_BASE_URL = 'https://storage.readest.com'; export const READEST_OPDS_USER_AGENT = 'Readest/1.0 (OPDS Browser)'; diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index 489362a9..217c4ba4 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -281,6 +281,7 @@ export interface SystemSettings { alwaysOnTop: boolean; openBookInNewWindow: boolean; autoCheckUpdates: boolean; + updateChannel: 'stable' | 'nightly'; screenWakeLock: boolean; screenBrightness: number; autoScreenBrightness: boolean; diff --git a/apps/readest-app/src/utils/bridge.ts b/apps/readest-app/src/utils/bridge.ts index df17bf6f..0b694713 100644 --- a/apps/readest-app/src/utils/bridge.ts +++ b/apps/readest-app/src/utils/bridge.ts @@ -1,4 +1,4 @@ -import { invoke } from '@tauri-apps/api/core'; +import { invoke, Channel } from '@tauri-apps/api/core'; export interface CopyURIRequest { uri: string; @@ -263,3 +263,31 @@ export async function clearSyncPassphrase(): Promise { export async function isSyncKeychainAvailable(): Promise { return invoke('plugin:native-bridge|is_sync_keychain_available'); } + +// ── Nightly updater (main-app commands, no native-bridge prefix) ───────── +// `verify_update_signature` gates the custom install flows (portable / +// AppImage / Android); `install_nightly_update` drives the Tauri updater for +// the platform keys it natively installs (macOS / Windows-NSIS). + +export async function verifyUpdateSignature( + path: string, + signature: string, + pubKey: string, +): Promise { + return invoke('verify_update_signature', { path, signature, pubKey }); +} + +export interface NightlyProgress { + event: 'progress' | 'finished'; + downloaded: number; + contentLength: number; +} + +export async function installNightlyUpdate( + endpoint: string, + onProgress?: (p: NightlyProgress) => void, +): Promise { + const channel = new Channel(); + if (onProgress) channel.onmessage = onProgress; + await invoke('install_nightly_update', { endpoint, channel }); +} diff --git a/apps/readest-app/src/utils/version.ts b/apps/readest-app/src/utils/version.ts index bbe12220..7681ed27 100644 --- a/apps/readest-app/src/utils/version.ts +++ b/apps/readest-app/src/utils/version.ts @@ -1,5 +1,47 @@ +import semver from 'semver'; import packageJson from '../../package.json'; export const getAppVersion = () => { return packageJson.version; }; + +export interface ParsedUpdateVersion { + base: string; // "X.Y.Z" + stamp: number | null; // YYYYMMDDHH, or null when not a nightly + isNightly: boolean; +} + +// A nightly version is `-`: a single, pure-10-digit +// prerelease identifier. Anything else (e.g. `-rc.1`, `-2026`) is treated as a +// non-nightly base version. +export const parseUpdateVersion = (version: string): ParsedUpdateVersion | null => { + const parsed = semver.parse(version); + if (!parsed) return null; + const base = `${parsed.major}.${parsed.minor}.${parsed.patch}`; + let stamp: number | null = null; + if (parsed.prerelease.length === 1) { + const id = String(parsed.prerelease[0]); + if (/^\d{10}$/.test(id)) { + stamp = Number(id); + } + } + return { base, stamp, isNightly: stamp !== null }; +}; + +// Base-aware "is candidate newer than current?" used by both the nightly channel +// check and (mirrored in Rust) the Tauri updater version_comparator. +// Rule: higher X.Y.Z core wins; on equal core a nightly outranks the matching +// stable (it was built after it) but never the reverse; two nightlies compare by +// stamp. +export const isUpdateNewer = (candidate: string, current: string): boolean => { + const c = parseUpdateVersion(candidate); + const cur = parseUpdateVersion(current); + if (!c || !cur) return false; + if (c.base !== cur.base) { + return semver.compare(c.base, cur.base) > 0; + } + if (c.isNightly && !cur.isNightly) return true; + if (!c.isNightly && cur.isNightly) return false; + if (c.isNightly && cur.isNightly) return (c.stamp as number) > (cur.stamp as number); + return false; +};