Files
cinny/src/app/components/update-notification/UpdateNotification.tsx

213 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}