('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 (
+
+ );
+ }
+
+ if (!newVersion) {
return null;
}
@@ -438,7 +557,7 @@ export const UpdaterContent = ({
{_('Readest {{newVersion}} is available (installed version {{currentVersion}}).', {
- newVersion,
+ newVersion: formatVersionLabel(newVersion),
currentVersion,
})}
@@ -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(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}
/>
)}
diff --git a/apps/readest-app/src/helpers/updater.ts b/apps/readest-app/src/helpers/updater.ts
index 9082b64b..6148d296 100644
--- a/apps/readest-app/src/helpers/updater.ts
+++ b/apps/readest-app/src/helpers/updater.ts
@@ -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;
+}
+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 => {
+ 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 => {
+ 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 => {
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) {
diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts
index 875e1bbf..4c09ebcf 100644
--- a/apps/readest-app/src/services/constants.ts
+++ b/apps/readest-app/src/services/constants.ts
@@ -107,6 +107,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial = {
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)';
diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts
index 489362a9..217c4ba4 100644
--- a/apps/readest-app/src/types/settings.ts
+++ b/apps/readest-app/src/types/settings.ts
@@ -281,6 +281,7 @@ export interface SystemSettings {
alwaysOnTop: boolean;
openBookInNewWindow: boolean;
autoCheckUpdates: boolean;
+ updateChannel: 'stable' | 'nightly';
screenWakeLock: boolean;
screenBrightness: number;
autoScreenBrightness: boolean;
diff --git a/apps/readest-app/src/utils/bridge.ts b/apps/readest-app/src/utils/bridge.ts
index df17bf6f..0b694713 100644
--- a/apps/readest-app/src/utils/bridge.ts
+++ b/apps/readest-app/src/utils/bridge.ts
@@ -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 {
export async function isSyncKeychainAvailable(): Promise {
return invoke('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 {
+ return invoke('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 {
+ const channel = new Channel();
+ if (onProgress) channel.onmessage = onProgress;
+ await invoke('install_nightly_update', { endpoint, channel });
+}
diff --git a/apps/readest-app/src/utils/version.ts b/apps/readest-app/src/utils/version.ts
index bbe12220..7681ed27 100644
--- a/apps/readest-app/src/utils/version.ts
+++ b/apps/readest-app/src/utils/version.ts
@@ -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 `-`: 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;
+};