diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts index 6c8d54ef..db6f5e75 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -131,6 +131,15 @@ export function initUpdater(windowGetter: () => BrowserWindow | null): void { // reply "not-available" so the menu can show "up to date". export function checkForUpdates(): void { if (!app.isPackaged) { + // Dev-only UI harness: set COW_MOCK_UPDATE=1 to simulate an available + // update so the update panel/menu interactions can be exercised in + // `npm run dev` (where there's no real feed). Never runs in a packaged app. + if (process.env.COW_MOCK_UPDATE) { + const version = process.env.COW_MOCK_UPDATE_VERSION || '9.9.9' + log(`checkForUpdates: not packaged, MOCK available version=${version}`) + send({ state: 'available', version }) + return + } log('checkForUpdates: not packaged, replying not-available') send({ state: 'not-available' }) return diff --git a/desktop/src/renderer/src/components/UpdateBanner.tsx b/desktop/src/renderer/src/components/UpdateBanner.tsx index 8c95ac10..b51d1e8b 100644 --- a/desktop/src/renderer/src/components/UpdateBanner.tsx +++ b/desktop/src/renderer/src/components/UpdateBanner.tsx @@ -1,61 +1,41 @@ -import React, { useEffect, useState } from 'react' +import React from 'react' import { Download, RefreshCw, X, Loader2 } from 'lucide-react' import { t } from '../i18n' -import { useUpdateStore, hasPendingUpdate } from '../store/updateStore' +import { useUpdateStore, hasAvailableUpdate } from '../store/updateStore' -// Compact update panel anchored to the NavRail footer. Only mounts content -// when there's a pending update; otherwise renders nothing so it stays out of -// the way until electron-updater reports a new version. +// Compact update panel anchored to the NavRail footer. Shown whenever an update +// is available AND the panel is open (auto-opened on detection, re-openable via +// "check for update"). Dismissing (×) just closes it; the menu can re-open it. const UpdateBanner: React.FC = () => { const state = useUpdateStore() - const [open, setOpen] = useState(false) + const open = state.panelOpen - const pending = hasPendingUpdate(state) + const available = hasAvailableUpdate(state) const status = state.status - // Auto-open the panel the moment a new version is first detected. - useEffect(() => { - if (status?.state === 'available') setOpen(true) - }, [status?.state]) - - if (!pending) return null + // Render nothing when there's no update, or the panel is closed (dismissed). + if (!available || !open) return null const version = state.version const downloading = status?.state === 'downloading' const downloaded = status?.state === 'downloaded' return ( -
- {/* Collapsed pill: a red-dotted button that re-opens the panel. */} - {!open && ( - - )} - - {open && ( -
-
-
-

{t('update_available')}

- {version &&

v{version}

} -
- +
+
+
+
+

{t('update_available')}

+ {version &&

v{version}

}
+ +
{downloading && (
@@ -89,7 +69,6 @@ const UpdateBanner: React.FC = () => { )}
- )}
) } diff --git a/desktop/src/renderer/src/layout/NavRail.tsx b/desktop/src/renderer/src/layout/NavRail.tsx index 925c0525..5b4dbeab 100644 --- a/desktop/src/renderer/src/layout/NavRail.tsx +++ b/desktop/src/renderer/src/layout/NavRail.tsx @@ -28,7 +28,7 @@ import { t, getLang, setLang, Lang } from '../i18n' import { useUIStore } from '../store/uiStore' import { useTheme } from '../hooks/useTheme' import { usePlatform } from '../hooks/usePlatform' -import { useUpdateStore, hasPendingUpdate } from '../store/updateStore' +import { useUpdateStore, hasPendingUpdate, hasAvailableUpdate } from '../store/updateStore' import UpdateBanner from '../components/UpdateBanner' // Fallback shown when app.getVersion() is unavailable (dev/web preview). Keep @@ -83,7 +83,11 @@ const NavRail: React.FC = ({ onLangChange }) => { const width = collapsed ? 'w-[56px]' : 'w-[208px]' const updateState = useUpdateStore() + // Footer dot: hidden once dismissed for this version (user asked for this). const pendingUpdate = hasPendingUpdate(updateState) + // Menu "check for update" dot: stays as long as an update actually exists, + // even after dismissing the footer badge. + const availableUpdate = hasAvailableUpdate(updateState) const checking = updateState.status?.state === 'checking' const [menuOpen, setMenuOpen] = useState(false) @@ -148,7 +152,10 @@ const NavRail: React.FC = ({ onLangChange }) => { const checkUpdate = () => { setCheckedManually(true) - window.electronAPI?.checkForUpdate?.() + // Re-open the update panel if an update is already known; also kicks a + // fresh check. Closing the menu so the re-opened panel is visible. + setMenuOpen(false) + updateState.recheck() } return ( @@ -211,8 +218,8 @@ const NavRail: React.FC = ({ onLangChange }) => { { setMenuOpen(false) navigate('/logs') diff --git a/desktop/src/renderer/src/store/updateStore.ts b/desktop/src/renderer/src/store/updateStore.ts index 10863304..9af2d396 100644 --- a/desktop/src/renderer/src/store/updateStore.ts +++ b/desktop/src/renderer/src/store/updateStore.ts @@ -9,9 +9,19 @@ interface UpdateState { percent: number /** User dismissed the badge for this version (don't nag again until next). */ dismissedVersion: string | null + /** Whether the update panel is currently shown. Lifted here so the "check + * for update" menu item can re-open it on demand. */ + panelOpen: boolean setStatus: (s: UpdateStatus) => void + /** Dismiss the floating badge/panel for the current version (footer dot goes + * away), but keep the update itself known so the menu can still surface it. */ dismiss: () => void + openPanel: () => void + closePanel: () => void + /** User explicitly clicked "check for update": ask main to re-check, and if + * an update is already known, re-open the panel immediately (undismiss). */ + recheck: () => void // Actions proxied to the main process via the preload bridge. download: () => void @@ -23,16 +33,32 @@ export const useUpdateStore = create((set, get) => ({ version: null, percent: 0, dismissedVersion: null, + panelOpen: false, setStatus: (s) => set(() => { - if (s.state === 'available') return { status: s, version: s.version, percent: 0 } + // A newly detected version auto-opens the panel. + if (s.state === 'available') return { status: s, version: s.version, percent: 0, panelOpen: true } if (s.state === 'downloading') return { status: s, percent: s.percent } if (s.state === 'downloaded') return { status: s, version: s.version, percent: 100 } return { status: s } }), - dismiss: () => set((st) => ({ dismissedVersion: st.version })), + dismiss: () => set((st) => ({ dismissedVersion: st.version, panelOpen: false })), + openPanel: () => set({ panelOpen: true }), + closePanel: () => set({ panelOpen: false }), + + recheck: () => { + const st = get() + // If we already know about an available/downloaded update, just re-open the + // panel (clearing the dismiss for this version) instead of waiting on a + // network round-trip — the user asked to see it. + if (hasAvailableUpdate(st)) { + set({ dismissedVersion: null, panelOpen: true }) + } + // Always kick a fresh check too (picks up newer versions / recovers errors). + window.electronAPI?.checkForUpdate?.() + }, download: () => window.electronAPI?.downloadUpdate?.(), install: () => window.electronAPI?.installUpdate?.(), @@ -45,11 +71,18 @@ export function initUpdateListener(): (() => void) | undefined { }) } -// Whether a new version should be surfaced (available/downloading/downloaded -// and not dismissed for that version). -export function hasPendingUpdate(state: UpdateState): boolean { +// Whether an update exists at all (available/downloading/downloaded), +// regardless of dismiss. Drives the "check for update" menu item's dot, which +// should persist as long as an update is actually available. +export function hasAvailableUpdate(state: UpdateState): boolean { const s = state.status if (!s) return false - const active = s.state === 'available' || s.state === 'downloading' || s.state === 'downloaded' - return active && state.dismissedVersion !== state.version + return s.state === 'available' || s.state === 'downloading' || s.state === 'downloaded' +} + +// Whether a new version should be surfaced in the floating footer badge: +// available and not dismissed for that version. Dismissing hides only this, +// not the menu dot (hasAvailableUpdate). +export function hasPendingUpdate(state: UpdateState): boolean { + return hasAvailableUpdate(state) && state.dismissedVersion !== state.version }