From d338e1c35e0ea9d6dc6b962714194284e94667f9 Mon Sep 17 00:00:00 2001 From: litruv Date: Thu, 23 Jul 2026 15:18:24 +1000 Subject: [PATCH] Show update progress as a title-bar bar instead of a popout. --- .../UpdateNotification.css.ts | 182 ++++++---- .../UpdateNotification.tsx | 318 ++++++++---------- 2 files changed, 259 insertions(+), 241 deletions(-) diff --git a/src/app/components/update-notification/UpdateNotification.css.ts b/src/app/components/update-notification/UpdateNotification.css.ts index d78bf7f..9727523 100644 --- a/src/app/components/update-notification/UpdateNotification.css.ts +++ b/src/app/components/update-notification/UpdateNotification.css.ts @@ -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)', + }, + }, }); diff --git a/src/app/components/update-notification/UpdateNotification.tsx b/src/app/components/update-notification/UpdateNotification.tsx index aba1caa..0f6ef31 100644 --- a/src/app/components/update-notification/UpdateNotification.tsx +++ b/src/app/components/update-notification/UpdateNotification.tsx @@ -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('idle'); const [updateInfo, setUpdateInfo] = useState(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) => { - 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 (
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} + className={css.IdleSlot} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} >
); } - // Show checking state - if (checking) { + if (phase === 'checking') { return ( - +
+
+
+
+ + Checking… + +
); } - // Show update available/ready state - if (!updateAvailable && !updateReady) { - return null; + if (phase === 'downloading') { + return ( +
+
+
+
+ + {downloadProgress}% + +
+ ); } - return ( - <> - + if (phase === 'ready') { + return ( + + + {isMock ? 'Mock ready' : 'Update ready'} + {updateInfo ? ` · ${updateInfo.version}` : ''} + + + + ); + } - - - - - {updateReady ? 'Update Ready' : 'Update Available'} - - {updateInfo && ( - - Version {updateInfo.version} - - )} - - - {updateReady ? ( - - ) : ( - - )} - - - } + // available + return ( + + + {isMock ? 'Mock update' : 'Update'} + {updateInfo ? ` ${updateInfo.version}` : ''} + + + + ); }