Show update progress as a title-bar bar instead of a popout.

This commit is contained in:
2026-07-23 15:18:24 +10:00
parent 9589680a81
commit d338e1c35e
2 changed files with 259 additions and 241 deletions

View File

@@ -1,138 +1,136 @@
import React, { useState, useEffect } from 'react';
import { Box, Spinner, Text, Menu, PopOut, Button, config } from 'folds';
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 [updateAvailable, setUpdateAvailable] = useState(false);
const [phase, setPhase] = useState<UpdaterPhase>('idle');
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [downloading, setDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [updateReady, setUpdateReady] = useState(false);
const [checking, setChecking] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number; width: number; height: number } | undefined>(undefined);
const [isMock, setIsMock] = useState(false);
const [hovered, setHovered] = useState(false);
useEffect(() => {
// Check if we're in Electron environment
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
return;
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);
}
const { updater } = (window as any).electron;
// Listen for update available
updater.onUpdateAvailable((info: UpdateInfo) => {
console.log('Update available:', info.version);
setUpdateAvailable(true);
setUpdateInfo(info);
setDownloading(false);
setUpdateReady(false);
setChecking(false);
});
// Listen for download progress
updater.onUpdateDownloadProgress((progress: DownloadProgress) => {
setDownloadProgress(Math.round(progress.percent));
});
// Listen for update downloaded
updater.onUpdateDownloaded((info: UpdateInfo) => {
console.log('Update downloaded:', info.version);
setDownloading(false);
setUpdateReady(true);
});
// Cleanup - IPC listeners don't need manual cleanup in this case
return undefined;
}, []);
const handleCheckForUpdates = async () => {
setChecking(true);
const handleCheck = useCallback(async () => {
setPhase('checking');
try {
const result = await (window as any).electron.updater.checkForUpdates();
console.log('Update check result:', result);
// Handle error response (including dev mode error)
if (!result.success) {
console.warn('Update check failed:', result.error);
setChecking(false);
return;
if (!result?.success) {
setPhase('idle');
}
// If no update found, show feedback briefly
setTimeout(() => {
if (!updateAvailable) {
setChecking(false);
}
}, 2000);
} catch (error) {
console.error('Failed to check for updates:', error);
setChecking(false);
// available event will advance phase; if nothing comes, fall back
window.setTimeout(() => {
setPhase((current) => (current === 'checking' ? 'idle' : current));
}, 4000);
} catch {
setPhase('idle');
}
};
}, []);
const handleDownload = async () => {
setDownloading(true);
const handleDownload = useCallback(async () => {
setPhase('downloading');
setDownloadProgress(0);
try {
await (window as any).electron.updater.downloadUpdate();
} catch (error) {
console.error('Failed to download update:', error);
setDownloading(false);
} catch {
setPhase('available');
}
setMenuAnchor(undefined);
};
}, []);
const handleInstall = async () => {
const handleInstall = useCallback(async () => {
try {
await (window as any).electron.updater.installUpdate();
} catch (error) {
console.error('Failed to install update:', error);
if (isMock) {
setPhase('idle');
setUpdateInfo(null);
setDownloadProgress(0);
}
} catch {
// keep ready state
}
};
}, [isMock]);
const handleMenuToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
if (menuAnchor) {
setMenuAnchor(undefined);
} else {
const rect = event.currentTarget.getBoundingClientRect();
setMenuAnchor({ x: rect.x, y: rect.y, width: rect.width, height: rect.height });
}
};
const handleDismiss = useCallback(() => {
setPhase('idle');
setUpdateInfo(null);
setDownloadProgress(0);
}, []);
// Don't render anything if not in Electron
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
return null;
}
// Show check button if no update status
if (!updateAvailable && !updateReady && !checking) {
if (phase === 'idle') {
return (
<div
className={css.CheckButtonContainer}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={css.IdleSlot}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<button
className={css.CheckButton}
data-visible={isHovered}
onClick={handleCheckForUpdates}
aria-label="Check for updates"
title="Check for updates"
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="16" height="16" viewBox="0 0 16 16" fill="none">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M8 2V10M8 10L5 7M8 10L11 7"
stroke="currentColor"
@@ -140,109 +138,75 @@ export function UpdateNotification() {
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 14H13"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<path d="M3 14H13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</div>
);
}
// Show checking state
if (checking) {
if (phase === 'checking') {
return (
<button className={css.UpdateButton} disabled type="button">
<Spinner variant="Secondary" size="50" />
</button>
<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>
);
}
// Show update available/ready state
if (!updateAvailable && !updateReady) {
return null;
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>
);
}
return (
<>
<button
className={css.UpdateButton}
onClick={handleMenuToggle}
aria-label={updateReady ? 'Update ready' : 'Update available'}
title={updateReady ? 'Update downloaded - click to install' : 'New version available'}
type="button"
>
{downloading ? (
<Spinner variant="Secondary" size="50" />
) : (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<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>
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>
);
}
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
offset={8}
content={
<Menu className={css.UpdateMenu}>
<Box direction="Column" gap="200" style={{ padding: config.space.S300 }}>
<Box direction="Column" gap="100">
<Text size="H5" priority="400">
{updateReady ? 'Update Ready' : 'Update Available'}
</Text>
{updateInfo && (
<Text size="T200" priority="300">
Version {updateInfo.version}
</Text>
)}
</Box>
{updateReady ? (
<Button
variant="Primary"
size="400"
onClick={handleInstall}
fill="Solid"
>
<Text size="B400">Install and Restart</Text>
</Button>
) : (
<Button
variant="Primary"
size="400"
onClick={handleDownload}
disabled={downloading}
fill="Solid"
>
<Text size="B400">
{downloading ? `Downloading ${downloadProgress}%` : 'Download Update'}
</Text>
</Button>
)}
</Box>
</Menu>
}
// 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"
>
{null}
</PopOut>
</>
×
</button>
</Box>
);
}