feat(updater): add auto-update UI with periodic checks and state guards
- Add green "Update app" / "Restart to update" button in header (TabBarActions) - Enhance UpdateDialog: larger, dynamic buttons, scrollable release notes, version badge, "View on GitHub" link - Add DashboardUpdateBanner: compact dismissible banner keyed by version - Add periodic hourly update checks in UpdaterService with unref() timer - Add dismissed version tracking in updateSlice (localStorage persistence) - Add state transition guards in store listener: prevent periodic re-checks from resetting downloading/downloaded status back to available/checking - Guard error events from overriding downloaded state (transient check failures)
This commit is contained in:
parent
be38447c97
commit
a9cdf20ca8
7 changed files with 269 additions and 32 deletions
|
|
@ -18,6 +18,7 @@ const logger = createLogger('UpdaterService');
|
||||||
|
|
||||||
export class UpdaterService {
|
export class UpdaterService {
|
||||||
private mainWindow: BrowserWindow | null = null;
|
private mainWindow: BrowserWindow | null = null;
|
||||||
|
private periodicTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
autoUpdater.autoDownload = false;
|
autoUpdater.autoDownload = false;
|
||||||
|
|
@ -66,6 +67,27 @@ export class UpdaterService {
|
||||||
autoUpdater.quitAndInstall(true, true);
|
autoUpdater.quitAndInstall(true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start periodic update checks at the given interval (default: 1 hour).
|
||||||
|
* Uses unref() so the timer does not prevent process exit.
|
||||||
|
*/
|
||||||
|
startPeriodicCheck(intervalMs: number = 3_600_000): void {
|
||||||
|
this.stopPeriodicCheck();
|
||||||
|
this.periodicTimer = setInterval(() => void this.checkForUpdates(), intervalMs);
|
||||||
|
this.periodicTimer.unref();
|
||||||
|
logger.info(`Periodic update check started (interval: ${Math.round(intervalMs / 60_000)}min)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop periodic update checks.
|
||||||
|
*/
|
||||||
|
stopPeriodicCheck(): void {
|
||||||
|
if (this.periodicTimer !== null) {
|
||||||
|
clearInterval(this.periodicTimer);
|
||||||
|
this.periodicTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sendStatus(status: UpdaterStatus): void {
|
private sendStatus(status: UpdaterStatus): void {
|
||||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
this.mainWindow.webContents.send('updater:status', status);
|
this.mainWindow.webContents.send('updater:status', status);
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,26 @@
|
||||||
*
|
*
|
||||||
* Prompts the user to download the update or dismiss it.
|
* Prompts the user to download the update or dismiss it.
|
||||||
* Release notes (markdown from GitHub) are rendered with ReactMarkdown.
|
* Release notes (markdown from GitHub) are rendered with ReactMarkdown.
|
||||||
|
* Shows "Restart now" when the update has already been downloaded.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
|
||||||
|
import { isElectronMode } from '@renderer/api';
|
||||||
import { markdownComponents } from '@renderer/components/chat/markdownComponents';
|
import { markdownComponents } from '@renderer/components/chat/markdownComponents';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
||||||
import { X } from 'lucide-react';
|
import { ExternalLink, X } from 'lucide-react';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
export const UpdateDialog = (): React.JSX.Element | null => {
|
export const UpdateDialog = (): React.JSX.Element | null => {
|
||||||
const showUpdateDialog = useStore((s) => s.showUpdateDialog);
|
const showUpdateDialog = useStore((s) => s.showUpdateDialog);
|
||||||
|
const updateStatus = useStore((s) => s.updateStatus);
|
||||||
const availableVersion = useStore((s) => s.availableVersion);
|
const availableVersion = useStore((s) => s.availableVersion);
|
||||||
const releaseNotes = useStore((s) => s.releaseNotes);
|
const releaseNotes = useStore((s) => s.releaseNotes);
|
||||||
const downloadUpdate = useStore((s) => s.downloadUpdate);
|
const downloadUpdate = useStore((s) => s.downloadUpdate);
|
||||||
|
const installUpdate = useStore((s) => s.installUpdate);
|
||||||
const dismissUpdateDialog = useStore((s) => s.dismissUpdateDialog);
|
const dismissUpdateDialog = useStore((s) => s.dismissUpdateDialog);
|
||||||
|
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -75,6 +79,21 @@ export const UpdateDialog = (): React.JSX.Element | null => {
|
||||||
|
|
||||||
if (!showUpdateDialog) return null;
|
if (!showUpdateDialog) return null;
|
||||||
|
|
||||||
|
const isDownloaded = updateStatus === 'downloaded';
|
||||||
|
|
||||||
|
const releaseUrl = availableVersion
|
||||||
|
? `https://github.com/777genius/claude_agent_teams_ui/releases/tag/v${availableVersion}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const openReleaseOnGitHub = (): void => {
|
||||||
|
if (!releaseUrl) return;
|
||||||
|
if (isElectronMode()) {
|
||||||
|
void window.electronAPI.openExternal(releaseUrl);
|
||||||
|
} else {
|
||||||
|
window.open(releaseUrl, '_blank');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
|
|
@ -87,7 +106,7 @@ export const UpdateDialog = (): React.JSX.Element | null => {
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
className="relative mx-4 w-full max-w-sm rounded-md border p-4 shadow-lg"
|
className="relative mx-4 w-full max-w-lg rounded-md border p-5 shadow-lg"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Update available"
|
aria-label="Update available"
|
||||||
|
|
@ -107,25 +126,33 @@ export const UpdateDialog = (): React.JSX.Element | null => {
|
||||||
|
|
||||||
<div className="mb-3 pr-8">
|
<div className="mb-3 pr-8">
|
||||||
<h2 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
|
<h2 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||||
Update Available
|
{isDownloaded ? 'Update Ready' : 'Update Available'}
|
||||||
</h2>
|
</h2>
|
||||||
{availableVersion && (
|
{availableVersion && (
|
||||||
<div className="mt-1 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
|
<div
|
||||||
|
className="mt-1.5 inline-block rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDownloaded
|
||||||
|
? 'rgba(34, 197, 94, 0.15)'
|
||||||
|
: 'rgba(59, 130, 246, 0.15)',
|
||||||
|
color: isDownloaded ? '#4ade80' : '#60a5fa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
v{availableVersion}
|
v{availableVersion}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Release notes */}
|
{/* Release notes */}
|
||||||
{releaseNotes && (
|
<div
|
||||||
<div
|
className="prose prose-sm prose-invert mb-4 max-h-[60vh] overflow-y-auto rounded border p-3 text-xs"
|
||||||
className="prose prose-sm prose-invert mb-4 max-h-60 overflow-y-auto rounded border p-3 text-xs"
|
style={{
|
||||||
style={{
|
backgroundColor: 'var(--color-surface)',
|
||||||
backgroundColor: 'var(--color-surface)',
|
borderColor: 'var(--color-border)',
|
||||||
borderColor: 'var(--color-border)',
|
color: 'var(--color-text-secondary)',
|
||||||
color: 'var(--color-text-secondary)',
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{releaseNotes ? (
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={REHYPE_PLUGINS}
|
rehypePlugins={REHYPE_PLUGINS}
|
||||||
|
|
@ -133,11 +160,26 @@ export const UpdateDialog = (): React.JSX.Element | null => {
|
||||||
>
|
>
|
||||||
{releaseNotes}
|
{releaseNotes}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<p className="italic" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
No release notes available.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{releaseUrl && (
|
||||||
|
<button
|
||||||
|
onClick={openReleaseOnGitHub}
|
||||||
|
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs transition-colors hover:bg-white/5"
|
||||||
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-3" />
|
||||||
|
View on GitHub
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
<button
|
<button
|
||||||
onClick={dismissUpdateDialog}
|
onClick={dismissUpdateDialog}
|
||||||
className="rounded-md border px-3 py-1.5 text-sm font-medium transition-colors hover:bg-white/5"
|
className="rounded-md border px-3 py-1.5 text-sm font-medium transition-colors hover:bg-white/5"
|
||||||
|
|
@ -148,12 +190,21 @@ export const UpdateDialog = (): React.JSX.Element | null => {
|
||||||
>
|
>
|
||||||
Later
|
Later
|
||||||
</button>
|
</button>
|
||||||
<button
|
{isDownloaded ? (
|
||||||
onClick={downloadUpdate}
|
<button
|
||||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-500"
|
onClick={installUpdate}
|
||||||
>
|
className="rounded-md bg-green-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-green-500"
|
||||||
Download
|
>
|
||||||
</button>
|
Restart now
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={downloadUpdate}
|
||||||
|
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-500"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
84
src/renderer/components/dashboard/DashboardUpdateBanner.tsx
Normal file
84
src/renderer/components/dashboard/DashboardUpdateBanner.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
/**
|
||||||
|
* DashboardUpdateBanner - Compact banner on the dashboard when a new app version is available.
|
||||||
|
*
|
||||||
|
* Single-line banner: icon + "New version available vX.Y.Z" + action button + dismiss X.
|
||||||
|
* Dismissible with localStorage persistence keyed by version —
|
||||||
|
* dismissed for v1.3.0 won't suppress the banner when v1.4.0 arrives.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
import { ArrowUpCircle, X } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
const DISMISSED_KEY = 'update:dashboard-dismissed-version';
|
||||||
|
|
||||||
|
export const DashboardUpdateBanner = (): React.JSX.Element | null => {
|
||||||
|
const { updateStatus, availableVersion, openUpdateDialog, installUpdate } = useStore(
|
||||||
|
useShallow((s) => ({
|
||||||
|
updateStatus: s.updateStatus,
|
||||||
|
availableVersion: s.availableVersion,
|
||||||
|
openUpdateDialog: s.openUpdateDialog,
|
||||||
|
installUpdate: s.installUpdate,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const [dismissed, setDismissed] = useState(() => {
|
||||||
|
const saved = localStorage.getItem(DISMISSED_KEY);
|
||||||
|
return saved === availableVersion;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset dismissed state when a new version becomes available
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem(DISMISSED_KEY);
|
||||||
|
setDismissed(saved === availableVersion);
|
||||||
|
}, [availableVersion]);
|
||||||
|
|
||||||
|
if (dismissed) return null;
|
||||||
|
if (updateStatus !== 'available' && updateStatus !== 'downloaded') return null;
|
||||||
|
|
||||||
|
const handleDismiss = (): void => {
|
||||||
|
if (availableVersion) {
|
||||||
|
localStorage.setItem(DISMISSED_KEY, availableVersion);
|
||||||
|
}
|
||||||
|
setDismissed(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDownloaded = updateStatus === 'downloaded';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mb-6 flex items-center gap-3 rounded-lg border px-4 py-3"
|
||||||
|
style={{
|
||||||
|
borderColor: 'rgba(34, 197, 94, 0.3)',
|
||||||
|
backgroundColor: 'rgba(34, 197, 94, 0.04)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowUpCircle className="size-4 shrink-0 text-green-400" />
|
||||||
|
<span className="flex-1 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
|
New version available{' '}
|
||||||
|
{availableVersion && (
|
||||||
|
<span className="font-medium text-green-400">v{availableVersion}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={isDownloaded ? installUpdate : openUpdateDialog}
|
||||||
|
className="shrink-0 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5"
|
||||||
|
style={{
|
||||||
|
borderColor: 'rgba(34, 197, 94, 0.3)',
|
||||||
|
color: '#4ade80',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDownloaded ? 'Restart now' : 'View details'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="shrink-0 rounded p-0.5 transition-colors hover:bg-white/10"
|
||||||
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -30,6 +30,7 @@ import { formatDistanceToNow } from 'date-fns';
|
||||||
import { Command, FolderGit2, FolderOpen, GitBranch, GitFork, Search, Users } from 'lucide-react';
|
import { Command, FolderGit2, FolderOpen, GitBranch, GitFork, Search, Users } from 'lucide-react';
|
||||||
|
|
||||||
import { CliStatusBanner } from './CliStatusBanner';
|
import { CliStatusBanner } from './CliStatusBanner';
|
||||||
|
import { DashboardUpdateBanner } from './DashboardUpdateBanner';
|
||||||
|
|
||||||
import type { RepositoryGroup } from '@renderer/types/data';
|
import type { RepositoryGroup } from '@renderer/types/data';
|
||||||
|
|
||||||
|
|
@ -747,6 +748,9 @@ export const DashboardView = (): React.JSX.Element => {
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="relative mx-auto max-w-5xl px-8 py-12">
|
<div className="relative mx-auto max-w-5xl px-8 py-12">
|
||||||
|
{/* App update banner */}
|
||||||
|
<DashboardUpdateBanner />
|
||||||
|
|
||||||
{/* CLI Status Banner */}
|
{/* CLI Status Banner */}
|
||||||
<CliStatusBanner />
|
<CliStatusBanner />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { isElectronMode } from '@renderer/api';
|
import { isElectronMode } from '@renderer/api';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { Bell, PanelRight, Puzzle, Settings, Users } from 'lucide-react';
|
import { Bell, PanelRight, Puzzle, Settings, Users } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -25,6 +26,8 @@ export const TabBarActions = (): React.JSX.Element => {
|
||||||
tabSessionData,
|
tabSessionData,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
|
updateStatus,
|
||||||
|
openUpdateDialog,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
unreadCount: s.unreadCount,
|
unreadCount: s.unreadCount,
|
||||||
|
|
@ -37,6 +40,8 @@ export const TabBarActions = (): React.JSX.Element => {
|
||||||
tabSessionData: s.tabSessionData,
|
tabSessionData: s.tabSessionData,
|
||||||
sidebarCollapsed: s.sidebarCollapsed,
|
sidebarCollapsed: s.sidebarCollapsed,
|
||||||
toggleSidebar: s.toggleSidebar,
|
toggleSidebar: s.toggleSidebar,
|
||||||
|
updateStatus: s.updateStatus,
|
||||||
|
openUpdateDialog: s.openUpdateDialog,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -47,6 +52,7 @@ export const TabBarActions = (): React.JSX.Element => {
|
||||||
const [githubHover, setGithubHover] = useState(false);
|
const [githubHover, setGithubHover] = useState(false);
|
||||||
const [settingsHover, setSettingsHover] = useState(false);
|
const [settingsHover, setSettingsHover] = useState(false);
|
||||||
const [expandHover, setExpandHover] = useState(false);
|
const [expandHover, setExpandHover] = useState(false);
|
||||||
|
const [updateHover, setUpdateHover] = useState(false);
|
||||||
|
|
||||||
// Derive active tab and session detail for MoreMenu
|
// Derive active tab and session detail for MoreMenu
|
||||||
const activeTab = useMemo(
|
const activeTab = useMemo(
|
||||||
|
|
@ -62,6 +68,31 @@ export const TabBarActions = (): React.JSX.Element => {
|
||||||
className="ml-2 flex shrink-0 items-center gap-1"
|
className="ml-2 flex shrink-0 items-center gap-1"
|
||||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
|
{/* Update app button — only visible when update available or downloaded */}
|
||||||
|
{(updateStatus === 'available' || updateStatus === 'downloaded') && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={openUpdateDialog}
|
||||||
|
onMouseEnter={() => setUpdateHover(true)}
|
||||||
|
onMouseLeave={() => setUpdateHover(false)}
|
||||||
|
className="rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
color: updateHover ? '#4ade80' : '#22c55e',
|
||||||
|
backgroundColor: updateHover ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{updateStatus === 'downloaded' ? 'Restart to update' : 'Update app'}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
{updateStatus === 'downloaded'
|
||||||
|
? 'Update downloaded, restart to apply'
|
||||||
|
: 'New version available'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Notifications bell icon */}
|
{/* Notifications bell icon */}
|
||||||
<button
|
<button
|
||||||
onClick={openNotificationsTab}
|
onClick={openNotificationsTab}
|
||||||
|
|
|
||||||
|
|
@ -668,20 +668,40 @@ export function initializeNotificationListeners(): () => void {
|
||||||
const cleanup = api.updater.onStatus((_event: unknown, status: unknown) => {
|
const cleanup = api.updater.onStatus((_event: unknown, status: unknown) => {
|
||||||
const s = status as UpdaterStatus;
|
const s = status as UpdaterStatus;
|
||||||
switch (s.type) {
|
switch (s.type) {
|
||||||
case 'checking':
|
case 'checking': {
|
||||||
useStore.setState({ updateStatus: 'checking' });
|
// Don't downgrade status if we already know about an available/downloaded update
|
||||||
|
// or if a download is in progress (prevents UI flash during periodic re-checks)
|
||||||
|
const current = useStore.getState().updateStatus;
|
||||||
|
if (current !== 'available' && current !== 'downloaded' && current !== 'downloading') {
|
||||||
|
useStore.setState({ updateStatus: 'checking' });
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'available':
|
}
|
||||||
|
case 'available': {
|
||||||
|
// Don't downgrade from downloading/downloaded — the update is already
|
||||||
|
// in progress or ready to install (prevents periodic re-check from
|
||||||
|
// resetting the state after download completes)
|
||||||
|
const currentStatus = useStore.getState().updateStatus;
|
||||||
|
if (currentStatus === 'downloading' || currentStatus === 'downloaded') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const dismissed = useStore.getState().dismissedUpdateVersion;
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
updateStatus: 'available',
|
updateStatus: 'available',
|
||||||
availableVersion: s.version ?? null,
|
availableVersion: s.version ?? null,
|
||||||
releaseNotes: s.releaseNotes ?? null,
|
releaseNotes: s.releaseNotes ?? null,
|
||||||
showUpdateDialog: true,
|
showUpdateDialog: s.version !== dismissed,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'not-available':
|
}
|
||||||
useStore.setState({ updateStatus: 'not-available' });
|
case 'not-available': {
|
||||||
|
// Don't reset status if update is already downloading or downloaded
|
||||||
|
const notAvailCurrent = useStore.getState().updateStatus;
|
||||||
|
if (notAvailCurrent !== 'downloading' && notAvailCurrent !== 'downloaded') {
|
||||||
|
useStore.setState({ updateStatus: 'not-available' });
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'downloading':
|
case 'downloading':
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
updateStatus: 'downloading',
|
updateStatus: 'downloading',
|
||||||
|
|
@ -695,12 +715,19 @@ export function initializeNotificationListeners(): () => void {
|
||||||
availableVersion: s.version ?? useStore.getState().availableVersion,
|
availableVersion: s.version ?? useStore.getState().availableVersion,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error': {
|
||||||
|
// Don't lose downloaded state due to a transient check error —
|
||||||
|
// the update is already on disk and ready to install
|
||||||
|
const errCurrent = useStore.getState().updateStatus;
|
||||||
|
if (errCurrent === 'downloaded') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
updateStatus: 'error',
|
updateStatus: 'error',
|
||||||
updateError: s.error ?? 'Unknown error',
|
updateError: s.error ?? 'Unknown error',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (typeof cleanup === 'function') {
|
if (typeof cleanup === 'function') {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import type { StateCreator } from 'zustand';
|
||||||
|
|
||||||
const logger = createLogger('Store:update');
|
const logger = createLogger('Store:update');
|
||||||
|
|
||||||
|
const DISMISSED_VERSION_KEY = 'update:dismissed-version';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Slice Interface
|
// Slice Interface
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -30,11 +32,13 @@ export interface UpdateSlice {
|
||||||
updateError: string | null;
|
updateError: string | null;
|
||||||
showUpdateDialog: boolean;
|
showUpdateDialog: boolean;
|
||||||
showUpdateBanner: boolean;
|
showUpdateBanner: boolean;
|
||||||
|
dismissedUpdateVersion: string | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
checkForUpdates: () => void;
|
checkForUpdates: () => void;
|
||||||
downloadUpdate: () => void;
|
downloadUpdate: () => void;
|
||||||
installUpdate: () => void;
|
installUpdate: () => void;
|
||||||
|
openUpdateDialog: () => void;
|
||||||
dismissUpdateDialog: () => void;
|
dismissUpdateDialog: () => void;
|
||||||
dismissUpdateBanner: () => void;
|
dismissUpdateBanner: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -43,7 +47,7 @@ export interface UpdateSlice {
|
||||||
// Slice Creator
|
// Slice Creator
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export const createUpdateSlice: StateCreator<AppState, [], [], UpdateSlice> = (set) => ({
|
export const createUpdateSlice: StateCreator<AppState, [], [], UpdateSlice> = (set, get) => ({
|
||||||
// Initial state
|
// Initial state
|
||||||
updateStatus: 'idle',
|
updateStatus: 'idle',
|
||||||
availableVersion: null,
|
availableVersion: null,
|
||||||
|
|
@ -52,12 +56,16 @@ export const createUpdateSlice: StateCreator<AppState, [], [], UpdateSlice> = (s
|
||||||
updateError: null,
|
updateError: null,
|
||||||
showUpdateDialog: false,
|
showUpdateDialog: false,
|
||||||
showUpdateBanner: false,
|
showUpdateBanner: false,
|
||||||
|
dismissedUpdateVersion: localStorage.getItem(DISMISSED_VERSION_KEY),
|
||||||
|
|
||||||
checkForUpdates: () => {
|
checkForUpdates: () => {
|
||||||
set({ updateStatus: 'checking', updateError: null });
|
set({ updateStatus: 'checking', updateError: null });
|
||||||
api.updater.check().catch((error) => {
|
api.updater.check().catch((error) => {
|
||||||
logger.error('Failed to check for updates:', error);
|
logger.error('Failed to check for updates:', error);
|
||||||
set({ updateStatus: 'error', updateError: error instanceof Error ? error.message : 'Check failed' });
|
set({
|
||||||
|
updateStatus: 'error',
|
||||||
|
updateError: error instanceof Error ? error.message : 'Check failed',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -74,8 +82,18 @@ export const createUpdateSlice: StateCreator<AppState, [], [], UpdateSlice> = (s
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
openUpdateDialog: () => {
|
||||||
|
set({ showUpdateDialog: true });
|
||||||
|
},
|
||||||
|
|
||||||
dismissUpdateDialog: () => {
|
dismissUpdateDialog: () => {
|
||||||
set({ showUpdateDialog: false });
|
const version = get().availableVersion;
|
||||||
|
if (version) {
|
||||||
|
localStorage.setItem(DISMISSED_VERSION_KEY, version);
|
||||||
|
set({ showUpdateDialog: false, dismissedUpdateVersion: version });
|
||||||
|
} else {
|
||||||
|
set({ showUpdateDialog: false });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
dismissUpdateBanner: () => {
|
dismissUpdateBanner: () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue