feat(updater): nightly update channel (Android/Windows/macOS/Linux) (#4577)

This commit is contained in:
Huang Xin
2026-06-14 16:33:53 +08:00
committed by GitHub
parent bfb85c2f68
commit 57501cc520
23 changed files with 3120 additions and 26 deletions
+451
View File
@@ -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
# `<base>-<YYYYMMDDHH>` (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 <<EOF
[r2]
type = s3
provider = Cloudflare
access_key_id = ${{ secrets.RELEASE_R2_ACCESS_KEY_ID }}
secret_access_key = ${{ secrets.RELEASE_R2_SECRET_ACCESS_KEY }}
endpoint = https://${{ secrets.RELEASE_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
EOF
# ──────────────────────────── ANDROID ────────────────────────────
# `pnpm tauri android init` + `git checkout .` reverts tracked files
# (including package.json), so the nightly version MUST be patched AFTER
# the checkout. Mirrors release.yml's android build/signing steps.
- name: build and sign Android apks
if: matrix.config.release == 'android'
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/28.2.13676358
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
version="${{ needs.compute-version.outputs.nightly_version }}"
cd apps/readest-app/
rm -rf src-tauri/gen/android
pnpm tauri android init
pnpm tauri icon ../../data/icons/readest-book.png
git checkout .
# Patch the nightly version AFTER checkout so the stamp survives.
node -e "const f='package.json';const j=require('./'+f);j.version='${version}';require('fs').writeFileSync(f, JSON.stringify(j,null,2)+'\n')"
pushd src-tauri/gen/android
echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" > 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_<ver>_<x64|
# arm64>-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/<version>/.
- 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[<key>] = {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_<ver>_<amd64|aarch64>.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 <<EOF
[r2]
type = s3
provider = Cloudflare
access_key_id = ${{ secrets.RELEASE_R2_ACCESS_KEY_ID }}
secret_access_key = ${{ secrets.RELEASE_R2_SECRET_ACCESS_KEY }}
endpoint = https://${{ secrets.RELEASE_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
EOF
- name: assemble + atomically promote latest.json
run: |
set -euo pipefail
version="${{ needs.compute-version.outputs.nightly_version }}"
base="r2:readest-releases/nightly"
# Pull only the fragments that SUCCEEDED legs uploaded.
rclone copy "$base/${version}/manifest-fragments" ./frag || true
if [ -z "$(ls -A ./frag 2>/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 <base>-<YYYYMMDDHH>. 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."
Generated
+3
View File
@@ -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",
File diff suppressed because it is too large Load Diff
@@ -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: `<base>-<YYYYMMDDHH>`, 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: <top commit subjects>",
"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/<version>/` and writes a per-platform manifest
fragment to `nightly/<version>/manifest-fragments/<platform-arch>.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/<version>/` 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) |
+1
View File
@@ -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",
@@ -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 · <base> (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** `<base+1>` — 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 = '<ABS>/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 `<ABS>`. (`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 `<base>-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.
@@ -0,0 +1 @@
readest-nightly-verify-test
@@ -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();
+3
View File
@@ -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"
+4
View File
@@ -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())
@@ -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<u64> {
let pre = v.pre.as_str();
if pre.len() == 10 && pre.bytes().all(|b| b.is_ascii_digit()) {
pre.parse::<u64>().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<String> {
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<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
endpoint: String,
channel: tauri::ipc::Channel<NightlyProgress>,
) -> 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(), &current.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})"
);
}
}
}
@@ -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<typeof import('@/utils/version')>('@/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();
});
});
@@ -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);
});
});
@@ -55,6 +55,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ 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<SettingsMenuProps> = ({ 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<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
onClick={toggleAutoCheckUpdates}
/>
)}
{appService?.hasUpdater && (
<MenuItem
label={_('Nightly Builds (Unstable)')}
description={isNightlyChannel ? _('Early daily builds; may be unstable') : ''}
toggled={isNightlyChannel}
onClick={toggleNightlyChannel}
/>
)}
<hr aria-hidden='true' className='border-base-200 my-1' />
{appService?.hasWindow && (
<MenuItem
+1 -1
View File
@@ -372,7 +372,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useEffect(() => {
const doCheckAppUpdates = async () => {
if (appService?.hasUpdater && settings.autoCheckUpdates) {
await checkForAppUpdates(_);
await checkForAppUpdates(_, true, settings.updateChannel);
} else if (appService?.hasUpdater === false) {
checkAppReleaseNotes();
}
+1 -1
View File
@@ -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();
}
@@ -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<UpdateStatus | null>(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 {
+139 -16
View File
@@ -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<void>;
}
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<number | null>(null);
const [isMounted, setIsMounted] = useState(false);
const [error, setError] = useState<string | null>(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<string>('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 (
<div className='bg-base-100 flex min-h-screen items-center justify-center'>
<p className='text-base-content text-sm font-bold'>{error}</p>
</div>
);
}
if (!newVersion) {
return null;
}
@@ -438,7 +557,7 @@ export const UpdaterContent = ({
</h2>
<p className='mb-2'>
{_('Readest {{newVersion}} is available (installed version {{currentVersion}}).', {
newVersion,
newVersion: formatVersionLabel(newVersion),
currentVersion,
})}
</p>
@@ -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<ResolvedNightlyUpdate | undefined>(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}
/>
)}
</Dialog>
+117 -3
View File
@@ -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<string, UpdateManifestEntry>;
}
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<UpdateManifest | null> => {
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<ResolvedNightlyUpdate | null> => {
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<boolean> => {
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) {
@@ -107,6 +107,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
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)';
+1
View File
@@ -281,6 +281,7 @@ export interface SystemSettings {
alwaysOnTop: boolean;
openBookInNewWindow: boolean;
autoCheckUpdates: boolean;
updateChannel: 'stable' | 'nightly';
screenWakeLock: boolean;
screenBrightness: number;
autoScreenBrightness: boolean;
+29 -1
View File
@@ -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<SyncPassphraseResponse> {
export async function isSyncKeychainAvailable(): Promise<SyncKeychainAvailableResponse> {
return invoke<SyncKeychainAvailableResponse>('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<boolean> {
return invoke<boolean>('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<void> {
const channel = new Channel<NightlyProgress>();
if (onProgress) channel.onmessage = onProgress;
await invoke<void>('install_nightly_update', { endpoint, channel });
}
+42
View File
@@ -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 `<base>-<YYYYMMDDHH>`: 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;
};