forked from akai/readest
741 lines
26 KiB
TypeScript
741 lines
26 KiB
TypeScript
import clsx from 'clsx';
|
|
import semver from 'semver';
|
|
import Image from 'next/image';
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { useEffect, useState } from 'react';
|
|
import { type as osType, arch as osArch } from '@tauri-apps/plugin-os';
|
|
import { check, Update } from '@tauri-apps/plugin-updater';
|
|
import { relaunch, exit } from '@tauri-apps/plugin-process';
|
|
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
|
import { Command } from '@tauri-apps/plugin-shell';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { desktopDir } from '@tauri-apps/api/path';
|
|
import { isTauriAppPlatform } from '@/services/environment';
|
|
import { useTranslator } from '@/hooks/useTranslator';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { useSearchParams } from 'next/navigation';
|
|
import { getAppVersion } from '@/utils/version';
|
|
import { tauriDownload } from '@/utils/transfer';
|
|
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 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';
|
|
|
|
interface ReleaseNotes {
|
|
releases: Record<
|
|
string,
|
|
{
|
|
date: string;
|
|
notes: string[];
|
|
}
|
|
>;
|
|
}
|
|
|
|
interface Changelog {
|
|
version: string;
|
|
date: string;
|
|
notes: string[];
|
|
}
|
|
|
|
type DownloadEvent =
|
|
| {
|
|
event: 'Started';
|
|
data: {
|
|
contentLength?: number;
|
|
};
|
|
}
|
|
| {
|
|
event: 'Progress';
|
|
data: {
|
|
chunkLength: number;
|
|
};
|
|
}
|
|
| {
|
|
event: 'Finished';
|
|
};
|
|
|
|
interface GenericUpdate {
|
|
currentVersion: string;
|
|
version: string;
|
|
date?: string;
|
|
body?: string;
|
|
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');
|
|
const { translate } = useTranslator({
|
|
provider: 'azure',
|
|
sourceLang: 'AUTO',
|
|
targetLang,
|
|
});
|
|
const { appService } = useEnv();
|
|
const searchParams = useSearchParams();
|
|
const currentVersion = getAppVersion();
|
|
const [newVersion, setNewVersion] = useState(
|
|
latestVersion ?? searchParams?.get('latestVersion') ?? '',
|
|
);
|
|
const [oldVersion, setOldVersion] = useState(
|
|
lastVersion ?? searchParams?.get('lastVersion') ?? '',
|
|
);
|
|
const [update, setUpdate] = useState<GenericUpdate | Update | null>(null);
|
|
const [changelogs, setChangelogs] = useState<Changelog[]>([]);
|
|
const [progress, setProgress] = useState<number | null>(null);
|
|
const [contentLength, setContentLength] = useState<number | null>(null);
|
|
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());
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const checkDesktopUpdate = async () => {
|
|
const update = await check();
|
|
if (update) {
|
|
setUpdate(update);
|
|
}
|
|
};
|
|
const checkAndroidUpdate = async () => {
|
|
if (!appService) return;
|
|
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
|
|
const response = await fetch(READEST_UPDATER_FILE);
|
|
const data = await response.json();
|
|
if (semver.gt(data.version, currentVersion)) {
|
|
const OS_ARCH = osArch();
|
|
const platformKey = OS_ARCH === 'aarch64' ? 'android-arm64' : 'android-universal';
|
|
const arch = OS_ARCH === 'aarch64' ? 'arm64' : 'universal';
|
|
const downloadUrl = data.platforms[platformKey]?.url as string;
|
|
const apkFilePath = await appService.resolveFilePath(
|
|
`Readest_${data.version}_${arch}.apk`,
|
|
'Cache',
|
|
);
|
|
setUpdate({
|
|
currentVersion,
|
|
version: data.version,
|
|
date: data.pub_date,
|
|
body: data.notes,
|
|
downloadAndInstall: async (onEvent) => {
|
|
await new Promise<void>(async (resolve, reject) => {
|
|
let downloaded = 0;
|
|
let total = 0;
|
|
await tauriDownload(downloadUrl, apkFilePath, (progress) => {
|
|
if (!onEvent) return;
|
|
if (!total && progress.total) {
|
|
total = progress.total;
|
|
onEvent({
|
|
event: 'Started',
|
|
data: { contentLength: total },
|
|
});
|
|
} else if (downloaded > 0 && progress.progress === progress.total) {
|
|
console.log('APK downloaded to', apkFilePath);
|
|
onEvent?.({ event: 'Finished' });
|
|
setTimeout(() => {
|
|
resolve();
|
|
}, 1000);
|
|
}
|
|
|
|
onEvent({
|
|
event: 'Progress',
|
|
data: { chunkLength: progress.progress - downloaded },
|
|
});
|
|
downloaded = progress.progress;
|
|
}).catch((error) => {
|
|
console.error('Download failed:', error);
|
|
reject(error);
|
|
});
|
|
});
|
|
|
|
const res = await installPackage({
|
|
path: apkFilePath,
|
|
});
|
|
if (res.success) {
|
|
console.log('APK installed successfully');
|
|
} else {
|
|
console.error('Failed to install APK:', res.error);
|
|
}
|
|
},
|
|
} as GenericUpdate);
|
|
}
|
|
};
|
|
const downloadWithProgress = (
|
|
downloadUrl: string,
|
|
filePath: string,
|
|
onEvent?: (progress: DownloadEvent) => void,
|
|
): Promise<void> => {
|
|
return new Promise<void>(async (resolve, reject) => {
|
|
let downloaded = 0;
|
|
let total = 0;
|
|
await tauriDownload(downloadUrl, filePath, (progress) => {
|
|
if (!onEvent) return;
|
|
if (!total && progress.total) {
|
|
total = progress.total;
|
|
onEvent({
|
|
event: 'Started',
|
|
data: { contentLength: total },
|
|
});
|
|
} else if (downloaded > 0 && progress.progress === progress.total) {
|
|
console.log('File downloaded to', filePath);
|
|
onEvent?.({ event: 'Finished' });
|
|
setTimeout(() => {
|
|
resolve();
|
|
}, 1000);
|
|
}
|
|
|
|
onEvent({
|
|
event: 'Progress',
|
|
data: { chunkLength: progress.progress - downloaded },
|
|
});
|
|
downloaded = progress.progress;
|
|
}).catch((error) => {
|
|
console.error('Download failed:', error);
|
|
reject(error);
|
|
});
|
|
});
|
|
};
|
|
const checkWindowsPortableUpdate = async () => {
|
|
if (!appService) return;
|
|
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
|
|
const response = await fetch(READEST_UPDATER_FILE);
|
|
const data = await response.json();
|
|
if (semver.gt(data.version, currentVersion)) {
|
|
const OS_ARCH = osArch();
|
|
const platformKey =
|
|
OS_ARCH === 'x86_64' ? 'windows-x86_64-portable' : 'windows-aarch64-portable';
|
|
const arch = OS_ARCH === 'x86_64' ? 'x64' : 'arm64';
|
|
const downloadUrl = data.platforms[platformKey]?.url as string;
|
|
const execDir = await invoke<string>('get_executable_dir');
|
|
const exeFileName = `Readest_${data.version}_${arch}-portable.exe`;
|
|
const exeFilePath = await join(execDir, exeFileName);
|
|
setUpdate({
|
|
currentVersion,
|
|
version: data.version,
|
|
date: data.pub_date,
|
|
body: data.notes,
|
|
downloadAndInstall: async (onEvent) => {
|
|
await downloadWithProgress(downloadUrl, exeFilePath, onEvent);
|
|
try {
|
|
console.log('Launching new executable:', exeFilePath);
|
|
const command = Command.create('start-readest', ['/C', 'start', '', exeFilePath]);
|
|
await command.spawn();
|
|
console.log('New executable launched, exiting current app...');
|
|
setTimeout(async () => {
|
|
await exit(0);
|
|
}, 500);
|
|
} catch (error) {
|
|
console.error('Failed to launch new executable:', error);
|
|
}
|
|
},
|
|
} as GenericUpdate);
|
|
}
|
|
};
|
|
const checkAppImageUpdate = async () => {
|
|
if (!appService) return;
|
|
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
|
|
const response = await fetch(READEST_UPDATER_FILE);
|
|
const data = await response.json();
|
|
if (semver.gt(data.version, currentVersion)) {
|
|
const OS_ARCH = osArch();
|
|
const platformKey =
|
|
OS_ARCH === 'x86_64' ? 'linux-x86_64-appimage' : 'linux-aarch64-appimage';
|
|
const arch = OS_ARCH === 'x86_64' ? 'x86_64' : 'aarch64';
|
|
const downloadUrl = data.platforms[platformKey]?.url as string;
|
|
const appImageFileName = `Readest_${data.version}_${arch}.AppImage`;
|
|
const appImageFilePath = await join(await desktopDir(), appImageFileName);
|
|
setUpdate({
|
|
currentVersion,
|
|
version: data.version,
|
|
date: data.pub_date,
|
|
body: data.notes,
|
|
downloadAndInstall: async (onEvent) => {
|
|
await downloadWithProgress(downloadUrl, appImageFilePath, onEvent);
|
|
try {
|
|
// Make the AppImage executable
|
|
const chmodCommand = Command.create('chmod-appimage', ['+x', appImageFilePath]);
|
|
await chmodCommand.execute();
|
|
console.log('AppImage made executable:', appImageFilePath);
|
|
|
|
// Launch the new AppImage
|
|
console.log('Launching new AppImage:', appImageFilePath);
|
|
const launchCommand = Command.create('launch-appimage', [appImageFilePath]);
|
|
await launchCommand.spawn();
|
|
console.log('New AppImage launched, exiting current app...');
|
|
setTimeout(async () => {
|
|
await exit(0);
|
|
}, 500);
|
|
} catch (error) {
|
|
console.error('Failed to launch new AppImage:', error);
|
|
}
|
|
},
|
|
} 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();
|
|
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, nightlyUpdate]);
|
|
|
|
useEffect(() => {
|
|
if (latestVersion) {
|
|
setNewVersion(latestVersion);
|
|
}
|
|
if (lastVersion) {
|
|
setOldVersion(lastVersion);
|
|
}
|
|
if (!checkUpdate) {
|
|
setUpdate({
|
|
currentVersion,
|
|
version: latestVersion,
|
|
date: '',
|
|
body: '',
|
|
} as GenericUpdate);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [latestVersion, lastVersion, checkUpdate]);
|
|
|
|
useEffect(() => {
|
|
const fetchChangelogs = async (fromVersion: string): Promise<Changelog[]> => {
|
|
try {
|
|
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
|
|
const res = await fetch(READEST_CHANGELOG_FILE);
|
|
const data: ReleaseNotes = await res.json();
|
|
const releases = data.releases;
|
|
|
|
let entries = Object.entries(releases)
|
|
.filter(([ver]) => semver.gt(ver, fromVersion))
|
|
.sort(([a], [b]) => semver.rcompare(a, b));
|
|
entries = entries.length ? entries : Object.entries(releases).slice(0, 3);
|
|
return entries.map(([version, info]) => ({
|
|
version,
|
|
date: new Date(info.date).toLocaleDateString(),
|
|
notes: info.notes,
|
|
}));
|
|
} catch (error) {
|
|
console.error('Failed to fetch changelog:', error);
|
|
return [];
|
|
}
|
|
};
|
|
const parseNumberedList = (input: string): string[] => {
|
|
return input
|
|
.split(/(?:^|\s)\d+\.\s/)
|
|
.map((item) => item.trim())
|
|
.filter((item) => item.length > 0);
|
|
};
|
|
const updateChangelogs = async (update: GenericUpdate) => {
|
|
setNewVersion(update.version);
|
|
let changelogs = await fetchChangelogs(oldVersion || currentVersion);
|
|
if (changelogs.length === 0 && update.date && update.body) {
|
|
changelogs = [
|
|
{
|
|
version: update.version,
|
|
date: new Date(update.date).toLocaleDateString(),
|
|
notes: parseNumberedList(update.body ?? ''),
|
|
},
|
|
];
|
|
}
|
|
if (!targetLang.toLowerCase().startsWith('en')) {
|
|
for (const entry of changelogs) {
|
|
try {
|
|
entry.notes = await translate(entry.notes, { useCache: true });
|
|
} catch (error) {
|
|
console.log('Failed to translate changelog:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
setChangelogs(changelogs);
|
|
setLastShownReleaseNotesVersion(newVersion);
|
|
};
|
|
if (update) {
|
|
updateChangelogs(update);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [update]);
|
|
|
|
useEffect(() => {
|
|
setIsMounted(true);
|
|
}, []);
|
|
|
|
const handleDownloadInstall = async () => {
|
|
if (!update) {
|
|
return;
|
|
}
|
|
let downloaded = 0;
|
|
let contentLength = 0;
|
|
let lastLogged = 0;
|
|
setProgress(0);
|
|
setIsDownloading(true);
|
|
await update.downloadAndInstall?.((event) => {
|
|
switch (event.event) {
|
|
case 'Started':
|
|
contentLength = event.data.contentLength!;
|
|
setContentLength(contentLength);
|
|
break;
|
|
case 'Progress':
|
|
downloaded += event.data.chunkLength;
|
|
setDownloaded(downloaded);
|
|
// 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}`);
|
|
lastLogged = downloaded;
|
|
}
|
|
break;
|
|
case 'Finished':
|
|
console.log('download finished');
|
|
setProgress(100);
|
|
break;
|
|
}
|
|
});
|
|
console.log('package installed');
|
|
if (!appService?.isAndroidApp && process.env.NODE_ENV === 'production') {
|
|
await relaunch();
|
|
}
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
return (
|
|
<div className='bg-base-100 flex min-h-screen justify-center'>
|
|
<div className='flex w-full max-w-2xl flex-col gap-4'>
|
|
<div className='flex flex-col justify-center gap-4 sm:flex-row sm:items-start'>
|
|
<div className='flex items-center justify-center'>
|
|
<Image src='/icon.png' alt='Logo' className='h-20 w-20' width={64} height={64} />
|
|
</div>
|
|
|
|
{checkUpdate ? (
|
|
<div className='text-base-content flex-grow text-sm'>
|
|
<h2 className='mb-4 text-center font-bold sm:text-start'>
|
|
{_('A new version of Readest is available!')}
|
|
</h2>
|
|
<p className='mb-2'>
|
|
{_('Readest {{newVersion}} is available (installed version {{currentVersion}}).', {
|
|
newVersion: formatVersionLabel(newVersion),
|
|
currentVersion,
|
|
})}
|
|
</p>
|
|
<p className='mb-2'>{_('Download and install now?')}</p>
|
|
|
|
<div className='flex w-full flex-row items-center justify-end gap-4'>
|
|
{progress !== null && (
|
|
<div className='flex flex-grow flex-col'>
|
|
<progress
|
|
className='progress my-1 h-4 w-full'
|
|
value={progress}
|
|
max='100'
|
|
></progress>
|
|
<p className='text-base-content/75 flex items-center justify-center text-sm'>
|
|
{progress < 100
|
|
? _('Downloading {{downloaded}} of {{contentLength}}', {
|
|
downloaded: downloaded
|
|
? `${Math.floor(downloaded / 1024 / 1024)} MB`
|
|
: '0 MB',
|
|
contentLength: contentLength
|
|
? `${Math.floor(contentLength / 1024 / 1024)} MB`
|
|
: '0 MB',
|
|
})
|
|
: _('Download finished')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className={clsx('card-actions', isDownloading && 'hidden sm:flex')}>
|
|
<button
|
|
className={clsx(
|
|
'btn btn-warning text-base-100 px-6 font-bold',
|
|
(!update || isDownloading) && 'btn-disabled',
|
|
)}
|
|
onClick={handleDownloadInstall}
|
|
>
|
|
{_('DOWNLOAD & INSTALL')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className='text-base-content flex h-full flex-grow flex-col text-sm sm:flex-row'>
|
|
<div className='flex flex-col items-center justify-center gap-4 p-1 sm:items-start sm:gap-2'>
|
|
<h2 className='text-center font-bold sm:text-start'>
|
|
{_('Version {{version}}', { version: currentVersion })}
|
|
</h2>
|
|
|
|
{changelogs.length > 0 && semver.gt(changelogs[0]!.version, currentVersion) ? (
|
|
<div className='flex gap-2'>
|
|
{(appService?.isIOSApp || appService?.isMacOSApp) && (
|
|
<Link
|
|
href='https://apps.apple.com/app/id6738622779'
|
|
target='_blank'
|
|
rel='noopener noreferrer'
|
|
className='btn btn-primary btn-sm'
|
|
>
|
|
{_('Check Update')}
|
|
</Link>
|
|
)}
|
|
|
|
{appService?.isAndroidApp && (
|
|
<Link
|
|
href='https://play.google.com/store/apps/details?id=com.bilingify.readest'
|
|
target='_blank'
|
|
rel='noopener noreferrer'
|
|
className='btn btn-primary btn-sm'
|
|
>
|
|
{_('Check Update')}
|
|
</Link>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className='flex'>
|
|
<p className='text-sm font-bold'>{_('Already the latest version')}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className='text-base-content text-sm'>
|
|
<h3 className='mb-2 font-bold'>{_('Changelog')}</h3>
|
|
<div className='not-eink:bg-base-200 not-eink:px-4 mb-4 rounded-lg pb-2 pt-4'>
|
|
{changelogs.length > 0 ? (
|
|
changelogs.map((entry: Changelog) => (
|
|
<div key={entry.version} className='mb-4'>
|
|
<h4 className='mb-2 font-bold'>
|
|
{entry.version} ({entry.date})
|
|
</h4>
|
|
<ul className='list-disc space-y-1 ps-6 text-sm'>
|
|
{entry.notes.map((note: string, i: number) => (
|
|
<li key={i}>{note}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className='flex h-56 w-full flex-col gap-4'>
|
|
<div className='skeleton h-4 w-28'></div>
|
|
<div className='skeleton h-4 w-full'></div>
|
|
<div className='skeleton h-4 w-full'></div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const setUpdaterWindowVisible = (
|
|
visible: boolean,
|
|
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, nightlyUpdate },
|
|
});
|
|
dialog.dispatchEvent(event);
|
|
}
|
|
};
|
|
|
|
export const UpdaterWindow = () => {
|
|
const _ = useTranslation();
|
|
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, nightlyUpdate } = event.detail;
|
|
setIsOpen(visible);
|
|
setCheckUpdate(checkUpdate);
|
|
setNightlyUpdate(nightlyUpdate);
|
|
if (latestVersion) {
|
|
setLatestVersion(latestVersion);
|
|
}
|
|
if (lastVersion) {
|
|
setLastVersion(lastVersion);
|
|
}
|
|
};
|
|
|
|
const el = document.getElementById('updater_window');
|
|
if (el) {
|
|
el.addEventListener('setDialogVisibility', handleCustomEvent as EventListener);
|
|
}
|
|
|
|
return () => {
|
|
if (el) {
|
|
el.removeEventListener('setDialogVisibility', handleCustomEvent as EventListener);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<Dialog
|
|
id='updater_window'
|
|
isOpen={isOpen}
|
|
title={checkUpdate ? _('Software Update') : _("What's New in Readest")}
|
|
onClose={() => setIsOpen(false)}
|
|
boxClassName='sm:!w-[75%] sm:h-auto sm:!max-h-[85vh] sm:!max-w-2xl'
|
|
>
|
|
{isOpen && (
|
|
<UpdaterContent
|
|
latestVersion={latestVersion ?? undefined}
|
|
lastVersion={lastVersion ?? undefined}
|
|
checkUpdate={checkUpdate}
|
|
nightlyUpdate={nightlyUpdate}
|
|
/>
|
|
)}
|
|
</Dialog>
|
|
);
|
|
};
|