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
@@ -4,6 +4,7 @@ import semver from 'semver';
// ── Mocks for Tauri and internal modules ─────────────────────────
const mockCheck = vi.fn();
const mockOsType = vi.fn();
const mockOsArch = vi.fn();
const mockTauriFetch = vi.fn();
vi.mock('@tauri-apps/plugin-updater', () => ({
@@ -12,6 +13,7 @@ vi.mock('@tauri-apps/plugin-updater', () => ({
vi.mock('@tauri-apps/plugin-os', () => ({
type: () => mockOsType(),
arch: () => mockOsArch(),
}));
vi.mock('@tauri-apps/plugin-http', () => ({
@@ -43,14 +45,19 @@ vi.mock('@/services/environment', () => ({
}));
let mockAppVersion = '1.0.0';
vi.mock('@/utils/version', () => ({
getAppVersion: () => mockAppVersion,
}));
vi.mock('@/utils/version', async () => {
const actual = await vi.importActual<typeof import('@/utils/version')>('@/utils/version');
return {
...actual,
getAppVersion: () => mockAppVersion,
};
});
vi.mock('@/services/constants', () => ({
CHECK_UPDATE_INTERVAL_SEC: 86400,
READEST_UPDATER_FILE: 'https://example.com/latest.json',
READEST_CHANGELOG_FILE: 'https://example.com/release-notes.json',
READEST_NIGHTLY_UPDATER_FILE: 'https://example.com/nightly/latest.json',
}));
import {
@@ -58,7 +65,14 @@ import {
checkAppReleaseNotes,
setLastShownReleaseNotesVersion,
getLastShownReleaseNotesVersion,
resolveNightlyUpdate,
getNightlyPlatformKey,
} from '@/helpers/updater';
import {
buildNightlyManifest,
buildStableManifest,
baseVersion,
} from '../../../scripts/nightly-verify-harness/serve.mjs';
beforeEach(() => {
vi.clearAllMocks();
@@ -262,6 +276,38 @@ describe('updater', () => {
expect(stored).toBeGreaterThanOrEqual(before);
expect(stored).toBeLessThanOrEqual(after);
});
test('nightly channel resolves and opens the updater window', async () => {
const past = Date.now() - 86400 * 1000 - 1000;
localStorage.setItem('lastAppUpdateCheck', past.toString());
mockOsType.mockReturnValue('macos');
mockOsArch.mockReturnValue('aarch64');
mockAppVersion = '0.11.4';
mockTauriFetch.mockImplementation(async (url: string) =>
url.includes('nightly')
? {
ok: true,
json: async () => ({
version: '0.11.4-2026061406',
platforms: { 'darwin-aarch64': { url: 'u', signature: 's' } },
}),
}
: {
ok: true,
json: async () => ({
version: '0.11.4',
platforms: { 'darwin-aarch64': { url: 'u', signature: 's' } },
}),
},
);
mockIsTauriAppPlatform = true;
const result = await checkForAppUpdates(dummyTranslate, false, 'nightly');
expect(result).toBe(true);
expect(mockCheck).not.toHaveBeenCalled(); // isolated from Tauri check()
expect(mockSetUpdaterWindowVisible).toHaveBeenCalled();
});
});
// ── checkAppReleaseNotes ───────────────────────────────────────
@@ -363,3 +409,119 @@ describe('updater', () => {
});
});
});
describe('getNightlyPlatformKey', () => {
test('android', () => {
expect(getNightlyPlatformKey('android', 'aarch64', false, false)).toBe('android-arm64');
expect(getNightlyPlatformKey('android', 'x86_64', false, false)).toBe('android-universal');
});
test('windows nsis vs portable', () => {
expect(getNightlyPlatformKey('windows', 'x86_64', false, false)).toBe('windows-x86_64');
expect(getNightlyPlatformKey('windows', 'x86_64', true, false)).toBe('windows-x86_64-portable');
});
test('linux appimage vs deb', () => {
expect(getNightlyPlatformKey('linux', 'x86_64', false, true)).toBe('linux-x86_64-appimage');
expect(getNightlyPlatformKey('linux', 'x86_64', false, false)).toBeNull();
});
test('macos', () => {
expect(getNightlyPlatformKey('macos', 'aarch64', false, false)).toBe('darwin-aarch64');
});
});
describe('resolveNightlyUpdate', () => {
const mkRes = (body: unknown) => ({ ok: true, json: async () => body });
const platformKey = 'darwin-aarch64';
const entry = { url: 'https://x/app.tar.gz', signature: 'sig' };
test('picks newer nightly over stable when stable is same-base', async () => {
const fetchFn = vi.fn(async (url: string) =>
url.includes('nightly')
? mkRes({ version: '0.11.4-2026061406', platforms: { [platformKey]: entry } })
: mkRes({ version: '0.11.4', platforms: { [platformKey]: entry } }),
);
const r = await resolveNightlyUpdate('0.11.4', platformKey, fetchFn as never);
expect(r?.version).toBe('0.11.4-2026061406');
expect(r?.endpoint).toContain('nightly');
});
test('picks higher-base stable over older nightly', async () => {
const fetchFn = vi.fn(async (url: string) =>
url.includes('nightly')
? mkRes({ version: '0.11.4-2026061406', platforms: { [platformKey]: entry } })
: mkRes({ version: '0.11.5', platforms: { [platformKey]: entry } }),
);
const r = await resolveNightlyUpdate('0.11.4-2026061406', platformKey, fetchFn as never);
expect(r?.version).toBe('0.11.5');
expect(r?.endpoint).not.toContain('nightly');
});
test('ignores a manifest missing the current platform key', async () => {
const fetchFn = vi.fn(async (url: string) =>
url.includes('nightly')
? mkRes({ version: '0.11.4-2026061406', platforms: { [platformKey]: entry } })
: mkRes({ version: '0.11.5', platforms: {} }),
);
const r = await resolveNightlyUpdate('0.11.4', platformKey, fetchFn as never);
expect(r?.version).toBe('0.11.4-2026061406');
});
test('returns null when nothing is newer than installed', async () => {
const fetchFn = vi.fn(async () =>
mkRes({ version: '0.11.4', platforms: { [platformKey]: entry } }),
);
const r = await resolveNightlyUpdate('0.11.4', platformKey, fetchFn as never);
expect(r).toBeNull();
});
test('returns null when both manifests fail to fetch', async () => {
const fetchFn = vi.fn(async () => {
throw new Error('network');
});
const r = await resolveNightlyUpdate('0.11.4', platformKey, fetchFn as never);
expect(r).toBeNull();
});
});
// Runs the REAL resolver against the local verify harness's own manifest
// builders (scripts/nightly-verify-harness/serve.mjs), so the harness and the
// production decision logic can't drift, and the "what would the app offer"
// decision for each scenario is asserted without the GUI.
describe('resolveNightlyUpdate — harness scenarios', () => {
const platformKey = 'darwin-aarch64';
const base = baseVersion();
const [major, minor, patch] = base.split('.').map(Number) as [number, number, number];
const mkFetch = (nightly: unknown, stable: unknown) =>
vi.fn(async (url: string) => ({
ok: true,
json: async () => (url.includes('nightly') ? nightly : stable),
}));
test('offers the nightly when stable is same-base (Tier 2 detection)', async () => {
const r = await resolveNightlyUpdate(
base,
platformKey,
mkFetch(buildNightlyManifest(), buildStableManifest()) as never,
);
expect(r?.version).toBe(`${base}-2099010100`);
expect(r?.endpoint).toContain('nightly');
});
test('offers stable when stable surpasses the nightly (cross-channel)', async () => {
const r = await resolveNightlyUpdate(
base,
platformKey,
mkFetch(buildNightlyManifest(), buildStableManifest(true)) as never,
);
expect(r?.version).toBe(`${major}.${minor}.${patch + 1}`);
expect(r?.endpoint).not.toContain('nightly');
});
test('offers nothing when already on the harness nightly', async () => {
const r = await resolveNightlyUpdate(
`${base}-2099010100`,
platformKey,
mkFetch(buildNightlyManifest(), buildStableManifest()) as never,
);
expect(r).toBeNull();
});
});
@@ -0,0 +1,50 @@
import { describe, test, expect } from 'vitest';
import { parseUpdateVersion, isUpdateNewer } from '@/utils/version';
describe('parseUpdateVersion', () => {
test('parses a stable version', () => {
expect(parseUpdateVersion('0.11.4')).toEqual({ base: '0.11.4', stamp: null, isNightly: false });
});
test('parses a nightly stamp', () => {
expect(parseUpdateVersion('0.11.4-2026061406')).toEqual({
base: '0.11.4',
stamp: 2026061406,
isNightly: true,
});
});
test('non-10-digit prerelease is not a nightly stamp', () => {
expect(parseUpdateVersion('0.11.4-rc.1')).toEqual({
base: '0.11.4',
stamp: null,
isNightly: false,
});
expect(parseUpdateVersion('0.11.4-2026')).toEqual({
base: '0.11.4',
stamp: null,
isNightly: false,
});
});
test('returns null for malformed input', () => {
expect(parseUpdateVersion('')).toBeNull();
expect(parseUpdateVersion('not-a-version')).toBeNull();
});
});
describe('isUpdateNewer', () => {
const cases: Array<[string, string, boolean]> = [
['0.11.5', '0.11.4-2026061406', true],
['0.11.4-2026061506', '0.11.4-2026061406', true],
['0.11.4-2026061406', '0.11.4-2026061506', false],
['0.11.4', '0.11.4-2026061406', false],
['0.11.4-2026061406', '0.11.4', true],
['0.11.5-2026070106', '0.11.4', true],
['0.11.4', '0.11.4', false],
['0.11.4-2026061406', '0.11.4-2026061406', false],
['0.11.4-rc.1', '0.11.4', false],
['', '0.11.4', false],
['0.11.4', '', false],
];
test.each(cases)('isUpdateNewer(%s, %s) === %s', (candidate, current, expected) => {
expect(isUpdateNewer(candidate, current)).toBe(expected);
});
});
@@ -55,6 +55,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
const { settings, setSettingsDialogOpen } = useSettingsStore();
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
const [isNightlyChannel, setIsNightlyChannel] = useState(settings.updateChannel === 'nightly');
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(settings.alwaysOnTop);
const [isAlwaysShowStatusBar, setIsAlwaysShowStatusBar] = useState(settings.alwaysShowStatusBar);
const [isOpenLastBooks, setIsOpenLastBooks] = useState(settings.openLastBooks);
@@ -161,6 +162,12 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
setIsAutoCheckUpdates(newValue);
};
const toggleNightlyChannel = () => {
const newValue = !isNightlyChannel;
saveSysSettings(envConfig, 'updateChannel', newValue ? 'nightly' : 'stable');
setIsNightlyChannel(newValue);
};
const toggleOpenLastBooks = () => {
const newValue = !settings.openLastBooks;
saveSysSettings(envConfig, 'openLastBooks', newValue);
@@ -392,6 +399,14 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
onClick={toggleAutoCheckUpdates}
/>
)}
{appService?.hasUpdater && (
<MenuItem
label={_('Nightly Builds (Unstable)')}
description={isNightlyChannel ? _('Early daily builds; may be unstable') : ''}
toggled={isNightlyChannel}
onClick={toggleNightlyChannel}
/>
)}
<hr aria-hidden='true' className='border-base-200 my-1' />
{appService?.hasWindow && (
<MenuItem
+1 -1
View File
@@ -372,7 +372,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useEffect(() => {
const doCheckAppUpdates = async () => {
if (appService?.hasUpdater && settings.autoCheckUpdates) {
await checkForAppUpdates(_);
await checkForAppUpdates(_, true, settings.updateChannel);
} else if (appService?.hasUpdater === false) {
checkAppReleaseNotes();
}
+1 -1
View File
@@ -28,7 +28,7 @@ export default function Page() {
useEffect(() => {
const doCheckAppUpdates = async () => {
if (appService?.hasUpdater && settings.autoCheckUpdates) {
await checkForAppUpdates(_);
await checkForAppUpdates(_, true, settings.updateChannel);
} else if (appService?.hasUpdater === false) {
checkAppReleaseNotes();
}
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import Image from 'next/image';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater';
import { parseWebViewInfo } from '@/utils/ua';
import { getAppVersion } from '@/utils/version';
@@ -25,6 +26,7 @@ type UpdateStatus = 'checking' | 'updating' | 'updated' | 'error';
export const AboutWindow = () => {
const _ = useTranslation();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
const [browserInfo, setBrowserInfo] = useState('');
const [isOpen, setIsOpen] = useState(false);
@@ -52,7 +54,7 @@ export const AboutWindow = () => {
const handleCheckUpdate = async () => {
setUpdateStatus('checking');
try {
const hasUpdate = await checkForAppUpdates(_, false);
const hasUpdate = await checkForAppUpdates(_, false, settings.updateChannel);
if (hasUpdate) {
handleClose();
} else {
+139 -16
View File
@@ -16,11 +16,16 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useSearchParams } from 'next/navigation';
import { getAppVersion } from '@/utils/version';
import { tauriDownload } from '@/utils/transfer';
import { installPackage } from '@/utils/bridge';
import { installPackage, verifyUpdateSignature, installNightlyUpdate } from '@/utils/bridge';
import { join } from '@tauri-apps/api/path';
import { getLocale } from '@/utils/misc';
import { setLastShownReleaseNotesVersion } from '@/helpers/updater';
import { READEST_UPDATER_FILE, READEST_CHANGELOG_FILE } from '@/services/constants';
import type { ResolvedNightlyUpdate } from '@/helpers/updater';
import {
READEST_UPDATER_FILE,
READEST_CHANGELOG_FILE,
READEST_UPDATER_PUBKEY,
} from '@/services/constants';
import Dialog from '@/components/Dialog';
import Link from './Link';
@@ -65,14 +70,34 @@ interface GenericUpdate {
downloadAndInstall?(onEvent?: (progress: DownloadEvent) => void): Promise<void>;
}
const TAURI_UPDATER_KEYS = new Set([
'darwin-aarch64',
'darwin-x86_64',
'windows-x86_64',
'windows-aarch64',
]);
// Render a nightly stamp (e.g. "0.11.4-2026061406") in a human form. Returns
// the input unchanged for plain semver versions so stable releases display
// normally.
const formatVersionLabel = (v: string): string => {
const m = v.match(/^(\d+\.\d+\.\d+)-(\d{4})(\d{2})(\d{2})(\d{2})$/);
if (!m) return v;
const [, base, y, mo, d, h] = m;
const date = new Date(Number(y), Number(mo) - 1, Number(d));
return `Nightly · ${base} (${date.toLocaleDateString()}, ${h}:00)`;
};
export const UpdaterContent = ({
latestVersion,
lastVersion,
checkUpdate = true,
nightlyUpdate,
}: {
latestVersion?: string;
lastVersion?: string;
checkUpdate?: boolean;
nightlyUpdate?: ResolvedNightlyUpdate;
}) => {
const _ = useTranslation();
const [targetLang, setTargetLang] = useState('EN');
@@ -97,6 +122,7 @@ export const UpdaterContent = ({
const [isDownloading, setIsDownloading] = useState(false);
const [downloaded, setDownloaded] = useState<number | null>(null);
const [isMounted, setIsMounted] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setTargetLang(getLocale());
@@ -283,23 +309,102 @@ export const UpdaterContent = ({
} as GenericUpdate);
}
};
const buildNightlyUpdate = (n: ResolvedNightlyUpdate): GenericUpdate => ({
currentVersion,
version: n.version,
date: n.pubDate,
body: n.notes,
downloadAndInstall: async (onEvent) => {
if (TAURI_UPDATER_KEYS.has(n.platformKey)) {
// macOS / Windows-NSIS: Tauri updater (verify + install +
// relaunch). A 0 contentLength (server omitted Content-Length) is
// tolerated: we only emit 'Started' once a non-zero total arrives so
// the percent math never divides by zero.
let total = 0;
let lastDownloaded = 0;
await installNightlyUpdate(n.endpoint, (p) => {
if (p.event === 'progress') {
if (!total && p.contentLength) {
total = p.contentLength;
onEvent?.({ event: 'Started', data: { contentLength: total } });
}
// p.downloaded is a cumulative running total from Rust, but the
// consumer treats chunkLength as a per-chunk delta, so convert.
onEvent?.({
event: 'Progress',
data: { chunkLength: p.downloaded - lastDownloaded },
});
lastDownloaded = p.downloaded;
} else if (p.event === 'finished') {
onEvent?.({ event: 'Finished' });
}
});
return;
}
// Windows-portable / Linux-AppImage / Android: download, verify, install.
const fileName = n.url.split('/').pop() || `Readest_${n.version}`;
let filePath: string;
if (n.platformKey.includes('portable')) {
// Windows portable: write into the executable dir so the new exe
// replaces the running one in place (mirrors checkWindowsPortableUpdate).
const execDir = await invoke<string>('get_executable_dir');
filePath = await join(execDir, fileName);
} else {
filePath = await appService!.resolveFilePath(fileName, 'Cache');
}
await downloadWithProgress(n.url, filePath, onEvent);
const ok = await verifyUpdateSignature(filePath, n.signature, READEST_UPDATER_PUBKEY);
if (!ok) {
console.error('Nightly signature verification failed; aborting install');
throw new Error('Signature verification failed');
}
if (n.platformKey.startsWith('android')) {
const res = await installPackage({ path: filePath });
if (!res.success) console.error('Failed to install APK:', res.error);
} else if (n.platformKey.includes('appimage')) {
const chmod = Command.create('chmod-appimage', ['+x', filePath]);
await chmod.execute();
const launch = Command.create('launch-appimage', [filePath]);
await launch.spawn();
setTimeout(async () => {
await exit(0);
}, 500);
} else {
// windows portable
const command = Command.create('start-readest', ['/C', 'start', '', filePath]);
await command.spawn();
setTimeout(async () => {
await exit(0);
}, 500);
}
},
});
const checkForUpdates = async () => {
if (nightlyUpdate) {
setUpdate(buildNightlyUpdate(nightlyUpdate));
return;
}
const OS_TYPE = osType();
if (appService?.isPortableApp && OS_TYPE === 'windows') {
checkWindowsPortableUpdate();
} else if (appService?.isAppImage) {
checkAppImageUpdate();
} else if (['macos', 'windows', 'linux'].includes(OS_TYPE)) {
checkDesktopUpdate();
} else if (OS_TYPE === 'android') {
checkAndroidUpdate();
try {
if (appService?.isPortableApp && OS_TYPE === 'windows') {
await checkWindowsPortableUpdate();
} else if (appService?.isAppImage) {
await checkAppImageUpdate();
} else if (['macos', 'windows', 'linux'].includes(OS_TYPE)) {
await checkDesktopUpdate();
} else if (OS_TYPE === 'android') {
await checkAndroidUpdate();
}
} catch (err) {
console.error('Failed to check for updates:', err);
setError(_('Failed to check for updates'));
}
};
if (appService?.hasUpdater && checkUpdate) {
checkForUpdates();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService?.hasUpdater]);
}, [appService?.hasUpdater, nightlyUpdate]);
useEffect(() => {
if (latestVersion) {
@@ -400,7 +505,9 @@ export const UpdaterContent = ({
case 'Progress':
downloaded += event.data.chunkLength;
setDownloaded(downloaded);
const percent = Math.floor((downloaded / contentLength) * 100);
// Guard against a 0 total (server omitted Content-Length): keep the
// bar at an indeterminate 0% instead of NaN/Infinity.
const percent = contentLength > 0 ? Math.floor((downloaded / contentLength) * 100) : 0;
setProgress(percent);
if (downloaded - lastLogged >= 1 * 1024 * 1024) {
console.log(`downloaded ${downloaded} bytes from ${contentLength}`);
@@ -419,7 +526,19 @@ export const UpdaterContent = ({
}
};
if (!isMounted || !newVersion) {
if (!isMounted) {
return null;
}
if (error) {
return (
<div className='bg-base-100 flex min-h-screen items-center justify-center'>
<p className='text-base-content text-sm font-bold'>{error}</p>
</div>
);
}
if (!newVersion) {
return null;
}
@@ -438,7 +557,7 @@ export const UpdaterContent = ({
</h2>
<p className='mb-2'>
{_('Readest {{newVersion}} is available (installed version {{currentVersion}}).', {
newVersion,
newVersion: formatVersionLabel(newVersion),
currentVersion,
})}
</p>
@@ -555,11 +674,12 @@ export const setUpdaterWindowVisible = (
latestVersion: string,
lastVersion?: string,
checkUpdate = true,
nightlyUpdate?: ResolvedNightlyUpdate,
) => {
const dialog = document.getElementById('updater_window');
if (dialog) {
const event = new CustomEvent('setDialogVisibility', {
detail: { visible, latestVersion, lastVersion, checkUpdate },
detail: { visible, latestVersion, lastVersion, checkUpdate, nightlyUpdate },
});
dialog.dispatchEvent(event);
}
@@ -570,13 +690,15 @@ export const UpdaterWindow = () => {
const [latestVersion, setLatestVersion] = useState('');
const [lastVersion, setLastVersion] = useState('');
const [checkUpdate, setCheckUpdate] = useState(true);
const [nightlyUpdate, setNightlyUpdate] = useState<ResolvedNightlyUpdate | undefined>(undefined);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handleCustomEvent = (event: CustomEvent) => {
const { visible, latestVersion, lastVersion, checkUpdate } = event.detail;
const { visible, latestVersion, lastVersion, checkUpdate, nightlyUpdate } = event.detail;
setIsOpen(visible);
setCheckUpdate(checkUpdate);
setNightlyUpdate(nightlyUpdate);
if (latestVersion) {
setLatestVersion(latestVersion);
}
@@ -610,6 +732,7 @@ export const UpdaterWindow = () => {
latestVersion={latestVersion ?? undefined}
lastVersion={lastVersion ?? undefined}
checkUpdate={checkUpdate}
nightlyUpdate={nightlyUpdate}
/>
)}
</Dialog>
+117 -3
View File
@@ -1,17 +1,18 @@
import semver from 'semver';
import { check } from '@tauri-apps/plugin-updater';
import { type as osType } from '@tauri-apps/plugin-os';
import { type as osType, arch as osArch } from '@tauri-apps/plugin-os';
import { fetch } from '@tauri-apps/plugin-http';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { ScrollBarStyle } from '@tauri-apps/api/window';
import { TranslationFunc } from '@/hooks/useTranslation';
import { setUpdaterWindowVisible } from '@/components/UpdaterWindow';
import { isTauriAppPlatform } from '@/services/environment';
import { getAppVersion } from '@/utils/version';
import { getAppVersion, isUpdateNewer } from '@/utils/version';
import {
CHECK_UPDATE_INTERVAL_SEC,
READEST_CHANGELOG_FILE,
READEST_UPDATER_FILE,
READEST_NIGHTLY_UPDATER_FILE,
} from '@/services/constants';
const LAST_CHECK_KEY = 'lastAppUpdateCheck';
@@ -34,9 +35,105 @@ const showUpdateWindow = (latestVersion: string, scrollBarStyle: ScrollBarStyle)
});
};
type FetchFn = typeof fetch;
export interface UpdateManifestEntry {
url?: string;
signature?: string;
}
export interface UpdateManifest {
version: string;
pub_date?: string;
notes?: string;
platforms: Record<string, UpdateManifestEntry>;
}
export interface ResolvedNightlyUpdate {
endpoint: string; // manifest URL (for the Tauri UpdaterBuilder path)
version: string;
notes?: string;
pubDate?: string;
platformKey: string;
url: string; // artifact URL (for the custom install flows)
signature: string; // artifact signature
}
export const getNightlyPlatformKey = (
osTypeVal: string,
osArchVal: string,
isPortable: boolean,
isAppImage: boolean,
): string | null => {
const is64 = osArchVal === 'x86_64';
if (osTypeVal === 'android')
return osArchVal === 'aarch64' ? 'android-arm64' : 'android-universal';
if (osTypeVal === 'macos') return osArchVal === 'aarch64' ? 'darwin-aarch64' : 'darwin-x86_64';
if (osTypeVal === 'windows') {
if (isPortable) return is64 ? 'windows-x86_64-portable' : 'windows-aarch64-portable';
return is64 ? 'windows-x86_64' : 'windows-aarch64';
}
if (osTypeVal === 'linux') {
// Nightly Linux is AppImage-only; a deb/rpm install has no nightly
// artifact, so it cleanly gets no nightly rather than mis-routing.
if (isAppImage) return is64 ? 'linux-x86_64-appimage' : 'linux-aarch64-appimage';
return null;
}
return null;
};
const fetchManifest = async (fetchFn: FetchFn, url: string): Promise<UpdateManifest | null> => {
try {
const res = await fetchFn(url, { connectTimeout: 5000 } as RequestInit);
if (!res.ok) return null;
return (await res.json()) as UpdateManifest;
} catch (err) {
console.warn('Failed to fetch update manifest', url, err);
return null;
}
};
// Nightly channel resolution: fetch the nightly + stable manifests, keep only
// candidates that (a) have a usable artifact for this platform and (b) are newer
// than the installed version, then return the newest by the base-aware rule.
export const resolveNightlyUpdate = async (
currentVersion: string,
platformKey: string,
fetchFn: FetchFn,
): Promise<ResolvedNightlyUpdate | null> => {
const [nightly, stable] = await Promise.all([
fetchManifest(fetchFn, READEST_NIGHTLY_UPDATER_FILE),
fetchManifest(fetchFn, READEST_UPDATER_FILE),
]);
const sources: Array<[UpdateManifest | null, string]> = [
[nightly, READEST_NIGHTLY_UPDATER_FILE],
[stable, READEST_UPDATER_FILE],
];
const candidates: ResolvedNightlyUpdate[] = [];
for (const [manifest, endpoint] of sources) {
if (!manifest?.version) continue;
const entry = manifest.platforms?.[platformKey];
if (!entry?.url || !entry?.signature) continue; // platform-eligibility filter
if (!isUpdateNewer(manifest.version, currentVersion)) continue;
candidates.push({
endpoint,
version: manifest.version,
notes: manifest.notes,
pubDate: manifest.pub_date,
platformKey,
url: entry.url,
signature: entry.signature,
});
}
if (candidates.length === 0) return null;
candidates.sort((a, b) =>
isUpdateNewer(a.version, b.version) ? -1 : isUpdateNewer(b.version, a.version) ? 1 : 0,
);
return candidates[0]!;
};
export const checkForAppUpdates = async (
_: TranslationFunc,
isAutoCheck = true,
updateChannel: 'stable' | 'nightly' = 'stable',
): Promise<boolean> => {
const lastCheck = localStorage.getItem(LAST_CHECK_KEY);
const now = Date.now();
@@ -44,8 +141,25 @@ export const checkForAppUpdates = async (
return false;
localStorage.setItem(LAST_CHECK_KEY, now.toString());
console.log('Checking for updates');
console.log('Checking for updates', { updateChannel });
const OS_TYPE = osType();
if (updateChannel === 'nightly') {
const platformKey = getNightlyPlatformKey(
OS_TYPE,
osArch(),
Boolean(process.env['NEXT_PUBLIC_PORTABLE_APP']),
Boolean((window as { __READEST_IS_APPIMAGE?: boolean }).__READEST_IS_APPIMAGE),
);
if (!platformKey) return false;
const resolved = await resolveNightlyUpdate(getAppVersion(), platformKey, fetch);
if (resolved) {
setUpdaterWindowVisible(true, resolved.version, getAppVersion(), true, resolved);
return true;
}
return false;
}
if (['macos', 'windows', 'linux'].includes(OS_TYPE)) {
const update = await check();
if (update) {
@@ -107,6 +107,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
alwaysShowStatusBar: false,
alwaysInForeground: false,
autoCheckUpdates: true,
updateChannel: 'stable',
screenWakeLock: false,
screenBrightness: -1, // -1~100, -1 for system default
autoScreenBrightness: true,
@@ -797,6 +798,14 @@ export const READEST_UPDATER_FILE = `${LATEST_DOWNLOAD_BASE_URL}/latest.json`;
export const READEST_CHANGELOG_FILE = `${LATEST_DOWNLOAD_BASE_URL}/release-notes.json`;
export const READEST_NIGHTLY_UPDATER_FILE = 'https://download.readest.com/nightly/latest.json';
// Public (verification) key, identical to src-tauri/tauri.conf.json `updater.pubkey`.
// Used to verify nightly artifacts in the custom install flows (portable /
// AppImage / Android). Safe to embed — it is a public key.
export const READEST_UPDATER_PUBKEY =
'dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJFMEQ1QjE2OEU1NEIzNTEKUldSUnMxU09GbHNOdmpEaWFMT1crRFpEV2VORzQ2MklxaFc0M1R0ci9xY2c1bENXS0xhM1R1L2sK';
export const READEST_PUBLIC_STORAGE_BASE_URL = 'https://storage.readest.com';
export const READEST_OPDS_USER_AGENT = 'Readest/1.0 (OPDS Browser)';
+1
View File
@@ -281,6 +281,7 @@ export interface SystemSettings {
alwaysOnTop: boolean;
openBookInNewWindow: boolean;
autoCheckUpdates: boolean;
updateChannel: 'stable' | 'nightly';
screenWakeLock: boolean;
screenBrightness: number;
autoScreenBrightness: boolean;
+29 -1
View File
@@ -1,4 +1,4 @@
import { invoke } from '@tauri-apps/api/core';
import { invoke, Channel } from '@tauri-apps/api/core';
export interface CopyURIRequest {
uri: string;
@@ -263,3 +263,31 @@ export async function clearSyncPassphrase(): Promise<SyncPassphraseResponse> {
export async function isSyncKeychainAvailable(): Promise<SyncKeychainAvailableResponse> {
return invoke<SyncKeychainAvailableResponse>('plugin:native-bridge|is_sync_keychain_available');
}
// ── Nightly updater (main-app commands, no native-bridge prefix) ─────────
// `verify_update_signature` gates the custom install flows (portable /
// AppImage / Android); `install_nightly_update` drives the Tauri updater for
// the platform keys it natively installs (macOS / Windows-NSIS).
export async function verifyUpdateSignature(
path: string,
signature: string,
pubKey: string,
): Promise<boolean> {
return invoke<boolean>('verify_update_signature', { path, signature, pubKey });
}
export interface NightlyProgress {
event: 'progress' | 'finished';
downloaded: number;
contentLength: number;
}
export async function installNightlyUpdate(
endpoint: string,
onProgress?: (p: NightlyProgress) => void,
): Promise<void> {
const channel = new Channel<NightlyProgress>();
if (onProgress) channel.onmessage = onProgress;
await invoke<void>('install_nightly_update', { endpoint, channel });
}
+42
View File
@@ -1,5 +1,47 @@
import semver from 'semver';
import packageJson from '../../package.json';
export const getAppVersion = () => {
return packageJson.version;
};
export interface ParsedUpdateVersion {
base: string; // "X.Y.Z"
stamp: number | null; // YYYYMMDDHH, or null when not a nightly
isNightly: boolean;
}
// A nightly version is `<base>-<YYYYMMDDHH>`: a single, pure-10-digit
// prerelease identifier. Anything else (e.g. `-rc.1`, `-2026`) is treated as a
// non-nightly base version.
export const parseUpdateVersion = (version: string): ParsedUpdateVersion | null => {
const parsed = semver.parse(version);
if (!parsed) return null;
const base = `${parsed.major}.${parsed.minor}.${parsed.patch}`;
let stamp: number | null = null;
if (parsed.prerelease.length === 1) {
const id = String(parsed.prerelease[0]);
if (/^\d{10}$/.test(id)) {
stamp = Number(id);
}
}
return { base, stamp, isNightly: stamp !== null };
};
// Base-aware "is candidate newer than current?" used by both the nightly channel
// check and (mirrored in Rust) the Tauri updater version_comparator.
// Rule: higher X.Y.Z core wins; on equal core a nightly outranks the matching
// stable (it was built after it) but never the reverse; two nightlies compare by
// stamp.
export const isUpdateNewer = (candidate: string, current: string): boolean => {
const c = parseUpdateVersion(candidate);
const cur = parseUpdateVersion(current);
if (!c || !cur) return false;
if (c.base !== cur.base) {
return semver.compare(c.base, cur.base) > 0;
}
if (c.isNightly && !cur.isNightly) return true;
if (!c.isNightly && cur.isNightly) return false;
if (c.isNightly && cur.isNightly) return (c.stamp as number) > (cur.stamp as number);
return false;
};