feat: open Markdown files and in-app updater
Markdown: - File > Open Markdown File... and welcome-screen 'Open .md' import one or more .md files as new books (named after first H1 or the filename; originals never modified, collision-safe folder names). - Book dirs and .md files passed on the command line or via desktop file associations now open directly; single-instance lock forwards second launches to the running window. Updates: - File > Check for Updates... and welcome-screen 'Update' button: queries the Gitea releases API, one-click installs a newer release into ~/.local/share/Folio via a detached swap script that waits for app exit, keeps the old tree until the swap succeeds, and relaunches. Download is gzip-verified and restricted to the trusted release host. No root, no reinstall. Tests: 19 new assertions in tests/run-update-import-test.mjs (wired into npm test); full suite green, tsc clean.
This commit is contained in:
parent
9aebde4c35
commit
ada5eee679
12 changed files with 634 additions and 5 deletions
56
src/main/import-md.ts
Normal file
56
src/main/import-md.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Import standalone Markdown files as Folio books.
|
||||
//
|
||||
// Electron-free on purpose (like backup.ts): takes the file list and the
|
||||
// destination root as arguments so the logic is unit-testable without an
|
||||
// app instance. One book per file, named after the file's first H1 (or its
|
||||
// filename when there is no heading). The original file is never modified —
|
||||
// its content is copied into the new book's chapter.
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { createBook, sanitizeBookFolderName } from "./project.js";
|
||||
import { createChapter, setChapterContent } from "./chapters.js";
|
||||
|
||||
export interface ImportResult {
|
||||
ok: boolean;
|
||||
file: string;
|
||||
bookPath?: string;
|
||||
title?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const MD_EXT = /\.(md|markdown|txt)$/i;
|
||||
|
||||
export function isImportableMarkdown(file: string): boolean {
|
||||
return MD_EXT.test(file);
|
||||
}
|
||||
|
||||
// Title = first ATX H1 ("# Title"), else the filename stem.
|
||||
export function mdTitle(raw: string, fallback: string): string {
|
||||
const m = /^[ \t]*#[ \t]+(.+?)[ \t]*#*[ \t]*$/m.exec(raw);
|
||||
const t = (m && m[1] ? m[1] : fallback).trim();
|
||||
return t || fallback;
|
||||
}
|
||||
|
||||
export function importMarkdownFiles(files: string[], root: string): ImportResult[] {
|
||||
return files.map((file) => {
|
||||
try {
|
||||
const raw = fs.readFileSync(file, "utf-8");
|
||||
const stem = path.basename(file).replace(MD_EXT, "") || "Untitled";
|
||||
const title = mdTitle(raw, stem);
|
||||
const folder =
|
||||
sanitizeBookFolderName(title) || sanitizeBookFolderName(stem) || "Imported";
|
||||
let dir = path.join(root, folder);
|
||||
let n = 2;
|
||||
while (fs.existsSync(dir)) {
|
||||
dir = path.join(root, `${folder} ${n}`);
|
||||
n += 1;
|
||||
}
|
||||
createBook(dir, title);
|
||||
const entry = createChapter(dir, title);
|
||||
setChapterContent(dir, entry.id, raw);
|
||||
return { ok: true, file, bookPath: dir, title };
|
||||
} catch (e) {
|
||||
return { ok: false, file, error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -42,6 +42,18 @@ import { buildDocx, buildOdt } from "./office-export.js";
|
|||
import { getSetting, setSetting } from "./settings.js";
|
||||
import { migrateChapterToNative, migrateBookToNative } from "./migration.js";
|
||||
import { loadChapterDoc, saveChapterDoc } from "./chapters.js";
|
||||
import { importMarkdownFiles, isImportableMarkdown } from "./import-md.js";
|
||||
import {
|
||||
INSTALL_DIR_DEFAULT,
|
||||
LAUNCHER_DEFAULT,
|
||||
UPDATE_BASE_DEFAULT,
|
||||
UPDATE_REPO,
|
||||
buildInstallerScript,
|
||||
isTrustedDownloadUrl,
|
||||
isNewer,
|
||||
parseRelease,
|
||||
pickAsset,
|
||||
} from "./update.js";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let bookPath: string | null = null;
|
||||
|
|
@ -116,6 +128,13 @@ app.whenReady().then(() => {
|
|||
() => getWindow()?.webContents.send("folio:open-book-requested")
|
||||
);
|
||||
createWindow();
|
||||
// Open a book folder or Markdown file passed on the command line
|
||||
// (`folio notes.md`, or file-association launches from the desktop).
|
||||
const initialFile = fileArgFrom(process.argv);
|
||||
if (initialFile) {
|
||||
const win = getWindow();
|
||||
win?.webContents.once("did-finish-load", () => openFromArg(initialFile));
|
||||
}
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
|
|
@ -756,3 +775,221 @@ handleIpc("folio:importAttachment", async () => {
|
|||
const relPath = path.relative(bp, path.join(dir, name)).split(path.sep).join("/");
|
||||
return { ok: true, name, relPath };
|
||||
});
|
||||
|
||||
// ---- Markdown import ------------------------------------------------------
|
||||
|
||||
// Import standalone .md files as Folio books (one book per file, named after
|
||||
// the file's first H1 or its filename). Files are copied — the originals are
|
||||
// never modified. Books land in Documents, same as "New".
|
||||
handleIpc("folio:importMarkdown", async () => {
|
||||
const { canceled, filePaths } = await showOpenDialog({
|
||||
title: "Open Markdown file(s)",
|
||||
properties: ["openFile", "multiSelections"],
|
||||
filters: [{ name: "Markdown", extensions: ["md", "markdown", "txt"] }],
|
||||
});
|
||||
if (canceled || !filePaths.length) return { canceled: true } as const;
|
||||
return importMarkdownFiles(filePaths, app.getPath("documents"));
|
||||
});
|
||||
|
||||
// Open a path that may be either a Folio book directory or a Markdown file
|
||||
// (the desktop launcher can be handed either via file associations).
|
||||
handleIpc("folio:openPathOrImport", async (_e, p: string) => {
|
||||
if (p && isImportableMarkdown(p)) {
|
||||
const results = importMarkdownFiles([p], app.getPath("documents"));
|
||||
const r = results[0];
|
||||
if (!r.ok) return { error: r.error };
|
||||
return openBookAt(r.bookPath as string);
|
||||
}
|
||||
return openBookAt(p);
|
||||
});
|
||||
|
||||
// ---- In-app update --------------------------------------------------------
|
||||
|
||||
// Release source. Defaults to the project's Gitea instance; the installed
|
||||
// tree may override it with update.json next to the binary (e.g. for
|
||||
// staging), and FOLIO_UPDATE_BASE/FOLIO_UPDATE_REPO override everything.
|
||||
function updateConfig(): { base: string; repo: string; installDir: string; launcher: string } {
|
||||
const cfg = {
|
||||
base: UPDATE_BASE_DEFAULT,
|
||||
repo: UPDATE_REPO,
|
||||
installDir: INSTALL_DIR_DEFAULT,
|
||||
launcher: LAUNCHER_DEFAULT,
|
||||
};
|
||||
try {
|
||||
const p = path.join(cfg.installDir, "update.json");
|
||||
if (fs.existsSync(p)) {
|
||||
const j = JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
if (typeof j.base === "string") cfg.base = j.base.replace(/\/+$/, "");
|
||||
if (typeof j.repo === "string") cfg.repo = j.repo;
|
||||
if (typeof j.launcher === "string") cfg.launcher = j.launcher;
|
||||
}
|
||||
} catch {
|
||||
/* malformed update.json: keep defaults */
|
||||
}
|
||||
if (process.env.FOLIO_UPDATE_BASE) cfg.base = process.env.FOLIO_UPDATE_BASE.replace(/\/+$/, "");
|
||||
if (process.env.FOLIO_UPDATE_REPO) cfg.repo = process.env.FOLIO_UPDATE_REPO;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
interface UpdateCheckResult {
|
||||
current: string;
|
||||
latest?: string;
|
||||
tag?: string;
|
||||
updateAvailable?: boolean;
|
||||
assetName?: string;
|
||||
notes?: string;
|
||||
canInstall?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function fetchLatestRelease(): Promise<ReturnType<typeof parseRelease>> {
|
||||
const { base, repo } = updateConfig();
|
||||
const res = await fetch(`${base}/api/v1/repos/${repo}/releases/latest`, {
|
||||
headers: { accept: "application/json" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (res.status === 404) throw new Error("No published releases yet.");
|
||||
if (!res.ok) throw new Error(`Release server returned HTTP ${res.status}.`);
|
||||
return parseRelease(await res.json());
|
||||
}
|
||||
|
||||
handleIpc("folio:checkUpdate", async (): Promise<UpdateCheckResult> => {
|
||||
try {
|
||||
const rel = await fetchLatestRelease();
|
||||
const asset = pickAsset(rel.assets);
|
||||
const current = app.getVersion();
|
||||
return {
|
||||
current,
|
||||
latest: rel.version,
|
||||
tag: rel.tag,
|
||||
updateAvailable: isNewer(rel.version, current),
|
||||
assetName: asset ? asset.name : undefined,
|
||||
notes: rel.notes,
|
||||
canInstall:
|
||||
!!asset &&
|
||||
isTrustedDownloadUrl(asset.browser_download_url, updateConfig().base) &&
|
||||
process.platform === "linux" &&
|
||||
fs.existsSync(path.join(updateConfig().installDir, "folio")),
|
||||
};
|
||||
} catch (e) {
|
||||
return { current: app.getVersion(), error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Download the release tarball to the OS temp dir, verify it looks like a
|
||||
// gzip stream, then hand off to the detached swap script and quit. The old
|
||||
// install is kept until the swap succeeds, so failure can never strand the
|
||||
// user without a working app.
|
||||
handleIpc("folio:performUpdate", async () => {
|
||||
try {
|
||||
const cfg = updateConfig();
|
||||
const rel = await fetchLatestRelease();
|
||||
const asset = pickAsset(rel.assets);
|
||||
if (!asset) return { error: `Release ${rel.tag} has no tar.gz asset to install.` };
|
||||
if (!isTrustedDownloadUrl(asset.browser_download_url, cfg.base)) {
|
||||
return { error: "Download URL is not on the trusted release host; update aborted." };
|
||||
}
|
||||
if (!fs.existsSync(path.join(cfg.installDir, "folio"))) {
|
||||
return {
|
||||
error:
|
||||
"This Folio is not the user-local install (~/.local/share/Folio), so in-place update is unavailable.",
|
||||
};
|
||||
}
|
||||
const tarball = path.join(
|
||||
app.getPath("temp"),
|
||||
`folio-update-${Date.now()}.tar.gz`
|
||||
);
|
||||
const res = await fetch(asset.browser_download_url, {
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(10 * 60 * 1000),
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
return { error: `Download failed (HTTP ${res.status}).` };
|
||||
}
|
||||
const total = Number(res.headers.get("content-length")) || asset.size || 0;
|
||||
const { Readable } = await import("stream");
|
||||
const { createWriteStream } = await import("fs");
|
||||
const { pipeline } = await import("stream/promises");
|
||||
let received = 0;
|
||||
const source = Readable.fromWeb(res.body as never);
|
||||
source.on("data", (chunk: Buffer) => {
|
||||
received += chunk.length;
|
||||
getWindow()?.webContents.send("folio:update-progress", { received, total });
|
||||
});
|
||||
await pipeline(source, createWriteStream(tarball));
|
||||
const head = fs.readFileSync(tarball).subarray(0, 2);
|
||||
if (head.length < 2 || head[0] !== 0x1f || head[1] !== 0x8b) {
|
||||
fs.rmSync(tarball, { force: true });
|
||||
return { error: "Downloaded file is not a valid archive; update aborted." };
|
||||
}
|
||||
const script = path.join(
|
||||
app.getPath("temp"),
|
||||
`folio-update-${process.pid}.sh`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
script,
|
||||
buildInstallerScript({
|
||||
pid: process.pid,
|
||||
tarball,
|
||||
installDir: cfg.installDir,
|
||||
relaunch: cfg.launcher,
|
||||
}),
|
||||
{ mode: 0o700 }
|
||||
);
|
||||
const { spawn } = await import("child_process");
|
||||
const child = spawn("/bin/sh", [script], { detached: true, stdio: "ignore" });
|
||||
child.on("error", (err) => {
|
||||
console.error("[folio] updater spawn failed:", err.message);
|
||||
});
|
||||
child.unref();
|
||||
// Quit so the swap script can replace the app directory.
|
||||
setTimeout(() => app.quit(), 300);
|
||||
return { ok: true, version: rel.version };
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Files passed on the command line / file associations -----------------
|
||||
|
||||
// Pull the first usable file path out of argv (the desktop launcher forwards
|
||||
// %F; `folio path/to/book` or `folio notes.md` both work).
|
||||
function fileArgFrom(argv: string[]): string | null {
|
||||
for (const a of argv.slice(1)) {
|
||||
if (!a || a.startsWith("-")) continue;
|
||||
try {
|
||||
if (fs.statSync(a).isFile() || hasBook(a)) return path.resolve(a);
|
||||
} catch {
|
||||
/* not a readable path; ignore */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function openFromArg(p: string): void {
|
||||
if (isImportableMarkdown(p)) {
|
||||
const results = importMarkdownFiles([p], app.getPath("documents"));
|
||||
const r = results[0];
|
||||
if (r.ok) {
|
||||
openBookAt(r.bookPath as string);
|
||||
return;
|
||||
}
|
||||
console.error(`[folio] import failed for ${p}: ${r.error}`);
|
||||
return;
|
||||
}
|
||||
void openBookAt(p);
|
||||
}
|
||||
|
||||
// Single instance: opening a second file (double-clicking while Folio is
|
||||
// already open) forwards the path to the running window instead of stacking
|
||||
// another app instance.
|
||||
if (app.requestSingleInstanceLock()) {
|
||||
app.on("second-instance", (_e, argv) => {
|
||||
const f = fileArgFrom(argv);
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.focus();
|
||||
}
|
||||
if (f) openFromArg(f);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ export function buildMenu(
|
|||
submenu: [
|
||||
{ label: "New", accelerator: "CmdOrCtrl+N", click: onRequestNew },
|
||||
{ label: "Open", accelerator: "CmdOrCtrl+O", click: onRequestOpen },
|
||||
{
|
||||
label: "Open Markdown File…",
|
||||
click: () => getWindow()?.webContents.send("folio:open-markdown-requested"),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Save",
|
||||
|
|
@ -113,6 +117,11 @@ export function buildMenu(
|
|||
click: () => getWindow()?.webContents.send("folio:export-odt-requested"),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Check for Updates…",
|
||||
click: () => getWindow()?.webContents.send("folio:check-update-requested"),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Close",
|
||||
accelerator: isMac ? "Cmd+W" : "Ctrl+W",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ contextBridge.exposeInMainWorld("folio", {
|
|||
exportOdt: (chapterIds?: string[] | null) =>
|
||||
ipcRenderer.invoke("folio:exportOdt", chapterIds),
|
||||
importAttachment: () => ipcRenderer.invoke("folio:importAttachment"),
|
||||
openMarkdownFiles: () => ipcRenderer.invoke("folio:importMarkdown"),
|
||||
openPathOrImport: (p: string) => ipcRenderer.invoke("folio:openPathOrImport", p),
|
||||
checkUpdate: () => ipcRenderer.invoke("folio:checkUpdate"),
|
||||
performUpdate: () => ipcRenderer.invoke("folio:performUpdate"),
|
||||
onUpdateProgress: (cb: (p: { received: number; total: number }) => void) =>
|
||||
ipcRenderer.on("folio:update-progress", (_e, data) => cb(data)),
|
||||
copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text),
|
||||
getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"),
|
||||
toggleFullscreen: () => ipcRenderer.invoke("folio:toggleFullscreen"),
|
||||
|
|
@ -110,4 +116,8 @@ contextBridge.exposeInMainWorld("folio", {
|
|||
ipcRenderer.on("folio:zoom-out-requested", () => cb()),
|
||||
onZoomResetRequested: (cb: () => void) =>
|
||||
ipcRenderer.on("folio:zoom-reset-requested", () => cb()),
|
||||
onOpenMarkdownRequested: (cb: () => void) =>
|
||||
ipcRenderer.on("folio:open-markdown-requested", () => cb()),
|
||||
onCheckUpdateRequested: (cb: () => void) =>
|
||||
ipcRenderer.on("folio:check-update-requested", () => cb()),
|
||||
});
|
||||
|
|
|
|||
173
src/main/update.ts
Normal file
173
src/main/update.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
// In-app update support for the user-local install of Folio.
|
||||
//
|
||||
// Architecture: a release published on the Gitea instance carries a
|
||||
// linux tar.gz asset (the unpacked Electron app). "Install update"
|
||||
// downloads that tarball, then hands off to a tiny detached shell script
|
||||
// which waits for this process to exit, swaps ~/.local/share/Folio with the
|
||||
// new contents (keeping a .old copy until the swap succeeds), and relaunches
|
||||
// the user launcher. No root, no package manager, no reinstall.
|
||||
//
|
||||
// The pure logic lives here (Electron-free) so it is unit-testable; index.ts
|
||||
// only wires IPC, fetch, and spawn around it.
|
||||
import path from "path";
|
||||
|
||||
export const UPDATE_BASE_DEFAULT = "https://git.atitlan.io";
|
||||
export const UPDATE_REPO = "avi/Folio";
|
||||
export const INSTALL_DIR_DEFAULT = path.join(
|
||||
process.env.HOME || "~",
|
||||
".local",
|
||||
"share",
|
||||
"Folio"
|
||||
);
|
||||
export const LAUNCHER_DEFAULT = path.join(
|
||||
process.env.HOME || "~",
|
||||
".local",
|
||||
"bin",
|
||||
"folio"
|
||||
);
|
||||
|
||||
export interface ReleaseAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface ReleaseInfo {
|
||||
tag: string;
|
||||
version: string;
|
||||
url?: string;
|
||||
notes?: string;
|
||||
assets: ReleaseAsset[];
|
||||
}
|
||||
|
||||
// "v0.1.2" / "0.1.2-rc1" / "0.1.2" -> [0,1,2]; null when unparseable.
|
||||
export function parseVersion(v: string): number[] | null {
|
||||
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v || "").trim());
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
}
|
||||
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const va = parseVersion(a);
|
||||
const vb = parseVersion(b);
|
||||
if (!va || !vb) return 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (va[i] !== vb[i]) return va[i] < vb[i] ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isNewer(latest: string, current: string): boolean {
|
||||
return compareVersions(latest, current) > 0;
|
||||
}
|
||||
|
||||
// Normalize a Gitea release JSON object. Throws when the payload has no tag
|
||||
// (including Gitea's error objects, which carry a message but no tag_name).
|
||||
export function parseRelease(json: any): ReleaseInfo {
|
||||
const tag = json && typeof json.tag_name === "string" ? json.tag_name : "";
|
||||
if (!tag) {
|
||||
const msg = json && typeof json.message === "string" ? json.message : "invalid response";
|
||||
throw new Error(`No release found (${msg})`);
|
||||
}
|
||||
const assets: ReleaseAsset[] = Array.isArray(json.assets)
|
||||
? json.assets
|
||||
.filter(
|
||||
(a: any) =>
|
||||
a &&
|
||||
typeof a.name === "string" &&
|
||||
typeof a.browser_download_url === "string"
|
||||
)
|
||||
.map((a: any) => ({
|
||||
name: a.name,
|
||||
browser_download_url: a.browser_download_url,
|
||||
size: typeof a.size === "number" ? a.size : undefined,
|
||||
}))
|
||||
: [];
|
||||
const version = (parseVersion(tag) ? tag.replace(/^v/, "") : "").trim();
|
||||
return {
|
||||
tag,
|
||||
version: version || tag,
|
||||
url: typeof json.html_url === "string" ? json.html_url : undefined,
|
||||
notes: typeof json.body === "string" ? json.body : undefined,
|
||||
assets,
|
||||
};
|
||||
}
|
||||
|
||||
// Choose the update asset: the linux tar.gz (preferred, unpackable without
|
||||
// FUSE). AppImage/deb assets exist but the swap logic here installs the
|
||||
// unpacked tree, so only tar.gz is eligible.
|
||||
export function pickAsset(assets: ReleaseAsset[]): ReleaseAsset | null {
|
||||
const tarballs = assets.filter((a) => /\.tar\.gz$/.test(a.name));
|
||||
const linux = tarballs.find(
|
||||
(a) => /linux/i.test(a.name) && /x86_64|amd64/i.test(a.name)
|
||||
);
|
||||
return linux || tarballs[0] || null;
|
||||
}
|
||||
|
||||
// The download URL must live on the same origin as the configured update
|
||||
// base — this blocks a malicious release body from redirecting the installer
|
||||
// to arbitrary hosts.
|
||||
export function isTrustedDownloadUrl(url: string, base: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const b = new URL(base);
|
||||
return u.protocol === b.protocol && u.host === b.host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Single-quote a path for safe interpolation into /bin/sh scripts.
|
||||
export function shQuote(p: string): string {
|
||||
return `'${String(p).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
export interface InstallerSpec {
|
||||
pid: number;
|
||||
tarball: string;
|
||||
installDir: string;
|
||||
relaunch: string;
|
||||
}
|
||||
|
||||
// The handoff script. Deliberately forgiving (no set -e): on any failure it
|
||||
// still relaunches the OLD version, so a bad download can never leave the
|
||||
// user without an app. The old tree is kept as .old.<pid> and pruned only
|
||||
// after a successful swap.
|
||||
export function buildInstallerScript(spec: InstallerSpec): string {
|
||||
const { pid, tarball, installDir, relaunch } = spec;
|
||||
return [
|
||||
"#!/bin/sh",
|
||||
"# Folio updater — waits for the app to exit, swaps in the new version,",
|
||||
"# then relaunches. Generated by Folio; safe to delete after one run.",
|
||||
`PID=${Number(pid) || -1}`,
|
||||
`TARBALL=${shQuote(tarball)}`,
|
||||
`INSTALL=${shQuote(installDir)}`,
|
||||
`RELAUNCH=${shQuote(relaunch)}`,
|
||||
`NEW="$INSTALL.update-new.$$"`,
|
||||
`OLD="$INSTALL.old.$$"`,
|
||||
"# Wait (bounded to ~2 minutes) for the app process to exit.",
|
||||
"i=0",
|
||||
'while kill -0 "$PID" 2>/dev/null && [ "$i" -lt 400 ]; do sleep 0.3; i=$((i+1)); done',
|
||||
'rm -rf "$NEW"',
|
||||
'mkdir -p "$NEW"',
|
||||
'if tar -xzf "$TARBALL" -C "$NEW"; then',
|
||||
' # electron-builder nests everything under a top-level dir; flatten it.',
|
||||
' if [ ! -e "$NEW/folio" ]; then',
|
||||
' ONLY=$(find "$NEW" -mindepth 1 -maxdepth 1 -type d | head -n 1)',
|
||||
' [ -n "$ONLY" ] && mv "$ONLY"/* "$NEW"/ 2>/dev/null',
|
||||
" fi",
|
||||
"fi",
|
||||
'if [ -x "$NEW/folio" ]; then',
|
||||
' [ -e "$INSTALL" ] && mv "$INSTALL" "$OLD"',
|
||||
' if mv "$NEW" "$INSTALL"; then',
|
||||
' rm -rf "$OLD" "$INSTALL.update-new".* "$INSTALL.old".* 2>/dev/null',
|
||||
' rm -f "$TARBALL"',
|
||||
" else",
|
||||
' [ -e "$OLD" ] && mv "$OLD" "$INSTALL"',
|
||||
" fi",
|
||||
"else",
|
||||
' rm -rf "$NEW"',
|
||||
"fi",
|
||||
'"$RELAUNCH" >/dev/null 2>&1 &',
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
|
@ -131,6 +131,50 @@ export function initBook(api: FolioAPI) {
|
|||
// The comments module owns its own DOM chrome and wiring; here we only keep the
|
||||
// top-level constructor call and the bridge assignments it already set above.
|
||||
|
||||
// ---- update flow ----
|
||||
// "Check for Updates…" (File menu / welcome button): query the release
|
||||
// server, then offer a one-click install. The app downloads the tarball,
|
||||
// hands off to a detached swap script, and relaunches itself — no manual
|
||||
// reinstall. See src/main/update.ts for the safety properties.
|
||||
async function checkForUpdates() {
|
||||
showToast("Checking for updates…");
|
||||
const res = await api.checkUpdate();
|
||||
if (!res || res.error) {
|
||||
await alertMessage("Update check failed", (res && res.error) || "No response from the release server.");
|
||||
return;
|
||||
}
|
||||
if (!res.updateAvailable) {
|
||||
await alertMessage("You're up to date", `Folio ${res.current} is the latest release.`);
|
||||
return;
|
||||
}
|
||||
const go = await confirmMessage(
|
||||
`Update to ${res.latest}?`,
|
||||
`Folio ${res.current} → ${res.latest}. Folio will download the update, restart, and finish installing on its own.`,
|
||||
"Update"
|
||||
);
|
||||
if (!go) return;
|
||||
if (!res.canInstall) {
|
||||
await alertMessage(
|
||||
"Automatic install unavailable",
|
||||
"This release has no installable Linux archive, or this Folio is not the user-local install. Download it manually from the releases page."
|
||||
);
|
||||
return;
|
||||
}
|
||||
let lastPct = -1;
|
||||
api.onUpdateProgress(({ received, total }) => {
|
||||
const pct = total ? Math.floor((received / total) * 100) : -1;
|
||||
if (pct !== lastPct) {
|
||||
lastPct = pct;
|
||||
showToast(pct >= 0 ? `Downloading update… ${pct}%` : `Downloading update… ${(received / 1048576).toFixed(1)} MB`);
|
||||
}
|
||||
});
|
||||
const up = await api.performUpdate();
|
||||
if (up && up.error) {
|
||||
await alertMessage("Update failed", up.error);
|
||||
}
|
||||
// On success the app quits momentarily and reopens by itself.
|
||||
}
|
||||
|
||||
// ---- api menu handlers ----
|
||||
// These now delegate to the extracted feature facades rather than calling
|
||||
// inline functions defined in this module.
|
||||
|
|
@ -140,6 +184,9 @@ export function initBook(api: FolioAPI) {
|
|||
api.onNewBookRequested(() => void welcome.startNewBook());
|
||||
api.onOpenBookRequested(() => void welcome.startOpenBook());
|
||||
api.onOpenRecent((p) => void welcome.openPath(p));
|
||||
api.onOpenMarkdownRequested(() => void welcome.openMarkdown());
|
||||
api.onCheckUpdateRequested(() => void checkForUpdates());
|
||||
$("welcomeUpdate")?.addEventListener("click", () => void checkForUpdates());
|
||||
api.onRevealRequested(async (p) => {
|
||||
const ok = await api.revealInFolder(p);
|
||||
showToast(
|
||||
|
|
|
|||
|
|
@ -64,6 +64,11 @@ export type FolioAPI = {
|
|||
exportDocx: (chapterIds?: string[] | null) => Promise<any>;
|
||||
exportOdt: (chapterIds?: string[] | null) => Promise<any>;
|
||||
importAttachment: () => Promise<any>;
|
||||
openMarkdownFiles: () => Promise<any>;
|
||||
openPathOrImport: (p: string) => Promise<any>;
|
||||
checkUpdate: () => Promise<any>;
|
||||
performUpdate: () => Promise<any>;
|
||||
onUpdateProgress: (cb: (p: { received: number; total: number }) => void) => void;
|
||||
copyText: (text: string) => Promise<any>;
|
||||
getClipboardText: () => Promise<string>;
|
||||
toggleFullscreen: () => Promise<boolean>;
|
||||
|
|
@ -91,6 +96,8 @@ export type FolioAPI = {
|
|||
onZoomInRequested: (cb: () => void) => void;
|
||||
onZoomOutRequested: (cb: () => void) => void;
|
||||
onZoomResetRequested: (cb: () => void) => void;
|
||||
onOpenMarkdownRequested: (cb: () => void) => void;
|
||||
onCheckUpdateRequested: (cb: () => void) => void;
|
||||
};
|
||||
|
||||
// ---- P7 decomposition contracts ----
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface WelcomeFeature {
|
|||
refreshMeta(): Promise<void>;
|
||||
startNewBook(): Promise<void>;
|
||||
startOpenBook(): Promise<void>;
|
||||
openMarkdown(): Promise<void>;
|
||||
openPath(p: string): Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -162,6 +163,24 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
|||
await renderRecents();
|
||||
}
|
||||
|
||||
// Open one or more standalone Markdown files: each becomes a new book
|
||||
// (copied, originals untouched) and the first one opens immediately.
|
||||
async function openMarkdown() {
|
||||
const res = await api.openMarkdownFiles();
|
||||
if (!res || "canceled" in res) return;
|
||||
if (!Array.isArray(res)) { await alertMessage("Error", (res as { error: string }).error); return; }
|
||||
const results = res as { ok: boolean; file: string; bookPath?: string; title?: string; error?: string }[];
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
const first = results.find((r) => r.ok);
|
||||
if (first?.bookPath) await openPath(first.bookPath);
|
||||
if (failed.length) {
|
||||
await alertMessage(
|
||||
"Some files could not be imported",
|
||||
failed.map((f) => `${f.file}: ${f.error}`).join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderRecents() {
|
||||
try {
|
||||
const recents = await api.getRecents();
|
||||
|
|
@ -223,6 +242,7 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
|||
// ---- wiring (bind once at construction) ----
|
||||
$("welcomeNewBook").addEventListener("click", () => void startNewBook());
|
||||
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
|
||||
$("welcomeOpenMd")?.addEventListener("click", () => void openMarkdown());
|
||||
homeBtn?.addEventListener("click", async () => {
|
||||
hooks.clearTimers();
|
||||
if (state.selectedId && state.dirty) {
|
||||
|
|
@ -244,5 +264,6 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
|||
startNewBook,
|
||||
startOpenBook,
|
||||
openPath,
|
||||
openMarkdown,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const cancelBtn = el("modalCancel") as HTMLButtonElement;
|
|||
let currentMode: Mode = "prompt";
|
||||
let resolver: ((value: string | null) => void) | null = null;
|
||||
|
||||
function open(mode: Mode, title: string, body: string, value: string): Promise<string | null> {
|
||||
function open(mode: Mode, title: string, body: string, value: string, okLabel?: string): Promise<string | null> {
|
||||
// If a previous dialog was somehow still open (menu accelerators can open
|
||||
// one while another is visible), settle its promise as cancelled so the
|
||||
// awaiting caller isn't stranded forever.
|
||||
|
|
@ -30,7 +30,7 @@ function open(mode: Mode, title: string, body: string, value: string): Promise<s
|
|||
inputEl.classList.toggle("hidden", mode !== "prompt");
|
||||
msgEl.classList.toggle("hidden", mode === "prompt");
|
||||
cancelBtn.classList.toggle("hidden", mode === "alert");
|
||||
okBtn.textContent = mode === "confirm" ? "Delete" : "OK";
|
||||
okBtn.textContent = okLabel || (mode === "confirm" ? "Delete" : "OK");
|
||||
openEl(backdrop);
|
||||
if (mode === "prompt") {
|
||||
inputEl.focus();
|
||||
|
|
@ -84,8 +84,8 @@ export function promptInput(title: string, value?: string): Promise<string | nul
|
|||
return open("prompt", title, "", value ?? "");
|
||||
}
|
||||
|
||||
export function confirmMessage(title: string, body?: string): Promise<boolean> {
|
||||
return open("confirm", title, body ?? "", "").then((v) => v !== null);
|
||||
export function confirmMessage(title: string, body?: string, okLabel?: string): Promise<boolean> {
|
||||
return open("confirm", title, body ?? "", "", okLabel).then((v) => v !== null);
|
||||
}
|
||||
|
||||
export function alertMessage(title: string, body?: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@
|
|||
<div class="welcome-actions">
|
||||
<button class="button" id="welcomeNewBook">New</button>
|
||||
<button class="button button-ghost" id="welcomeOpenBook">Open</button>
|
||||
<button class="button button-ghost" id="welcomeOpenMd" title="Open a Markdown file as a new book">Open .md</button>
|
||||
<button class="button button-ghost" id="welcomeUpdate" title="Check for a new version of Folio">Update</button>
|
||||
</div>
|
||||
<div class="recents-block">
|
||||
<div class="recents-title">Recent</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue