feat(updater): nightly update channel (Android/Windows/macOS/Linux) (#4577)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)';
|
||||
|
||||
@@ -281,6 +281,7 @@ export interface SystemSettings {
|
||||
alwaysOnTop: boolean;
|
||||
openBookInNewWindow: boolean;
|
||||
autoCheckUpdates: boolean;
|
||||
updateChannel: 'stable' | 'nightly';
|
||||
screenWakeLock: boolean;
|
||||
screenBrightness: number;
|
||||
autoScreenBrightness: boolean;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user