mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 11:07:04 +00:00
feat(updater): show in-app progress during app update
Replace native update dialogs with an in-app modal that shows a live download progress bar and installing state, so updates no longer feel abrupt. Drives the plugin's downloadAndInstall progress events through a zustand store.
This commit is contained in:
1 parent
7ea33b5bfb
commit
866a971729
5 files changed
+254
-16
No files matched your search
@@ -5,6 +5,7 @@ import { Library } from "./components/Library";
|
|||||||
import { EditorView } from "./components/EditorView";
|
import { EditorView } from "./components/EditorView";
|
||||||
import { BackupSettings } from "./components/BackupSettings";
|
import { BackupSettings } from "./components/BackupSettings";
|
||||||
import { ExportPreview } from "./components/ExportPreview";
|
import { ExportPreview } from "./components/ExportPreview";
|
||||||
|
import { UpdateDialog } from "./components/UpdateDialog";
|
||||||
import { useBook } from "./store/useBook";
|
import { useBook } from "./store/useBook";
|
||||||
import { useBackup } from "./store/useBackup";
|
import { useBackup } from "./store/useBackup";
|
||||||
import { useExportPreview } from "./store/useExportPreview";
|
import { useExportPreview } from "./store/useExportPreview";
|
||||||
@@ -87,6 +88,7 @@ function App() {
|
|||||||
{book ? <EditorView /> : <Library onOpen={openBook} />}
|
{book ? <EditorView /> : <Library onOpen={openBook} />}
|
||||||
{isDesktop && <BackupSettings />}
|
{isDesktop && <BackupSettings />}
|
||||||
{isDesktop && <ExportPreview />}
|
{isDesktop && <ExportPreview />}
|
||||||
|
{isDesktop && <UpdateDialog />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useUpdater } from "../store/useUpdater";
|
||||||
|
import { installUpdate, dismissUpdate } from "../updater";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
const TITLES: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div className="overlay" onClick={close}>
|
||||||
|
<div className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="panel-head">
|
||||||
|
<h2>{TITLES[phase]}</h2>
|
||||||
|
{!busy && (
|
||||||
|
<button className="icon-btn" onClick={dismissUpdate} title="Close">
|
||||||
|
<Icon d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel-body">
|
||||||
|
{phase === "checking" && (
|
||||||
|
<div className="update-status-row">
|
||||||
|
<span className="update-spinner" />
|
||||||
|
<span>Checking for updates…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "uptodate" && <p className="confirm-text">margin is up to date.</p>}
|
||||||
|
|
||||||
|
{phase === "available" && (
|
||||||
|
<>
|
||||||
|
<p className="confirm-text">
|
||||||
|
<strong>margin {version}</strong> is available.
|
||||||
|
</p>
|
||||||
|
{notes && <div className="update-notes">{notes}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "downloading" && (
|
||||||
|
<>
|
||||||
|
<div className={`update-progress${total > 0 ? "" : " indeterminate"}`}>
|
||||||
|
<div className="update-progress-fill" style={total > 0 ? { width: `${pct}%` } : undefined} />
|
||||||
|
</div>
|
||||||
|
<p className="update-status">{total > 0 ? `Downloading update… ${pct}%` : "Downloading update…"}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "installing" && (
|
||||||
|
<div className="update-status-row">
|
||||||
|
<span className="update-spinner" />
|
||||||
|
<span>Installing update. margin will restart…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "error" && (
|
||||||
|
<>
|
||||||
|
<p className="confirm-text">Could not update margin.</p>
|
||||||
|
{error && <div className="update-notes">{error}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{phase === "available" && (
|
||||||
|
<div className="panel-foot">
|
||||||
|
<button className="btn-ghost" onClick={dismissUpdate}>
|
||||||
|
Later
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary" onClick={installUpdate}>
|
||||||
|
Install & Restart
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(phase === "uptodate" || phase === "error") && (
|
||||||
|
<div className="panel-foot">
|
||||||
|
<button className="btn-primary" onClick={dismissUpdate}>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<UpdaterState>) => 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<UpdaterState>((set) => ({
|
||||||
|
...initial,
|
||||||
|
set: (partial) => set(partial),
|
||||||
|
reset: () => set(initial),
|
||||||
|
}));
|
||||||
@@ -1615,6 +1615,72 @@ body.resizing .sidebar {
|
|||||||
color: var(--ink);
|
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-primary,
|
||||||
.btn-ghost,
|
.btn-ghost,
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
|
|||||||
+43
-16
@@ -1,31 +1,58 @@
|
|||||||
import { check } from "@tauri-apps/plugin-updater";
|
import { check } from "@tauri-apps/plugin-updater";
|
||||||
import { relaunch } from "@tauri-apps/plugin-process";
|
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) {
|
export async function checkForUpdates(silent: boolean) {
|
||||||
if (running) return;
|
if (checking) return;
|
||||||
running = true;
|
checking = true;
|
||||||
|
const store = useUpdater.getState();
|
||||||
|
if (!silent) store.set({ phase: "checking", error: "" });
|
||||||
try {
|
try {
|
||||||
const update = await check();
|
const update = await check();
|
||||||
if (!update) {
|
if (!update) {
|
||||||
if (!silent) await message("margin is up to date.", { title: "Check for Updates" });
|
store.set(silent ? { phase: "idle" } : { phase: "uptodate" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const notes = update.body ? `\n\n${update.body}` : "";
|
store.set({
|
||||||
const install = await ask(`margin ${update.version} is available.${notes}\n\nInstall now and restart?`, {
|
phase: "available",
|
||||||
title: "Update Available",
|
update,
|
||||||
kind: "info",
|
version: update.version,
|
||||||
|
notes: update.body ?? "",
|
||||||
|
downloaded: 0,
|
||||||
|
total: 0,
|
||||||
|
error: "",
|
||||||
});
|
});
|
||||||
if (!install) return;
|
|
||||||
await update.downloadAndInstall();
|
|
||||||
await relaunch();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!silent) {
|
store.set(silent ? { phase: "idle" } : { phase: "error", error: String(err) });
|
||||||
await message(`Could not check for updates.\n\n${err}`, { title: "Update Failed", kind: "error" });
|
|
||||||
}
|
|
||||||
} finally {
|
} 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();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user