From 4106bea6700a7c6657d8bfd8062060e17b5dadb2 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 10:44:31 +0100 Subject: [PATCH] feat: in-app update notification UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the old window.confirm updater with a proper React-based update notification system: - useUpdater hook now exposes state machine (idle → available → downloading → ready) and actions (startDownload, openReleaseNotes, dismiss) - UpdateBanner component renders at the top of the app shell: - "Available" state: shows version, Release Notes link, Update Now button, dismiss X - "Downloading" state: animated spinner, progress bar with percentage - "Ready" state: Restart Now button to apply the update - Silently checks on startup after 3s delay; fails silently on network errors or 404 - Release Notes link opens the GitHub Pages release history site Product decisions: - Banner at top of app (not a modal) — non-intrusive, visible but not blocking. User can dismiss and continue working. - Progress bar shows during download so user knows it's working. - Separate "Restart Now" state after download so user controls when the app restarts (they may have unsaved work). Co-Authored-By: Claude Opus 4.6 --- src/App.css | 5 ++ src/App.tsx | 4 +- src/components/UpdateBanner.tsx | 145 ++++++++++++++++++++++++++++++++ src/hooks/useUpdater.ts | 110 +++++++++++++++++++----- 4 files changed, 240 insertions(+), 24 deletions(-) create mode 100644 src/components/UpdateBanner.tsx diff --git a/src/App.css b/src/App.css index b6e63a44..e9c59281 100644 --- a/src/App.css +++ b/src/App.css @@ -46,3 +46,8 @@ .app__editor > * { flex: 1; } + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/src/App.tsx b/src/App.tsx index ba0aa874..59a75529 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { useEntryActions } from './hooks/useEntryActions' import { isTauri } from './mock-tauri' import { useKeyboardNavigation } from './hooks/useKeyboardNavigation' import { useUpdater } from './hooks/useUpdater' +import { UpdateBanner } from './components/UpdateBanner' import { setApiKey } from './utils/ai-chat' import type { SidebarSelection, GitCommit } from './types' import type { VaultOption } from './components/StatusBar' @@ -139,7 +140,7 @@ function App() { handleCloseTabRef: notes.handleCloseTabRef, }) - useUpdater() + const { status: updateStatus, actions: updateActions } = useUpdater() useKeyboardNavigation({ tabs: notes.tabs, @@ -168,6 +169,7 @@ function App() { return (
+
setShowCommitDialog(true)} /> diff --git a/src/components/UpdateBanner.tsx b/src/components/UpdateBanner.tsx new file mode 100644 index 00000000..64359ec8 --- /dev/null +++ b/src/components/UpdateBanner.tsx @@ -0,0 +1,145 @@ +import { Download, ExternalLink, RefreshCw, X } from 'lucide-react' +import type { UpdateStatus, UpdateActions } from '../hooks/useUpdater' +import { restartApp } from '../hooks/useUpdater' + +interface UpdateBannerProps { + status: UpdateStatus + actions: UpdateActions +} + +export function UpdateBanner({ status, actions }: UpdateBannerProps) { + if (status.state === 'idle' || status.state === 'error') return null + + return ( +
+ {status.state === 'available' && ( + <> + + + Laputa {status.version} is available + + + + + + )} + + {status.state === 'downloading' && ( + <> + + Downloading Laputa {status.version}... +
+
+
+ + {Math.round(status.progress * 100)}% + + + )} + + {status.state === 'ready' && ( + <> + + + Laputa {status.version} is ready — restart to apply + + + + )} +
+ ) +} diff --git a/src/hooks/useUpdater.ts b/src/hooks/useUpdater.ts index 4f63f4b4..2f92ba66 100644 --- a/src/hooks/useUpdater.ts +++ b/src/hooks/useUpdater.ts @@ -1,40 +1,104 @@ -import { useEffect } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { isTauri } from '../mock-tauri' -/** - * Checks for OTA updates on app startup (Tauri only). - * If an update is available, shows a native confirm dialog and - * downloads + installs + relaunches if the user accepts. - */ -export function useUpdater() { +const RELEASE_NOTES_URL = 'https://refactoringhq.github.io/laputa-app/' + +export type UpdateStatus = + | { state: 'idle' } + | { state: 'available'; version: string; notes: string | undefined } + | { state: 'downloading'; version: string; progress: number } + | { state: 'ready'; version: string } + | { state: 'error' } + +export interface UpdateActions { + startDownload: () => void + openReleaseNotes: () => void + dismiss: () => void +} + +export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } { + const [status, setStatus] = useState({ state: 'idle' }) + const updateRef = useRef(null) + useEffect(() => { if (!isTauri()) return const checkForUpdates = async () => { try { const { check } = await import('@tauri-apps/plugin-updater') - const { relaunch } = await import('@tauri-apps/plugin-process') - const update = await check() - if (!update) return + if (!update) return // up to date - const yes = window.confirm( - `A new version (${update.version}) is available.\n\n` + - (update.body ? `${update.body}\n\n` : '') + - 'Do you want to update and restart now?' - ) - if (!yes) return - - await update.downloadAndInstall() - await relaunch() - } catch (err) { - // Silently log — update check failures should never block the app - console.warn('[updater] Failed to check for updates:', err) + updateRef.current = update + setStatus({ + state: 'available', + version: update.version, + notes: update.body ?? undefined, + }) + } catch { + // Network error or 404 — fail silently + console.warn('[updater] Failed to check for updates') } } - // Delay slightly so the app can render first + // Delay so the app can render first const timer = setTimeout(checkForUpdates, 3000) return () => clearTimeout(timer) }, []) + + const startDownload = useCallback(async () => { + const update = updateRef.current as { + version: string + downloadAndInstall: (cb: (event: { event: string; data?: { contentLength?: number; chunkLength?: number } }) => void) => Promise + } | null + if (!update) return + + let totalBytes = 0 + let downloadedBytes = 0 + + setStatus({ state: 'downloading', version: update.version, progress: 0 }) + + try { + await update.downloadAndInstall((event) => { + if (event.event === 'Started' && event.data?.contentLength) { + totalBytes = event.data.contentLength + } else if (event.event === 'Progress' && event.data?.chunkLength) { + downloadedBytes += event.data.chunkLength + const progress = totalBytes > 0 ? Math.min(downloadedBytes / totalBytes, 1) : 0 + setStatus({ state: 'downloading', version: update.version, progress }) + } else if (event.event === 'Finished') { + setStatus({ state: 'ready', version: update.version }) + } + }) + + // If Finished wasn't emitted via callback, set ready after await resolves + setStatus((prev) => (prev.state === 'downloading' ? { state: 'ready', version: update.version } : prev)) + } catch { + console.warn('[updater] Download failed') + setStatus({ state: 'error' }) + } + }, []) + + const openReleaseNotes = useCallback(() => { + window.open(RELEASE_NOTES_URL, '_blank') + }, []) + + const dismiss = useCallback(() => { + setStatus({ state: 'idle' }) + }, []) + + return { status, actions: { startDownload, openReleaseNotes, dismiss } } +} + +/** + * Trigger app restart after an update has been downloaded. + * Separated so the component can call it on button click. + */ +export async function restartApp(): Promise { + try { + const { relaunch } = await import('@tauri-apps/plugin-process') + await relaunch() + } catch { + console.warn('[updater] Failed to relaunch') + } }