forked from akai/readest
feat(updater): nightly update channel (Android/Windows/macOS/Linux) (#4577)
This commit is contained in:
@@ -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();
|
||||
Reference in New Issue
Block a user