3 Commits
Author SHA1 Message Date
github-actions[bot] 4fa14306d1 chore(release): v0.1.15 2026-07-15 04:02:28 +00:00
pj acf78dfe29 feat(menu): reorganize native menu bar with discoverable actions
Move Check for Updates into the macOS app menu and add Settings (Cmd+,),
Save (Cmd+S), Find (Cmd+F), chapter navigation, and a Help > Report an
Issue link. Route new menu actions through menu-action events and trim
duplicate editor keybindings.
2026-07-15 09:31:57 +05:30
pj 866a971729 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.
2026-07-15 09:31:57 +05:30
12 changed files with 379 additions and 45 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "margin-app", "name": "margin-app",
"private": true, "private": true,
"version": "0.1.14", "version": "0.1.15",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -4713,7 +4713,7 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]] [[package]]
name = "margin-app" name = "margin-app"
version = "0.1.11" version = "0.1.12"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"fontdb", "fontdb",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "margin-app" name = "margin-app"
version = "0.1.14" version = "0.1.15"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
+92 -16
View File
@@ -16,50 +16,116 @@ use tauri::{Emitter, Runtime};
#[cfg(desktop)] #[cfg(desktop)]
fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> { fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
let menu = Menu::default(handle)?; let menu = Menu::default(handle)?;
let new_book = MenuItemBuilder::with_id("new-book", "New Book") let new_book = MenuItemBuilder::with_id("new-book", "New Book")
.accelerator("CmdOrCtrl+N") .accelerator("CmdOrCtrl+N")
.build(handle)?; .build(handle)?;
let export_pdf = MenuItemBuilder::with_id("export-pdf", "Export as PDF…").build(handle)?; let save = MenuItemBuilder::with_id("save", "Save")
let export_epub = MenuItemBuilder::with_id("export-epub", "Export as EPUB…").build(handle)?; .accelerator("CmdOrCtrl+S")
.build(handle)?;
let export_pdf = MenuItemBuilder::with_id("export-pdf", "Export as PDF…")
.accelerator("CmdOrCtrl+Shift+P")
.build(handle)?;
let export_epub = MenuItemBuilder::with_id("export-epub", "Export as EPUB…")
.accelerator("CmdOrCtrl+Shift+E")
.build(handle)?;
let check_updates = let check_updates =
MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?; MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?;
let settings = MenuItemBuilder::with_id("settings", "Settings…")
.accelerator("CmdOrCtrl+,")
.build(handle)?;
let find = MenuItemBuilder::with_id("find", "Find…")
.accelerator("CmdOrCtrl+F")
.build(handle)?;
let report_issue =
MenuItemBuilder::with_id("report-issue", "Report an Issue…").build(handle)?;
let file_submenu = menu let submenus: Vec<_> = menu
.items()? .items()?
.into_iter() .into_iter()
.find_map(|item| match item { .filter_map(|item| match item {
MenuItemKind::Submenu(submenu) MenuItemKind::Submenu(submenu) => Some(submenu),
if submenu.text().map(|t| t == "File").unwrap_or(false) =>
{
Some(submenu)
}
_ => None, _ => None,
}); })
.collect();
match file_submenu { let find_submenu = |name: &str| {
submenus
.iter()
.find(|submenu| submenu.text().map(|t| t == name).unwrap_or(false))
.cloned()
};
match find_submenu("File") {
Some(submenu) => { Some(submenu) => {
submenu.prepend_items(&[ submenu.prepend_items(&[
&new_book, &new_book,
&PredefinedMenuItem::separator(handle)?, &PredefinedMenuItem::separator(handle)?,
&save,
&PredefinedMenuItem::separator(handle)?,
&export_pdf, &export_pdf,
&export_epub, &export_epub,
&PredefinedMenuItem::separator(handle)?, &PredefinedMenuItem::separator(handle)?,
&check_updates,
&PredefinedMenuItem::separator(handle)?,
])?; ])?;
} }
None => { None => {
let submenu = SubmenuBuilder::new(handle, "File") let submenu = SubmenuBuilder::new(handle, "File")
.item(&new_book) .item(&new_book)
.item(&PredefinedMenuItem::separator(handle)?) .item(&PredefinedMenuItem::separator(handle)?)
.item(&save)
.item(&PredefinedMenuItem::separator(handle)?)
.item(&export_pdf) .item(&export_pdf)
.item(&export_epub) .item(&export_epub)
.item(&PredefinedMenuItem::separator(handle)?)
.item(&check_updates)
.build()?; .build()?;
menu.insert(&submenu, 1)?; menu.insert(&submenu, 1)?;
} }
} }
if let Some(edit) = find_submenu("Edit") {
edit.append_items(&[&PredefinedMenuItem::separator(handle)?, &find])?;
}
if let Some(help) = find_submenu("Help") {
help.append_items(&[&report_issue])?;
}
#[cfg(target_os = "macos")]
{
if let Some(app_submenu) = submenus.first() {
app_submenu.insert(&check_updates, 1)?;
app_submenu.insert(&settings, 3)?;
app_submenu.insert(&PredefinedMenuItem::separator(handle)?, 4)?;
}
if let Some(view) = find_submenu("View") {
let toggle_chapters = MenuItemBuilder::with_id("toggle-chapters", "Toggle Chapters")
.accelerator("CmdOrCtrl+\\")
.build(handle)?;
let next_chapter = MenuItemBuilder::with_id("next-chapter", "Next Chapter")
.accelerator("CmdOrCtrl+Alt+Down")
.build(handle)?;
let prev_chapter = MenuItemBuilder::with_id("prev-chapter", "Previous Chapter")
.accelerator("CmdOrCtrl+Alt+Up")
.build(handle)?;
view.prepend_items(&[
&toggle_chapters,
&PredefinedMenuItem::separator(handle)?,
&next_chapter,
&prev_chapter,
&PredefinedMenuItem::separator(handle)?,
])?;
}
}
#[cfg(not(target_os = "macos"))]
{
if let Some(file) = find_submenu("File") {
file.append_items(&[&PredefinedMenuItem::separator(handle)?, &check_updates])?;
}
if let Some(edit) = find_submenu("Edit") {
edit.append_items(&[&settings])?;
}
}
Ok(menu) Ok(menu)
} }
@@ -95,7 +161,17 @@ pub fn run() {
.on_menu_event(|app, event| { .on_menu_event(|app, event| {
if matches!( if matches!(
event.id().0.as_str(), event.id().0.as_str(),
"new-book" | "export-pdf" | "export-epub" | "check-updates" "new-book"
| "save"
| "export-pdf"
| "export-epub"
| "check-updates"
| "settings"
| "find"
| "toggle-chapters"
| "next-chapter"
| "prev-chapter"
| "report-issue"
) { ) {
app.emit("menu-action", event.id().0.as_str()).ok(); app.emit("menu-action", event.id().0.as_str()).ok();
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Margin", "productName": "Margin",
"version": "0.1.14", "version": "0.1.15",
"identifier": "studio.margin.app", "identifier": "studio.margin.app",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
+5
View File
@@ -1,10 +1,12 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { openUrl } from "@tauri-apps/plugin-opener";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { Library } from "./components/Library"; 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";
@@ -28,6 +30,8 @@ function App() {
} }
else if (event.payload === "export-epub") runExport("epub"); else if (event.payload === "export-epub") runExport("epub");
else if (event.payload === "check-updates") checkForUpdates(false); else if (event.payload === "check-updates") checkForUpdates(false);
else if (event.payload === "report-issue")
openUrl("https://github.com/priyanshujain/margin/issues").catch(() => {});
}); });
const unlistenWarn = listen<string>("pdf-warnings", (event) => { const unlistenWarn = listen<string>("pdf-warnings", (event) => {
useBook.getState().setNotice(`PDF exported with warnings:\n${event.payload}`); useBook.getState().setNotice(`PDF exported with warnings:\n${event.payload}`);
@@ -87,6 +91,7 @@ function App() {
{book ? <EditorView /> : <Library onOpen={openBook} />} {book ? <EditorView /> : <Library onOpen={openBook} />}
{isDesktop && <BackupSettings />} {isDesktop && <BackupSettings />}
{isDesktop && <ExportPreview />} {isDesktop && <ExportPreview />}
{isDesktop && <UpdateDialog />}
</> </>
); );
} }
+25 -8
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import type { Editor as TiptapEditor } from "@tiptap/react"; import type { Editor as TiptapEditor } from "@tiptap/react";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { Dock } from "./Dock"; import { Dock } from "./Dock";
@@ -129,17 +130,14 @@ export function EditorView() {
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") { if (e.metaKey && e.altKey && e.key.toLowerCase() === "f") {
e.preventDefault(); e.preventDefault();
saveNow(); openFind(true);
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") { } else if (e.metaKey && e.altKey && e.key === "ArrowRight") {
e.preventDefault();
openFind(e.altKey);
} else if (e.metaKey && e.altKey && (e.key === "ArrowDown" || e.key === "ArrowRight")) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
switchChapter(1); switchChapter(1);
} else if (e.metaKey && e.altKey && (e.key === "ArrowUp" || e.key === "ArrowLeft")) { } else if (e.metaKey && e.altKey && e.key === "ArrowLeft") {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
switchChapter(-1); switchChapter(-1);
@@ -151,7 +149,26 @@ export function EditorView() {
}; };
window.addEventListener("keydown", onKey, true); window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true);
}, [saveNow, openFind, switchChapter]); }, [openFind, switchChapter]);
const menuCommands = useRef({ saveNow, openFind, setSettingsOpen, toggleSidebar, switchChapter });
menuCommands.current = { saveNow, openFind, setSettingsOpen, toggleSidebar, switchChapter };
useEffect(() => {
if (!isDesktop) return;
const unlisten = listen<string>("menu-action", (e) => {
const cmd = menuCommands.current;
if (e.payload === "save") cmd.saveNow();
else if (e.payload === "find") cmd.openFind(false);
else if (e.payload === "settings") cmd.setSettingsOpen(true);
else if (e.payload === "toggle-chapters") cmd.toggleSidebar();
else if (e.payload === "next-chapter") cmd.switchChapter(1);
else if (e.payload === "prev-chapter") cmd.switchChapter(-1);
});
return () => {
unlisten.then((stop) => stop());
};
}, []);
useEffect(() => { useEffect(() => {
if (!widthOpen && !exportOpen && !moreOpen) return; if (!widthOpen && !exportOpen && !moreOpen) return;
+104
View File
@@ -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 &amp; Restart
</button>
</div>
)}
{(phase === "uptodate" || phase === "error") && (
<div className="panel-foot">
<button className="btn-primary" onClick={dismissUpdate}>
OK
</button>
</div>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -20,7 +20,7 @@ const TRIM: Record<TrimSize, { w: string; h: string }> = {
a5: { w: "148mm", h: "210mm" }, a5: { w: "148mm", h: "210mm" },
}; };
const INLINE_SPECIAL = /[\\#$*_`<>@~[\]]/g; const INLINE_SPECIAL = /[\\#$*_`<>@~()[\]]/g;
const LINE_SEPARATORS = /[\n\r\v\f\u2028\u2029]+/g; const LINE_SEPARATORS = /[\n\r\v\f\u2028\u2029]+/g;
function esc(text: string): string { function esc(text: string): string {
+39
View File
@@ -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),
}));
+66
View File
@@ -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
View File
@@ -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();
}