feat(updater): nightly update channel (Android/Windows/macOS/Linux) (#4577)
This commit is contained in:
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