diff --git a/src/App.tsx b/src/App.tsx index d48df9b..5b1e4c5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { Library } from "./components/Library"; import { EditorView } from "./components/EditorView"; import { BackupSettings } from "./components/BackupSettings"; import { ExportPreview } from "./components/ExportPreview"; +import { UpdateDialog } from "./components/UpdateDialog"; import { useBook } from "./store/useBook"; import { useBackup } from "./store/useBackup"; import { useExportPreview } from "./store/useExportPreview"; @@ -87,6 +88,7 @@ function App() { {book ? : } {isDesktop && } {isDesktop && } + {isDesktop && } ); } diff --git a/src/components/UpdateDialog.tsx b/src/components/UpdateDialog.tsx new file mode 100644 index 0000000..ea34996 --- /dev/null +++ b/src/components/UpdateDialog.tsx @@ -0,0 +1,104 @@ +import { useUpdater } from "../store/useUpdater"; +import { installUpdate, dismissUpdate } from "../updater"; +import { Icon } from "./Icon"; + +const TITLES: Record = { + checking: "Check for Updates", + available: "Update Available", + downloading: "Updating margin", + installing: "Updating margin", + uptodate: "Check for Updates", + error: "Update Failed", +}; + +export function UpdateDialog() { + const phase = useUpdater((s) => s.phase); + const version = useUpdater((s) => s.version); + const notes = useUpdater((s) => s.notes); + const downloaded = useUpdater((s) => s.downloaded); + const total = useUpdater((s) => s.total); + const error = useUpdater((s) => s.error); + + if (phase === "idle") return null; + + const busy = phase === "downloading" || phase === "installing"; + const pct = total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : 0; + const close = busy ? undefined : dismissUpdate; + + return ( +
+
e.stopPropagation()}> +
+

{TITLES[phase]}

+ {!busy && ( + + )} +
+ +
+ {phase === "checking" && ( +
+ + Checking for updates… +
+ )} + + {phase === "uptodate" &&

margin is up to date.

} + + {phase === "available" && ( + <> +

+ margin {version} is available. +

+ {notes &&
{notes}
} + + )} + + {phase === "downloading" && ( + <> +
0 ? "" : " indeterminate"}`}> +
0 ? { width: `${pct}%` } : undefined} /> +
+

{total > 0 ? `Downloading update… ${pct}%` : "Downloading update…"}

+ + )} + + {phase === "installing" && ( +
+ + Installing update. margin will restart… +
+ )} + + {phase === "error" && ( + <> +

Could not update margin.

+ {error &&
{error}
} + + )} +
+ + {phase === "available" && ( +
+ + +
+ )} + + {(phase === "uptodate" || phase === "error") && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/store/useUpdater.ts b/src/store/useUpdater.ts new file mode 100644 index 0000000..bc18fbb --- /dev/null +++ b/src/store/useUpdater.ts @@ -0,0 +1,39 @@ +import { create } from "zustand"; +import type { Update } from "@tauri-apps/plugin-updater"; + +export type UpdatePhase = + | "idle" + | "checking" + | "available" + | "downloading" + | "installing" + | "uptodate" + | "error"; + +interface UpdaterState { + phase: UpdatePhase; + version: string; + notes: string; + downloaded: number; + total: number; + error: string; + update: Update | null; + set: (partial: Partial) => void; + reset: () => void; +} + +const initial = { + phase: "idle" as UpdatePhase, + version: "", + notes: "", + downloaded: 0, + total: 0, + error: "", + update: null as Update | null, +}; + +export const useUpdater = create((set) => ({ + ...initial, + set: (partial) => set(partial), + reset: () => set(initial), +})); diff --git a/src/styles/app.css b/src/styles/app.css index fbf8493..cea5e82 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -1615,6 +1615,72 @@ body.resizing .sidebar { color: var(--ink); } +.update-notes { + max-height: 180px; + overflow-y: auto; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: var(--r-sm); + background: var(--raised); + font-size: var(--t-3); + line-height: 1.55; + color: var(--ink-soft); + white-space: pre-line; +} + +.update-status-row { + display: flex; + align-items: center; + gap: 11px; + font-size: var(--t-4); + color: var(--ink-soft); +} + +.update-spinner { + flex: none; + width: 18px; + height: 18px; + border: 2px solid var(--line-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +.update-progress { + width: 100%; + height: 7px; + border-radius: 999px; + background: var(--accent-wash); + overflow: hidden; +} + +.update-progress-fill { + height: 100%; + border-radius: 999px; + background: var(--accent); + transition: width 160ms var(--ease); +} + +.update-progress.indeterminate .update-progress-fill { + width: 40%; + animation: update-indeterminate 1.1s ease-in-out infinite; +} + +@keyframes update-indeterminate { + 0% { + transform: translateX(-120%); + } + 100% { + transform: translateX(320%); + } +} + +.update-status { + margin: 0; + font-size: var(--t-3); + color: var(--ink-soft); +} + .btn-primary, .btn-ghost, .btn-danger { diff --git a/src/updater.ts b/src/updater.ts index 8af20d6..4784587 100644 --- a/src/updater.ts +++ b/src/updater.ts @@ -1,31 +1,58 @@ import { check } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; -import { ask, message } from "@tauri-apps/plugin-dialog"; +import { useUpdater } from "./store/useUpdater"; -let running = false; +let checking = false; export async function checkForUpdates(silent: boolean) { - if (running) return; - running = true; + if (checking) return; + checking = true; + const store = useUpdater.getState(); + if (!silent) store.set({ phase: "checking", error: "" }); try { const update = await check(); if (!update) { - if (!silent) await message("margin is up to date.", { title: "Check for Updates" }); + store.set(silent ? { phase: "idle" } : { phase: "uptodate" }); return; } - const notes = update.body ? `\n\n${update.body}` : ""; - const install = await ask(`margin ${update.version} is available.${notes}\n\nInstall now and restart?`, { - title: "Update Available", - kind: "info", + store.set({ + phase: "available", + update, + version: update.version, + notes: update.body ?? "", + downloaded: 0, + total: 0, + error: "", }); - if (!install) return; - await update.downloadAndInstall(); - await relaunch(); } catch (err) { - if (!silent) { - await message(`Could not check for updates.\n\n${err}`, { title: "Update Failed", kind: "error" }); - } + store.set(silent ? { phase: "idle" } : { phase: "error", error: String(err) }); } finally { - running = false; + checking = false; } } + +export async function installUpdate() { + const { update, phase } = useUpdater.getState(); + if (!update || phase === "downloading" || phase === "installing") return; + try { + useUpdater.getState().set({ phase: "downloading", downloaded: 0, total: 0 }); + await update.downloadAndInstall((event) => { + const s = useUpdater.getState(); + if (event.event === "Started") { + s.set({ total: event.data.contentLength ?? 0, downloaded: 0 }); + } else if (event.event === "Progress") { + s.set({ downloaded: s.downloaded + event.data.chunkLength }); + } else if (event.event === "Finished") { + s.set({ phase: "installing" }); + } + }); + useUpdater.getState().set({ phase: "installing" }); + await relaunch(); + } catch (err) { + useUpdater.getState().set({ phase: "error", error: String(err) }); + } +} + +export function dismissUpdate() { + useUpdater.getState().reset(); +}