feat(updater): nightly update channel (Android/Windows/macOS/Linux) (#4577)
This commit is contained in:
@@ -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
@@ -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) |
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
|
||||
@@ -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(), ¤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})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)';
|
||||
|
||||
@@ -281,6 +281,7 @@ export interface SystemSettings {
|
||||
alwaysOnTop: boolean;
|
||||
openBookInNewWindow: boolean;
|
||||
autoCheckUpdates: boolean;
|
||||
updateChannel: 'stable' | 'nightly';
|
||||
screenWakeLock: boolean;
|
||||
screenBrightness: number;
|
||||
autoScreenBrightness: boolean;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user