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
@@ -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>