From b604d23e5df4afd54cd0563ebc5ef12733bd63e0 Mon Sep 17 00:00:00 2001 From: Ali Turki Date: Sun, 24 May 2026 04:13:35 +0800 Subject: [PATCH 1/2] chore(deps): add tauri-plugin-process for relaunch after update install The updater install flow requires a process restart on macOS/Linux, which is provided by tauri-plugin-process. Wire it on both the JS side (@tauri-apps/plugin-process 2.3.1) and the Rust side, and grant the default capability the process:default permission set. --- bun.lock | 3 +++ package.json | 1 + src-tauri/Cargo.lock | 11 +++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 3 ++- src-tauri/src/lib.rs | 1 + 6 files changed, 19 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 667e26f..c15feb3 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-store": "^2.4.3", "@tauri-apps/plugin-updater": "^2.10.1", "bob-wasm": "^1.0.1", @@ -515,6 +516,8 @@ "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="], + "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="], + "@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.4.3", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-9LWPj9yMphRi9czEtUv87XHbl1b6xgd9EXpPrUnq6nG7+nbtoF84d4Kwz9xhAv/Hf30sr58pq7EOlyI936y8qw=="], "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="], diff --git a/package.json b/package.json index 46d9c2b..67a3b75 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-store": "^2.4.3", "@tauri-apps/plugin-updater": "^2.10.1", "bob-wasm": "^1.0.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 36fe8d1..8734ace 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -812,6 +812,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-opener", + "tauri-plugin-process", "tauri-plugin-store", "tauri-plugin-updater", "tokio", @@ -4004,6 +4005,16 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + [[package]] name = "tauri-plugin-store" version = "2.4.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8c145d5..f948ad6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,4 +29,5 @@ serde_yaml = "0.9" rayon = "1.12.0" tauri-plugin-updater = "2" tokio = { version = "1.52.2", features = ["process", "time"] } +tauri-plugin-process = "2.3.1" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e34c117..1e9a104 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -29,6 +29,7 @@ ] }, "store:default", - "updater:default" + "updater:default", + "process:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 12266dd..4a09973 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -826,6 +826,7 @@ pub fn run() { .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_process::init()) .invoke_handler(tauri::generate_handler![ scan_markdown, install_welcome_workspace, From b94457a9eae59b237cc4e5f8a39f8b88047532da Mon Sep 17 00:00:00 2001 From: Ali Turki Date: Sun, 24 May 2026 04:13:45 +0800 Subject: [PATCH 2/2] feat(updater): add auto-update check hook and install banner Existing v0.5.0 users have no in-app notification path for new releases; they only find out by manually checking GitHub. The plugin-updater and minisign endpoint were already configured but nothing consumed them. useUpdater polls check() 5 seconds after launch and every 6 hours while the app is running, persisting per-version dismissals in tauri-plugin-store so 'X' on the banner stays sticky across reloads until a newer version arrives. UpdateBanner renders below the header with download progress, an 'Install and relaunch' button that calls downloadAndInstall() then relaunch(), and a dismiss button. Styling matches ExternalChangeBanner (sticky, themed, no layout shift). --- src/App.tsx | 14 +++ src/components/document/UpdateBanner.tsx | 93 +++++++++++++++ src/hooks/useUpdater.ts | 145 +++++++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 src/components/document/UpdateBanner.tsx create mode 100644 src/hooks/useUpdater.ts diff --git a/src/App.tsx b/src/App.tsx index 275c192..e2d59a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ import { } from "@/components/explorer/ExplorerSidebar"; import { PathBreadcrumb } from "@/components/document/PathBreadcrumb"; import { PaneView } from "@/components/document/PaneView"; +import { UpdateBanner } from "@/components/document/UpdateBanner"; const SettingsDialog = lazy(() => import("@/components/settings/SettingsDialog")); import { useLibrary } from "@/hooks/useLibrary"; @@ -33,6 +34,7 @@ import { useTheme } from "@/hooks/useTheme"; import { useViewSettings } from "@/hooks/useViewSettings"; import { useSidebarState } from "@/hooks/useSidebarState"; import { usePinned } from "@/hooks/usePinned"; +import { useUpdater } from "@/hooks/useUpdater"; import { buildTree } from "@/lib/tree"; import { buildCuratedTree } from "@/lib/curatedTree"; import { collectDirKeys } from "@/components/explorer/FileTree"; @@ -60,6 +62,7 @@ function App() { const tabs = panes.activePane; const sidebar = useSidebarState(viewSettings.settings.defaultFolderState); const pinned = usePinned(); + const updater = useUpdater(); useTheme(viewSettings.settings.colorScheme, viewSettings.settings.accentColor); const deferredSettings = useDeferredValue(viewSettings.settings); const [search, setSearch] = useState(""); @@ -590,6 +593,17 @@ function App() { + +
{panes.layout.split === "off" ? ( diff --git a/src/components/document/UpdateBanner.tsx b/src/components/document/UpdateBanner.tsx new file mode 100644 index 0000000..70b2f6a --- /dev/null +++ b/src/components/document/UpdateBanner.tsx @@ -0,0 +1,93 @@ +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/hooks/useUpdater.ts b/src/hooks/useUpdater.ts new file mode 100644 index 0000000..7b5c857 --- /dev/null +++ b/src/hooks/useUpdater.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { check, type Update } from "@tauri-apps/plugin-updater"; +import { relaunch } from "@tauri-apps/plugin-process"; +import { LazyStore } from "@tauri-apps/plugin-store"; + +const store = new LazyStore("docsreader.settings.json"); +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 interface UpdaterState { + phase: UpdaterPhase; + pendingVersion?: string; + currentVersion?: string; + notes?: string; + progressBytes?: number; + totalBytes?: number; + error?: string; +} + +export interface UpdaterControls { + install: () => Promise; + dismiss: () => Promise; +} + +export function useUpdater(): UpdaterState & UpdaterControls { + const [state, setState] = useState({ phase: "idle" }); + const updateRef = useRef(null); + const dismissedRef = useRef(null); + + const runCheck = useCallback(async () => { + try { + const update = await check(); + if (!update) { + if (updateRef.current) { + updateRef.current.close().catch(() => undefined); + updateRef.current = null; + } + setState({ phase: "idle" }); + return; + } + // Respect prior dismissal for the same version. A newer version + // supersedes the dismissal. + if (dismissedRef.current && dismissedRef.current === update.version) { + update.close().catch(() => undefined); + return; + } + updateRef.current = update; + setState({ + phase: "available", + pendingVersion: update.version, + currentVersion: update.currentVersion, + notes: update.body, + }); + } catch (err) { + // Network / signature errors are non-fatal: keep the app usable. + // Surface only if no update was already detected. + setState((prev) => + prev.phase === "available" || prev.phase === "downloading" || prev.phase === "installing" + ? prev + : { phase: "error", error: err instanceof Error ? err.message : String(err) } + ); + } + }, []); + + useEffect(() => { + let cancelled = false; + let interval: ReturnType | undefined; + + (async () => { + 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); + }; + })(); + + return () => { + cancelled = true; + if (interval) clearInterval(interval); + if (updateRef.current) { + updateRef.current.close().catch(() => undefined); + updateRef.current = null; + } + }; + }, [runCheck]); + + const install = useCallback(async () => { + const update = updateRef.current; + if (!update) return; + setState((prev) => ({ ...prev, phase: "downloading", progressBytes: 0 })); + try { + let downloaded = 0; + await update.downloadAndInstall((event) => { + if (event.event === "Started") { + setState((prev) => ({ + ...prev, + phase: "downloading", + progressBytes: 0, + totalBytes: event.data.contentLength, + })); + } else if (event.event === "Progress") { + downloaded += event.data.chunkLength; + setState((prev) => ({ ...prev, progressBytes: downloaded })); + } else if (event.event === "Finished") { + setState((prev) => ({ ...prev, phase: "installing" })); + } + }); + setState((prev) => ({ ...prev, phase: "ready-to-relaunch" })); + await relaunch(); + } catch (err) { + setState((prev) => ({ + ...prev, + phase: "error", + error: err instanceof Error ? err.message : String(err), + })); + } + }, []); + + const dismiss = useCallback(async () => { + const version = updateRef.current?.version; + if (version) { + dismissedRef.current = version; + await store.set(DISMISSED_KEY, version); + await store.save(); + } + if (updateRef.current) { + updateRef.current.close().catch(() => undefined); + updateRef.current = null; + } + setState({ phase: "idle" }); + }, []); + + return { ...state, install, dismiss }; +}