Show update progress as a title-bar bar instead of a popout.
This commit is contained in:
@@ -1,95 +1,149 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
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',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '32px',
|
||||
width: '32px',
|
||||
height: '100%',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const CheckButton = style({
|
||||
export const GhostCheck = style({
|
||||
all: 'unset',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
width: toRem(28),
|
||||
height: toRem(28),
|
||||
borderRadius: config.radii.R300,
|
||||
cursor: 'pointer',
|
||||
color: color.Secondary.Main,
|
||||
backgroundColor: 'transparent',
|
||||
transition: 'opacity 0.2s, background-color 0.15s',
|
||||
height: '32px',
|
||||
width: '32px',
|
||||
color: color.Surface.OnContainer,
|
||||
opacity: 0,
|
||||
WebkitAppRegion: 'no-drag',
|
||||
flexShrink: 0,
|
||||
|
||||
cursor: 'pointer',
|
||||
transition: 'opacity 0.15s ease, background-color 0.15s ease',
|
||||
selectors: {
|
||||
'&[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({
|
||||
all: 'unset',
|
||||
export const Bar = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
gap: config.space.S200,
|
||||
height: toRem(22),
|
||||
minWidth: toRem(120),
|
||||
maxWidth: toRem(200),
|
||||
padding: `0 ${config.space.S200}`,
|
||||
borderRadius: config.radii.R300,
|
||||
cursor: 'pointer',
|
||||
color: color.Success.Main,
|
||||
backgroundColor: 'transparent',
|
||||
transition: 'background-color 0.15s',
|
||||
height: '32px',
|
||||
width: '32px',
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
WebkitAppRegion: 'no-drag',
|
||||
flexShrink: 0,
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
|
||||
':hover': {
|
||||
backgroundColor: color.Surface.ContainerHover,
|
||||
},
|
||||
export const BarTrack = style({
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
height: toRem(4),
|
||||
borderRadius: toRem(2),
|
||||
overflow: 'hidden',
|
||||
backgroundColor: color.Surface.ContainerLine,
|
||||
minWidth: toRem(64),
|
||||
});
|
||||
|
||||
':active': {
|
||||
backgroundColor: color.Surface.ContainerActive,
|
||||
},
|
||||
export const BarFill = style({
|
||||
position: 'absolute',
|
||||
inset: '0 auto 0 0',
|
||||
height: '100%',
|
||||
borderRadius: 'inherit',
|
||||
backgroundColor: color.Success.Main,
|
||||
transition: 'width 120ms linear',
|
||||
});
|
||||
|
||||
':focus-visible': {
|
||||
outline: `2px solid ${color.Success.Main}`,
|
||||
outlineOffset: '2px',
|
||||
export const BarIndeterminate = style({
|
||||
position: 'absolute',
|
||||
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({
|
||||
minWidth: toRem(280),
|
||||
maxWidth: toRem(320),
|
||||
backgroundColor: color.Surface.Container,
|
||||
borderRadius: config.radii.R400,
|
||||
boxShadow: config.shadow.E400,
|
||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||
});
|
||||
|
||||
export const ProgressText = style({
|
||||
color: color.Success.Main,
|
||||
fontWeight: 500,
|
||||
minWidth: toRem(35),
|
||||
textAlign: 'center',
|
||||
export const ChipDismiss = style({
|
||||
all: 'unset',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: toRem(18),
|
||||
height: toRem(18),
|
||||
borderRadius: config.radii.R300,
|
||||
cursor: 'pointer',
|
||||
opacity: 0.7,
|
||||
fontSize: toRem(14),
|
||||
lineHeight: 1,
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
opacity: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.08)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user