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,95 +1,149 @@
import { style } from '@vanilla-extract/css'; import { keyframes, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds'; import { color, config, toRem } from 'folds';
export const CheckButtonContainer = style({ const indeterminate = keyframes({
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(250%)' },
});
export const IdleSlot = style({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
height: '32px', height: '100%',
width: '32px', WebkitAppRegion: 'no-drag',
flexShrink: 0,
}); });
export const CheckButton = style({ export const GhostCheck = style({
all: 'unset', all: 'unset',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: 0, width: toRem(28),
height: toRem(28),
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
cursor: 'pointer', color: color.Surface.OnContainer,
color: color.Secondary.Main,
backgroundColor: 'transparent',
transition: 'opacity 0.2s, background-color 0.15s',
height: '32px',
width: '32px',
opacity: 0, opacity: 0,
WebkitAppRegion: 'no-drag', cursor: 'pointer',
flexShrink: 0, transition: 'opacity 0.15s ease, background-color 0.15s ease',
selectors: { selectors: {
'&[data-visible="true"]': { '&[data-visible="true"]': {
opacity: 0.7, opacity: 0.65,
},
'&:hover': {
opacity: 1,
backgroundColor: color.Surface.ContainerHover,
}, },
}, },
':hover': {
backgroundColor: color.Surface.ContainerHover,
opacity: 1,
},
':active': {
backgroundColor: color.Surface.ContainerActive,
},
':focus-visible': {
outline: `2px solid ${color.Secondary.Main}`,
outlineOffset: '2px',
opacity: 1,
},
}); });
export const UpdateButton = style({ export const Bar = style({
all: 'unset',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', gap: config.space.S200,
padding: 0, height: toRem(22),
minWidth: toRem(120),
maxWidth: toRem(200),
padding: `0 ${config.space.S200}`,
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
cursor: 'pointer', backgroundColor: color.SurfaceVariant.Container,
color: color.Success.Main, color: color.SurfaceVariant.OnContainer,
backgroundColor: 'transparent',
transition: 'background-color 0.15s',
height: '32px',
width: '32px',
WebkitAppRegion: 'no-drag', WebkitAppRegion: 'no-drag',
flexShrink: 0, flexShrink: 0,
boxSizing: 'border-box',
});
':hover': { export const BarTrack = style({
backgroundColor: color.Surface.ContainerHover, position: 'relative',
}, flex: 1,
height: toRem(4),
borderRadius: toRem(2),
overflow: 'hidden',
backgroundColor: color.Surface.ContainerLine,
minWidth: toRem(64),
});
':active': { export const BarFill = style({
backgroundColor: color.Surface.ContainerActive, position: 'absolute',
}, inset: '0 auto 0 0',
height: '100%',
borderRadius: 'inherit',
backgroundColor: color.Success.Main,
transition: 'width 120ms linear',
});
':focus-visible': { export const BarIndeterminate = style({
outline: `2px solid ${color.Success.Main}`, position: 'absolute',
outlineOffset: '2px', top: 0,
bottom: 0,
left: 0,
width: '40%',
borderRadius: 'inherit',
backgroundColor: color.Secondary.Main,
animation: `${indeterminate} 1s ease-in-out infinite`,
});
export const BarLabel = style({
flexShrink: 0,
fontVariantNumeric: 'tabular-nums',
opacity: 0.9,
minWidth: toRem(28),
textAlign: 'right',
});
export const Chip = style({
height: toRem(22),
maxWidth: toRem(260),
padding: `0 ${config.space.S100} 0 ${config.space.S200}`,
borderRadius: config.radii.R300,
backgroundColor: color.Success.Container,
color: color.Success.OnContainer,
WebkitAppRegion: 'no-drag',
flexShrink: 0,
boxSizing: 'border-box',
});
export const ChipText = style({
maxWidth: toRem(140),
});
export const ChipAction = style({
all: 'unset',
display: 'inline-flex',
alignItems: 'center',
height: toRem(18),
padding: `0 ${config.space.S200}`,
borderRadius: config.radii.R300,
backgroundColor: color.Success.Main,
color: color.Success.OnMain,
fontSize: toRem(11),
fontWeight: 600,
cursor: 'pointer',
whiteSpace: 'nowrap',
selectors: {
'&:hover': {
filter: 'brightness(1.05)',
},
}, },
}); });
export const UpdateMenu = style({ export const ChipDismiss = style({
minWidth: toRem(280), all: 'unset',
maxWidth: toRem(320), display: 'inline-flex',
backgroundColor: color.Surface.Container, alignItems: 'center',
borderRadius: config.radii.R400, justifyContent: 'center',
boxShadow: config.shadow.E400, width: toRem(18),
border: `1px solid ${color.Surface.ContainerLine}`, height: toRem(18),
}); borderRadius: config.radii.R300,
cursor: 'pointer',
export const ProgressText = style({ opacity: 0.7,
color: color.Success.Main, fontSize: toRem(14),
fontWeight: 500, lineHeight: 1,
minWidth: toRem(35), selectors: {
textAlign: 'center', '&:hover': {
opacity: 1,
backgroundColor: 'rgba(0,0,0,0.08)',
},
},
}); });

View File

@@ -1,138 +1,136 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Box, Spinner, Text, Menu, PopOut, Button, config } from 'folds'; import { Box, Text } from 'folds';
import * as css from './UpdateNotification.css'; import * as css from './UpdateNotification.css';
interface UpdateInfo { interface UpdateInfo {
version: string; version: string;
releaseNotes?: string; releaseNotes?: string;
releaseDate?: string; releaseDate?: string;
mock?: boolean;
} }
interface DownloadProgress { interface DownloadProgress {
percent: number; percent: number;
transferred: number; transferred: number;
total: number; total: number;
mock?: boolean;
} }
type UpdaterPhase = 'idle' | 'checking' | 'available' | 'downloading' | 'ready';
export function UpdateNotification() { export function UpdateNotification() {
const [updateAvailable, setUpdateAvailable] = useState(false); const [phase, setPhase] = useState<UpdaterPhase>('idle');
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null); const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [downloading, setDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0); const [downloadProgress, setDownloadProgress] = useState(0);
const [updateReady, setUpdateReady] = useState(false); const [isMock, setIsMock] = useState(false);
const [checking, setChecking] = useState(false); const [hovered, setHovered] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number; width: number; height: number } | undefined>(undefined);
useEffect(() => { useEffect(() => {
// Check if we're in Electron environment const electron = (window as any).electron;
if (typeof window === 'undefined' || !(window as any).electron?.updater) { if (!electron?.updater) return undefined;
return;
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; return undefined;
// 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
}, []); }, []);
const handleCheckForUpdates = async () => { const handleCheck = useCallback(async () => {
setChecking(true); setPhase('checking');
try { try {
const result = await (window as any).electron.updater.checkForUpdates(); const result = await (window as any).electron.updater.checkForUpdates();
console.log('Update check result:', result); if (!result?.success) {
setPhase('idle');
// Handle error response (including dev mode error)
if (!result.success) {
console.warn('Update check failed:', result.error);
setChecking(false);
return;
} }
// available event will advance phase; if nothing comes, fall back
// If no update found, show feedback briefly window.setTimeout(() => {
setTimeout(() => { setPhase((current) => (current === 'checking' ? 'idle' : current));
if (!updateAvailable) { }, 4000);
setChecking(false); } catch {
} setPhase('idle');
}, 2000);
} catch (error) {
console.error('Failed to check for updates:', error);
setChecking(false);
} }
}; }, []);
const handleDownload = async () => { const handleDownload = useCallback(async () => {
setDownloading(true); setPhase('downloading');
setDownloadProgress(0); setDownloadProgress(0);
try { try {
await (window as any).electron.updater.downloadUpdate(); await (window as any).electron.updater.downloadUpdate();
} catch (error) { } catch {
console.error('Failed to download update:', error); setPhase('available');
setDownloading(false);
} }
setMenuAnchor(undefined); }, []);
};
const handleInstall = async () => { const handleInstall = useCallback(async () => {
try { try {
await (window as any).electron.updater.installUpdate(); await (window as any).electron.updater.installUpdate();
} catch (error) { if (isMock) {
console.error('Failed to install update:', error); setPhase('idle');
setUpdateInfo(null);
setDownloadProgress(0);
}
} catch {
// keep ready state
} }
}; }, [isMock]);
const handleMenuToggle = (event: React.MouseEvent<HTMLButtonElement>) => { const handleDismiss = useCallback(() => {
if (menuAnchor) { setPhase('idle');
setMenuAnchor(undefined); setUpdateInfo(null);
} else { setDownloadProgress(0);
const rect = event.currentTarget.getBoundingClientRect(); }, []);
setMenuAnchor({ x: rect.x, y: rect.y, width: rect.width, height: rect.height });
}
};
// Don't render anything if not in Electron
if (typeof window === 'undefined' || !(window as any).electron?.updater) { if (typeof window === 'undefined' || !(window as any).electron?.updater) {
return null; return null;
} }
// Show check button if no update status if (phase === 'idle') {
if (!updateAvailable && !updateReady && !checking) {
return ( return (
<div <div
className={css.CheckButtonContainer} className={css.IdleSlot}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setHovered(false)}
> >
<button <button
className={css.CheckButton}
data-visible={isHovered}
onClick={handleCheckForUpdates}
aria-label="Check for updates"
title="Check for updates"
type="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="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 <path
d="M8 2V10M8 10L5 7M8 10L11 7" d="M8 2V10M8 10L5 7M8 10L11 7"
stroke="currentColor" stroke="currentColor"
@@ -140,109 +138,75 @@ export function UpdateNotification() {
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
<path <path d="M3 14H13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
d="M3 14H13"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg> </svg>
</button> </button>
</div> </div>
); );
} }
// Show checking state if (phase === 'checking') {
if (checking) {
return ( return (
<button className={css.UpdateButton} disabled type="button"> <div className={css.Bar} title="Checking for updates…">
<Spinner variant="Secondary" size="50" /> <div className={css.BarTrack}>
</button> <div className={css.BarIndeterminate} />
</div>
<Text className={css.BarLabel} size="L400">
Checking
</Text>
</div>
); );
} }
// Show update available/ready state if (phase === 'downloading') {
if (!updateAvailable && !updateReady) { return (
return null; <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 ( return (
<> <Box className={css.Chip} alignItems="Center" gap="100">
<button <Text className={css.ChipText} size="L400" truncate>
className={css.UpdateButton} {isMock ? 'Mock update' : 'Update'}
onClick={handleMenuToggle} {updateInfo ? ` ${updateInfo.version}` : ''}
aria-label={updateReady ? 'Update ready' : 'Update available'} </Text>
title={updateReady ? 'Update downloaded - click to install' : 'New version available'} <button type="button" className={css.ChipAction} onClick={handleDownload}>
type="button" Download
>
{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> </button>
<button
<PopOut type="button"
anchor={menuAnchor} className={css.ChipDismiss}
position="Bottom" onClick={handleDismiss}
align="End" aria-label="Dismiss update"
offset={8} title="Dismiss"
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>
}
> >
{null} ×
</PopOut> </button>
</> </Box>
); );
} }