From 2ed1619befb706ef25c9f8f588b3fcf46ea610a0 Mon Sep 17 00:00:00 2001 From: Ali Turki Date: Sun, 5 Jul 2026 03:35:04 +0800 Subject: [PATCH 1/2] feat(updater): harden checks with manual trigger and version awareness Add a manual checkNow() control with a dedicated checking phase, always resolve the current app version via getVersion() (not only when an update exists), and expose lastCheckedAt for the About panel. Background check failures now stay silent to keep the app usable; only a user-initiated check surfaces errors. Guard against overlapping checks and fix a cleanup path where the initial-check timeout was never cleared on unmount. --- src/hooks/useUpdater.ts | 89 +++++++++++++++++++++++++++++------------ 1 file changed, 63 insertions(+), 26 deletions(-) diff --git a/src/hooks/useUpdater.ts b/src/hooks/useUpdater.ts index 7b5c857..0231695 100644 --- a/src/hooks/useUpdater.ts +++ b/src/hooks/useUpdater.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { check, type Update } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; +import { getVersion } from "@tauri-apps/api/app"; import { LazyStore } from "@tauri-apps/plugin-store"; const store = new LazyStore("docsreader.settings.json"); @@ -8,13 +9,17 @@ const DISMISSED_KEY = "updater.dismissedVersion"; const INITIAL_CHECK_DELAY_MS = 5_000; const RECHECK_INTERVAL_MS = 6 * 60 * 60 * 1_000; -export type UpdaterPhase = - | "idle" - | "available" - | "downloading" - | "installing" - | "ready-to-relaunch" - | "error"; +export const UPDATER_PHASES = [ + "idle", + "checking", + "available", + "downloading", + "installing", + "ready-to-relaunch", + "up-to-date", + "error", +] as const; +export type UpdaterPhase = (typeof UPDATER_PHASES)[number]; export interface UpdaterState { phase: UpdaterPhase; @@ -24,69 +29,99 @@ export interface UpdaterState { progressBytes?: number; totalBytes?: number; error?: string; + lastCheckedAt?: number; } export interface UpdaterControls { install: () => Promise; dismiss: () => Promise; + checkNow: () => Promise; } export function useUpdater(): UpdaterState & UpdaterControls { const [state, setState] = useState({ phase: "idle" }); const updateRef = useRef(null); const dismissedRef = useRef(null); + const checkingRef = useRef(false); - const runCheck = useCallback(async () => { + const runCheck = useCallback(async (manual: boolean) => { + if (checkingRef.current) return; + checkingRef.current = true; + // A manual check means the user wants to see updates now, even one they + // dismissed earlier. + if (manual) { + dismissedRef.current = null; + setState((prev) => ({ ...prev, phase: "checking", error: undefined })); + } try { const update = await check(); + const checkedAt = Date.now(); if (!update) { if (updateRef.current) { updateRef.current.close().catch(() => undefined); updateRef.current = null; } - setState({ phase: "idle" }); + setState((prev) => ({ + phase: manual ? "up-to-date" : "idle", + currentVersion: prev.currentVersion, + lastCheckedAt: checkedAt, + })); return; } - // Respect prior dismissal for the same version. A newer version - // supersedes the dismissal. - if (dismissedRef.current && dismissedRef.current === update.version) { + if (!manual && dismissedRef.current === update.version) { update.close().catch(() => undefined); + setState((prev) => ({ ...prev, lastCheckedAt: checkedAt })); return; } updateRef.current = update; - setState({ + setState((prev) => ({ phase: "available", pendingVersion: update.version, - currentVersion: update.currentVersion, + currentVersion: update.currentVersion ?? prev.currentVersion, notes: update.body, - }); + lastCheckedAt: checkedAt, + })); } catch (err) { - // Network / signature errors are non-fatal: keep the app usable. - // Surface only if no update was already detected. + // Never clobber an update that's already mid-flight. Background failures + // (offline, feed hiccup) stay silent; only a manual check surfaces them. setState((prev) => prev.phase === "available" || prev.phase === "downloading" || prev.phase === "installing" ? prev - : { phase: "error", error: err instanceof Error ? err.message : String(err) } + : { + phase: manual ? "error" : "idle", + currentVersion: prev.currentVersion, + lastCheckedAt: Date.now(), + error: manual ? (err instanceof Error ? err.message : String(err)) : undefined, + } ); + } finally { + checkingRef.current = false; } }, []); useEffect(() => { let cancelled = false; let interval: ReturnType | undefined; + let initial: ReturnType | undefined; (async () => { - dismissedRef.current = ((await store.get(DISMISSED_KEY)) ?? null); + try { + const version = await getVersion(); + if (!cancelled) { + setState((prev) => ({ ...prev, currentVersion: prev.currentVersion ?? version })); + } + } catch { + // Version is a nicety for the About panel; ignore if unavailable. + } + dismissedRef.current = (await store.get(DISMISSED_KEY)) ?? null; if (cancelled) return; - const initial = setTimeout(runCheck, INITIAL_CHECK_DELAY_MS); - interval = setInterval(runCheck, RECHECK_INTERVAL_MS); - return () => { - clearTimeout(initial); - }; + initial = setTimeout(() => void runCheck(false), INITIAL_CHECK_DELAY_MS); + interval = setInterval(() => void runCheck(false), RECHECK_INTERVAL_MS); })(); return () => { cancelled = true; + if (initial) clearTimeout(initial); if (interval) clearInterval(interval); if (updateRef.current) { updateRef.current.close().catch(() => undefined); @@ -138,8 +173,10 @@ export function useUpdater(): UpdaterState & UpdaterControls { updateRef.current.close().catch(() => undefined); updateRef.current = null; } - setState({ phase: "idle" }); + setState((prev) => ({ phase: "idle", currentVersion: prev.currentVersion, lastCheckedAt: prev.lastCheckedAt })); }, []); - return { ...state, install, dismiss }; + const checkNow = useCallback(() => runCheck(true), [runCheck]); + + return { ...state, install, dismiss, checkNow }; } From 1495bec3b6701bfee7560142169170d510c2b34e Mon Sep 17 00:00:00 2001 From: Ali Turki Date: Sun, 5 Jul 2026 03:35:13 +0800 Subject: [PATCH 2/2] feat(updater): floating update toast and About settings section Replace the sticky top banner with a compact, square floating toast anchored bottom-right, showing update availability, a live download progress bar, and install/dismiss actions. Add a Settings > About section as the manual entry point: app name, current version, a Check for updates button with inline status (checking, up to date, available, error), and a link to release notes on GitHub. --- src/App.tsx | 6 +- src/components/document/UpdateBanner.tsx | 93 ------------- src/components/document/UpdateToast.tsx | 118 +++++++++++++++++ src/components/settings/AboutSection.tsx | 122 ++++++++++++++++++ .../settings/SettingsDialog.test.tsx | 10 +- src/components/settings/SettingsDialog.tsx | 8 +- 6 files changed, 259 insertions(+), 98 deletions(-) delete mode 100644 src/components/document/UpdateBanner.tsx create mode 100644 src/components/document/UpdateToast.tsx create mode 100644 src/components/settings/AboutSection.tsx diff --git a/src/App.tsx b/src/App.tsx index e21ec99..b81ef57 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,7 +23,7 @@ import { ExplorerSidebar } from "@/components/explorer/ExplorerSidebar"; import { ConvertWorkspacePrompt } from "@/components/explorer/ConvertWorkspacePrompt"; import { PathBreadcrumb } from "@/components/document/PathBreadcrumb"; import { PaneView } from "@/components/document/PaneView"; -import { UpdateBanner } from "@/components/document/UpdateBanner"; +import { UpdateToast } from "@/components/document/UpdateToast"; const SettingsDialog = lazy(() => import("@/components/settings/SettingsDialog")); import { useLibrary } from "@/hooks/useLibrary"; @@ -536,6 +536,7 @@ function App() { onChange={viewSettings.update} initialSection={settingsSection} onOpenWelcome={() => void handleOpenWelcome()} + updater={updater} /> )} @@ -588,13 +589,12 @@ function App() { - diff --git a/src/components/document/UpdateBanner.tsx b/src/components/document/UpdateBanner.tsx deleted file mode 100644 index 70b2f6a..0000000 --- a/src/components/document/UpdateBanner.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { Download, RefreshCw, X } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import type { UpdaterPhase } from "@/hooks/useUpdater"; - -interface Props { - phase: UpdaterPhase; - pendingVersion?: string; - currentVersion?: string; - progressBytes?: number; - totalBytes?: number; - error?: string; - onInstall: () => void; - onDismiss: () => void; -} - -export function UpdateBanner({ - phase, - pendingVersion, - currentVersion, - progressBytes, - totalBytes, - error, - onInstall, - onDismiss, -}: Props) { - if (phase === "idle" || phase === "error") return null; - - const isBusy = phase === "downloading" || phase === "installing"; - const progressLabel = formatProgress(phase, progressBytes, totalBytes); - - return ( -
-
- -
- - {phase === "ready-to-relaunch" - ? "Restarting to apply update…" - : `DocsReader ${pendingVersion ?? ""} is available`} - - {currentVersion && phase === "available" ? ( - - You're on v{currentVersion} - - ) : null} - {progressLabel ? ( - {progressLabel} - ) : null} -
-
- - -
-
- {error ? ( -

{error}

- ) : null} -
- ); -} - -function formatProgress( - phase: UpdaterPhase, - progressBytes?: number, - totalBytes?: number -): string { - if (phase !== "downloading") return ""; - if (!totalBytes) { - if (!progressBytes) return ""; - return `${formatBytes(progressBytes)} downloaded`; - } - const pct = Math.min(100, Math.round(((progressBytes ?? 0) / totalBytes) * 100)); - return `${pct}% (${formatBytes(progressBytes ?? 0)} / ${formatBytes(totalBytes)})`; -} - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} diff --git a/src/components/document/UpdateToast.tsx b/src/components/document/UpdateToast.tsx new file mode 100644 index 0000000..8527a13 --- /dev/null +++ b/src/components/document/UpdateToast.tsx @@ -0,0 +1,118 @@ +import { Download, RefreshCw, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { UpdaterPhase } from "@/hooks/useUpdater"; + +interface Props { + phase: UpdaterPhase; + pendingVersion?: string; + currentVersion?: string; + progressBytes?: number; + totalBytes?: number; + onInstall: () => void; + onDismiss: () => void; +} + +const VISIBLE_PHASES: readonly UpdaterPhase[] = [ + "available", + "downloading", + "installing", + "ready-to-relaunch", +]; + +export function UpdateToast({ + phase, + pendingVersion, + currentVersion, + progressBytes, + totalBytes, + onInstall, + onDismiss, +}: Props) { + if (!VISIBLE_PHASES.includes(phase)) return null; + + const isBusy = phase === "downloading" || phase === "installing"; + const isRelaunching = phase === "ready-to-relaunch"; + const percent = downloadPercent(progressBytes, totalBytes); + + return ( +
+
+
+ +
+

{title(phase)}

+

+ {subtitle(phase, pendingVersion, currentVersion)} +

+
+ {phase === "available" && ( + + )} +
+ + {phase === "downloading" && ( +
+
+
+ )} + +
+ + {phase === "available" && ( + + )} +
+
+
+ ); +} + +function title(phase: UpdaterPhase): string { + if (phase === "ready-to-relaunch") return "Restarting DocsReader"; + if (phase === "installing") return "Installing update"; + if (phase === "downloading") return "Downloading update"; + return "Update available"; +} + +function subtitle(phase: UpdaterPhase, pendingVersion?: string, currentVersion?: string): string { + if (phase === "ready-to-relaunch") return "The app will reopen in a moment."; + if (phase === "downloading" || phase === "installing") { + return pendingVersion ? `DocsReader ${pendingVersion}` : "Please wait…"; + } + const target = pendingVersion ? `DocsReader ${pendingVersion}` : "A new version"; + return currentVersion ? `${target} · you're on v${currentVersion}` : `${target} is ready to install`; +} + +function downloadPercent(progressBytes?: number, totalBytes?: number): number | null { + if (!totalBytes) return null; + return Math.min(100, Math.round(((progressBytes ?? 0) / totalBytes) * 100)); +} diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx new file mode 100644 index 0000000..f697707 --- /dev/null +++ b/src/components/settings/AboutSection.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from "react"; +import { AlertCircle, CheckCircle2, Download, ExternalLink, RefreshCw } from "lucide-react"; +import { getName } from "@tauri-apps/api/app"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { Button } from "@/components/ui/button"; +import type { UpdaterControls, UpdaterState } from "@/hooks/useUpdater"; + +const RELEASES_URL = "https://github.com/anbturki/docsreader/releases"; + +interface Props { + updater: UpdaterState & UpdaterControls; +} + +export function AboutSection({ updater }: Props) { + const [appName, setAppName] = useState("DocsReader"); + + useEffect(() => { + getName() + .then(setAppName) + .catch(() => undefined); + }, []); + + const { phase, currentVersion, pendingVersion, error, lastCheckedAt } = updater; + const isChecking = phase === "checking"; + const isInstalling = phase === "downloading" || phase === "installing" || phase === "ready-to-relaunch"; + + return ( +
+
+
+ +
+
+
{appName}
+
+ {currentVersion ? `Version ${currentVersion}` : "Version unknown"} +
+
+
+ +
+
+ + {phase === "available" && ( + + )} +
+ +
+ + +
+ ); +} + +function UpdateStatus({ + phase, + pendingVersion, + error, + lastCheckedAt, +}: { + phase: UpdaterState["phase"]; + pendingVersion?: string; + error?: string; + lastCheckedAt?: number; +}) { + if (phase === "up-to-date") { + return ( +

+ + You're on the latest version. +

+ ); + } + if (phase === "available") { + return ( +

+ Version {pendingVersion} is available to download. +

+ ); + } + if (phase === "error" && error) { + return ( +

+ + Couldn't check for updates: {error} +

+ ); + } + if (lastCheckedAt) { + return ( +

+ Last checked {new Date(lastCheckedAt).toLocaleString()}. +

+ ); + } + return null; +} diff --git a/src/components/settings/SettingsDialog.test.tsx b/src/components/settings/SettingsDialog.test.tsx index 42e7a80..60e84ff 100644 --- a/src/components/settings/SettingsDialog.test.tsx +++ b/src/components/settings/SettingsDialog.test.tsx @@ -16,6 +16,13 @@ beforeEach(() => { vi.mocked(detectAgentClients).mockResolvedValue([]); }); +const noopUpdater = { + phase: "idle" as const, + install: vi.fn(), + dismiss: vi.fn(), + checkNow: vi.fn(), +}; + function renderDialog() { return render( ); } @@ -32,7 +40,7 @@ describe("SettingsDialog", () => { it("opens on the appearance section with all nav entries", () => { renderDialog(); expect(screen.getByText("Settings")).toBeInTheDocument(); - for (const label of ["Appearance", "Reading", "Explorer", "AI agents", "Shortcuts"]) { + for (const label of ["Appearance", "Reading", "Explorer", "AI agents", "Shortcuts", "About"]) { expect(screen.getByRole("button", { name: label })).toBeInTheDocument(); } expect(screen.getByRole("radio", { name: "Light" })).toBeInTheDocument(); diff --git a/src/components/settings/SettingsDialog.tsx b/src/components/settings/SettingsDialog.tsx index c7ae63a..d58a2b1 100644 --- a/src/components/settings/SettingsDialog.tsx +++ b/src/components/settings/SettingsDialog.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { BookOpen, Bot, FolderTree, Keyboard, Palette } from "lucide-react"; +import { BookOpen, Bot, FolderTree, Info, Keyboard, Palette } from "lucide-react"; import { Dialog, DialogContent, @@ -8,6 +8,8 @@ import { } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import type { ViewSettings } from "@/lib/storage"; +import type { UpdaterControls, UpdaterState } from "@/hooks/useUpdater"; +import { AboutSection } from "./AboutSection"; import { AgentsSection } from "./AgentsSection"; import { AppearanceSection } from "./AppearanceSection"; import { ExplorerSection } from "./ExplorerSection"; @@ -20,6 +22,7 @@ const SECTIONS = [ { id: "explorer", label: "Explorer", icon: FolderTree }, { id: "agents", label: "AI agents", icon: Bot }, { id: "shortcuts", label: "Shortcuts", icon: Keyboard }, + { id: "about", label: "About", icon: Info }, ] as const; export type SettingsSection = (typeof SECTIONS)[number]["id"]; @@ -31,6 +34,7 @@ interface Props { onChange: (next: ViewSettings) => void; initialSection?: SettingsSection; onOpenWelcome: () => void; + updater: UpdaterState & UpdaterControls; } export default function SettingsDialog({ @@ -40,6 +44,7 @@ export default function SettingsDialog({ onChange, initialSection, onOpenWelcome, + updater, }: Props) { const [active, setActive] = useState(initialSection ?? "appearance"); @@ -95,6 +100,7 @@ export default function SettingsDialog({ {active === "shortcuts" && ( )} + {active === "about" && }