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,
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 };
+}