213 lines
5.8 KiB
TypeScript
213 lines
5.8 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import { Box, Text } from 'folds';
|
||
import * as css from './UpdateNotification.css';
|
||
|
||
interface UpdateInfo {
|
||
version: string;
|
||
releaseNotes?: string;
|
||
releaseDate?: string;
|
||
mock?: boolean;
|
||
}
|
||
|
||
interface DownloadProgress {
|
||
percent: number;
|
||
transferred: number;
|
||
total: number;
|
||
mock?: boolean;
|
||
}
|
||
|
||
type UpdaterPhase = 'idle' | 'checking' | 'available' | 'downloading' | 'ready';
|
||
|
||
export function UpdateNotification() {
|
||
const [phase, setPhase] = useState<UpdaterPhase>('idle');
|
||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||
const [isMock, setIsMock] = useState(false);
|
||
const [hovered, setHovered] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const electron = (window as any).electron;
|
||
if (!electron?.updater) return undefined;
|
||
|
||
const { updater } = electron;
|
||
|
||
updater.isMock?.().then((result: { success?: boolean; data?: { mock?: boolean } }) => {
|
||
if (result?.data?.mock) setIsMock(true);
|
||
}).catch(() => {});
|
||
|
||
updater.onUpdateAvailable((info: UpdateInfo) => {
|
||
setUpdateInfo(info);
|
||
if (info.mock) setIsMock(true);
|
||
setPhase('available');
|
||
setDownloadProgress(0);
|
||
});
|
||
|
||
updater.onUpdateDownloadProgress((progress: DownloadProgress) => {
|
||
setPhase('downloading');
|
||
setDownloadProgress(Math.min(100, Math.round(progress.percent)));
|
||
});
|
||
|
||
updater.onUpdateDownloaded((info: UpdateInfo) => {
|
||
setUpdateInfo(info);
|
||
setPhase('ready');
|
||
setDownloadProgress(100);
|
||
});
|
||
|
||
const onNotAvailable = () => {
|
||
setPhase('idle');
|
||
setUpdateInfo(null);
|
||
setDownloadProgress(0);
|
||
};
|
||
// Optional channel — ignore if preload doesn't expose a dedicated listener
|
||
if (electron.updater.onUpdateNotAvailable) {
|
||
electron.updater.onUpdateNotAvailable(onNotAvailable);
|
||
}
|
||
|
||
return undefined;
|
||
}, []);
|
||
|
||
const handleCheck = useCallback(async () => {
|
||
setPhase('checking');
|
||
try {
|
||
const result = await (window as any).electron.updater.checkForUpdates();
|
||
if (!result?.success) {
|
||
setPhase('idle');
|
||
}
|
||
// available event will advance phase; if nothing comes, fall back
|
||
window.setTimeout(() => {
|
||
setPhase((current) => (current === 'checking' ? 'idle' : current));
|
||
}, 4000);
|
||
} catch {
|
||
setPhase('idle');
|
||
}
|
||
}, []);
|
||
|
||
const handleDownload = useCallback(async () => {
|
||
setPhase('downloading');
|
||
setDownloadProgress(0);
|
||
try {
|
||
await (window as any).electron.updater.downloadUpdate();
|
||
} catch {
|
||
setPhase('available');
|
||
}
|
||
}, []);
|
||
|
||
const handleInstall = useCallback(async () => {
|
||
try {
|
||
await (window as any).electron.updater.installUpdate();
|
||
if (isMock) {
|
||
setPhase('idle');
|
||
setUpdateInfo(null);
|
||
setDownloadProgress(0);
|
||
}
|
||
} catch {
|
||
// keep ready state
|
||
}
|
||
}, [isMock]);
|
||
|
||
const handleDismiss = useCallback(() => {
|
||
setPhase('idle');
|
||
setUpdateInfo(null);
|
||
setDownloadProgress(0);
|
||
}, []);
|
||
|
||
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
|
||
return null;
|
||
}
|
||
|
||
if (phase === 'idle') {
|
||
return (
|
||
<div
|
||
className={css.IdleSlot}
|
||
onMouseEnter={() => setHovered(true)}
|
||
onMouseLeave={() => setHovered(false)}
|
||
>
|
||
<button
|
||
type="button"
|
||
className={css.GhostCheck}
|
||
data-visible={hovered || isMock ? 'true' : undefined}
|
||
onClick={handleCheck}
|
||
title={isMock ? 'Check for updates (mock)' : 'Check for updates'}
|
||
aria-label="Check for updates"
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||
<path
|
||
d="M8 2V10M8 10L5 7M8 10L11 7"
|
||
stroke="currentColor"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
<path d="M3 14H13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (phase === 'checking') {
|
||
return (
|
||
<div className={css.Bar} title="Checking for updates…">
|
||
<div className={css.BarTrack}>
|
||
<div className={css.BarIndeterminate} />
|
||
</div>
|
||
<Text className={css.BarLabel} size="L400">
|
||
Checking…
|
||
</Text>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (phase === 'downloading') {
|
||
return (
|
||
<div
|
||
className={css.Bar}
|
||
title={`Downloading update${updateInfo ? ` ${updateInfo.version}` : ''}… ${downloadProgress}%`}
|
||
>
|
||
<div className={css.BarTrack}>
|
||
<div className={css.BarFill} style={{ width: `${downloadProgress}%` }} />
|
||
</div>
|
||
<Text className={css.BarLabel} size="L400">
|
||
{downloadProgress}%
|
||
</Text>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (phase === 'ready') {
|
||
return (
|
||
<Box className={css.Chip} alignItems="Center" gap="100">
|
||
<Text className={css.ChipText} size="L400" truncate>
|
||
{isMock ? 'Mock ready' : 'Update ready'}
|
||
{updateInfo ? ` · ${updateInfo.version}` : ''}
|
||
</Text>
|
||
<button type="button" className={css.ChipAction} onClick={handleInstall}>
|
||
Restart
|
||
</button>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// available
|
||
return (
|
||
<Box className={css.Chip} alignItems="Center" gap="100">
|
||
<Text className={css.ChipText} size="L400" truncate>
|
||
{isMock ? 'Mock update' : 'Update'}
|
||
{updateInfo ? ` ${updateInfo.version}` : ''}
|
||
</Text>
|
||
<button type="button" className={css.ChipAction} onClick={handleDownload}>
|
||
Download
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={css.ChipDismiss}
|
||
onClick={handleDismiss}
|
||
aria-label="Dismiss update"
|
||
title="Dismiss"
|
||
>
|
||
×
|
||
</button>
|
||
</Box>
|
||
);
|
||
}
|