Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

3 changes: 2 additions & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
]
},
"store:default",
"updater:default"
"updater:default",
"process:default"
]
}
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -590,6 +593,17 @@ function App() {
</div>
</header>

<UpdateBanner
phase={updater.phase}
pendingVersion={updater.pendingVersion}
currentVersion={updater.currentVersion}
progressBytes={updater.progressBytes}
totalBytes={updater.totalBytes}
error={updater.error}
onInstall={updater.install}
onDismiss={updater.dismiss}
/>

<div className="flex flex-1 min-h-0">
<div className="relative flex-1 min-h-0">
{panes.layout.split === "off" ? (
Expand Down
93 changes: 93 additions & 0 deletions src/components/document/UpdateBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="sticky top-0 z-10 border-b border-sky-200 bg-sky-50/95 px-4 py-2 text-sm dark:border-sky-900/60 dark:bg-sky-950/40">
<div className="mx-auto flex max-w-3xl items-center gap-3">
<Download className="size-4 shrink-0 text-sky-600 dark:text-sky-500" />
<div className="flex-1 truncate">
<span className="font-medium text-sky-900 dark:text-sky-200">
{phase === "ready-to-relaunch"
? "Restarting to apply update…"
: `DocsReader ${pendingVersion ?? ""} is available`}
</span>
{currentVersion && phase === "available" ? (
<span className="ml-2 text-sky-800/70 dark:text-sky-200/70">
You're on v{currentVersion}
</span>
) : null}
{progressLabel ? (
<span className="ml-2 text-sky-800/70 dark:text-sky-200/70">{progressLabel}</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button size="sm" variant="default" onClick={onInstall} disabled={isBusy || phase === "ready-to-relaunch"}>
<RefreshCw className={`size-3.5${isBusy ? " animate-spin" : ""}`} />
{phase === "downloading" ? "Downloading…" : phase === "installing" ? "Installing…" : "Install and relaunch"}
</Button>
<Button
size="icon"
variant="ghost"
onClick={onDismiss}
disabled={isBusy || phase === "ready-to-relaunch"}
title="Dismiss until next version"
aria-label="Dismiss"
className="size-8"
>
<X />
</Button>
</div>
</div>
{error ? (
<p className="mx-auto mt-1 max-w-3xl text-xs text-sky-800/70 dark:text-sky-200/70">{error}</p>
) : null}
</div>
);
}

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`;
}
145 changes: 145 additions & 0 deletions src/hooks/useUpdater.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
dismiss: () => Promise<void>;
}

export function useUpdater(): UpdaterState & UpdaterControls {
const [state, setState] = useState<UpdaterState>({ phase: "idle" });
const updateRef = useRef<Update | null>(null);
const dismissedRef = useRef<string | null>(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<typeof setInterval> | undefined;

(async () => {
dismissedRef.current = ((await store.get<string>(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 };
}
Loading