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

This commit is contained in:
Huang Xin
2026-06-14 16:33:53 +08:00
committed by GitHub
parent bfb85c2f68
commit 57501cc520
23 changed files with 3120 additions and 26 deletions
@@ -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();