Files
Your NameandClaude Sonnet 5.5 d5d25eaf46 perf: faster startup and long pages
- open the window while the notes folder loads; rebuild the search index after the page list is sent
- load mermaid and katex on demand
- vault.list reads files in parallel batches
- resolveLink uses a per-page-list lookup table
- very long pages (>300 blocks) skip layout of off-screen blocks
- perf-large: add a long-page case, fix the tasks poll

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:25:45 +02:00

788 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const { app, BrowserWindow, ipcMain, dialog, shell, protocol, net, Menu, session, nativeTheme, globalShortcut, screen } = require('electron');
const fs = require('node:fs');
const fsp = fs.promises;
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { Vault, titleOf, setLimits } = require('./vault.cjs');
const { backup, due: backupDue } = require('./backup.cjs');
const links = require('./links.cjs');
const tasks = require('./tasks.cjs');
const importer = require('./importer.cjs');
const canvas = require('./canvas.cjs');
const linkpreview = require('./linkpreview.cjs');
const imagefetch = require('./imagefetch.cjs');
// What search indexes for a page: a canvas's text, not its JSON.
const indexed = (id, body) => (canvas.isCanvasId(id) ? canvas.canvasText(body) : body);
const { openDb } = require('./db.cjs');
const { isEmpty, seedVault } = require('./seed.cjs');
const settings = require('./settings.cjs');
const DEV = process.argv.includes('--dev');
const DEV_URL = process.env.VITE_DEV_SERVER_URL || 'http://localhost:5173';
const BUILD = path.join(__dirname, '..', 'build');
const MAC = process.platform === 'darwin';
protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true } },
// Images inside the notes folder: notes-media://vault/<page id>?src=<path as written in the page>
{ scheme: 'notes-media', privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true, stream: true } }
]);
// The app used to be called "Notes"; its settings (notes folder, favorites, window size) lived under that name.
(function moveOldSettings() {
const now = path.join(app.getPath('userData'), 'settings.json');
const old = path.join(app.getPath('appData'), 'Notes', 'settings.json');
try {
if (!fs.existsSync(now) && fs.existsSync(old)) {
fs.mkdirSync(path.dirname(now), { recursive: true });
fs.copyFileSync(old, now);
}
} catch (err) { console.error('Could not bring over old settings', err); }
})();
if (process.platform === 'win32') app.setAppUserModelId('app.ink.desktop');
if (!app.requestSingleInstanceLock()) app.quit();
let win = null;
let vault = null;
let db = null;
let stopWatch = null;
let allowClose = false;
let quitting = false;
/* ---------- vault ---------- */
const strip = (pages) => pages.map(({ body, ...p }) => p);
async function snapshot(pages) {
if (!vault) return null;
return { path: vault.root, name: path.basename(vault.root), pages: strip(pages || (await vault.list())) };
}
async function openVault(dir) {
if (stopWatch) stopWatch();
if (db) db.close();
await fsp.mkdir(path.join(dir, '.notes'), { recursive: true });
vault = new Vault(dir, { trash: (p) => shell.trashItem(p) });
db = openDb(path.join(dir, '.notes', 'index.sqlite'));
const pages = await vault.list();
// The index is only a cache of the files, so refresh it once the window has its page list.
const opened = vault;
setTimeout(async () => {
try { if (vault === opened) db.rebuild(await vault.list()); } catch (err) { console.error('Could not rebuild the search index', err); }
}, 300).unref();
const trashDays = prefs().trashDays;
if (trashDays > 0) vault.purgeOlderThan(trashDays).catch((err) => console.error('Could not clear old trash', err));
stopWatch = vault.watch(async (ids) => {
for (const id of ids) {
try {
const r = await vault.read(id);
if (r.exists) db.upsert({ id, title: r.title, tags: r.meta.tags, props: r.meta, body: indexed(id, r.body) });
else db.remove(id);
} catch { /* folder events and the like */ }
}
if (win && !win.isDestroyed()) win.webContents.send('vault:changed', ids);
});
settings.set({ vault: vault.root });
return snapshot(pages);
}
async function reindexTree(id) {
const all = await vault.list();
for (const p of all) if (p.exists && (p.id === id || p.id.startsWith(id + '/'))) db.upsert(p);
}
// After page `from` became `to` (rename or move), point every [[link]] to it, or to a page under it, at the new place.
async function relink(before, from, to) {
const moved = (id) => (id === from ? to : id.startsWith(from + '/') ? to + id.slice(from.length) : null);
const after = await vault.list();
const touched = [];
for (const p of after) {
if (!p.exists) continue;
if (canvas.isCanvasId(p.id)) {
// (p.body is the canvas's text; the file itself is JSON)
const next = canvas.rewriteCanvas((await vault.read(p.id)).body, before, after, moved);
if (next === null) continue;
await vault.write(p.id, {}, next);
db.upsert({ id: p.id, title: p.title, tags: [], body: canvas.canvasText(next) });
touched.push(p.id);
continue;
}
const rewritten = links.rewriteLinks(p.body, before, after, moved);
const patch = links.rewriteProps(p.props, before, after, moved); // [[links]] in a record's property values
if (rewritten === null && !patch) continue;
const { meta, body: raw } = await vault.read(p.id);
const body = rewritten === null ? raw : rewritten;
await vault.write(p.id, patch ? { ...meta, ...patch } : meta, body);
db.upsert({ id: p.id, title: p.title, tags: p.tags, props: { ...(p.props || {}), ...(patch || {}) }, body });
touched.push(p.id);
}
if (touched.length && win && !win.isDestroyed()) win.webContents.send('vault:changed', touched);
}
// A heading of page `id` was renamed: point other pages' [[Page#from]] links and ![[Page#from]] embeds at `to`.
// (The page itself is left to the editor, which has its unsaved text.) Returns { pages: [ids changed], count: links changed }.
async function renameHeading(id, from, to) {
const pages = await need().list();
const touched = [];
let count = 0;
for (const p of pages) {
if (!p.exists || p.id === id) continue;
const isCanvas = canvas.isCanvasId(p.id);
const r = isCanvas
? canvas.renameHeadingInCanvas((await vault.read(p.id)).body, pages, id, String(from), String(to))
: links.renameHeadingRefs(p.body, pages, id, String(from), String(to));
if (!r) continue;
const { meta } = await vault.read(p.id);
await vault.write(p.id, meta, r.body);
db.upsert({ id: p.id, title: p.title, tags: p.tags, props: p.props, body: indexed(p.id, r.body) });
touched.push(p.id);
count += r.count;
}
if (touched.length && win && !win.isDestroyed()) win.webContents.send('vault:changed', touched);
return { pages: touched, count };
}
async function relocate(id, op) {
const before = await need().list();
const next = await op();
if (next !== id) {
db.remove(id);
await reindexTree(next);
await relink(before, id, next);
}
return next;
}
const IMAGE_TYPES = [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'bmp'] }];
function need() {
if (!vault) throw new Error('No notes folder is open.');
return vault;
}
/* ---------- IPC ---------- */
const handle = (channel, fn) => ipcMain.handle(channel, (_event, ...args) => fn(...args));
// The window is created while the folder is still opening; the page's first question waits for it.
let opening = null;
handle('vault:get', async () => { if (opening) await opening.catch(() => {}); return snapshot(); });
handle('vault:list', () => snapshot());
handle('vault:pick', async (kind) => {
let dir;
if (kind === 'default') {
dir = path.join(app.getPath('home'), 'Notes');
} else {
const r = await dialog.showOpenDialog(win, {
title: 'Choose a folder for your notes',
buttonLabel: 'Use this folder',
properties: ['openDirectory', 'createDirectory']
});
if (r.canceled || !r.filePaths[0]) return null;
dir = r.filePaths[0];
}
if (await isEmpty(dir)) await seedVault(dir);
return openVault(dir);
});
handle('vault:read', (id) => need().read(id));
handle('vault:keepVersion', (id) => need().keepCurrentVersion(id));
handle('vault:listHistory', (id) => need().listHistory(id));
handle('vault:readHistory', (id, ts) => need().readHistory(id, ts));
handle('vault:listTemplates', () => need().listTemplates());
handle('vault:getTemplate', (name) => need().getTemplate(name));
handle('vault:openTemplates', async () => shell.openPath(await need().ensureTemplatesDir())); // '' on success, else an error text
handle('vault:restoreHistory', async (id, ts) => {
const mtime = await need().restoreHistory(id, ts);
const r = await vault.read(id);
db.upsert({ id, title: titleOf(id), tags: r.meta.tags || [], props: r.meta, body: indexed(id, r.body) });
return mtime;
});
handle('vault:write', async (id, meta, body) => {
const mtime = await need().write(id, meta || {}, body || '');
db.upsert({ id, title: titleOf(id), tags: (meta && meta.tags) || [], props: meta, body: indexed(id, body) });
return mtime;
});
handle('vault:create', async (parent, title, meta, body = '') => {
const id = await need().create(parent || null, title || 'Untitled', meta || {}, body);
db.upsert({ id, title: titleOf(id), tags: (meta && meta.tags) || [], props: meta, body: indexed(id, body) });
return id;
});
// Many records at once (a CSV import): [{ title, meta }] -> their ids, in order.
handle('vault:createMany', async (parent, items) => {
const v = need();
const ids = [];
for (const it of Array.isArray(items) ? items.slice(0, 20000) : []) {
const id = await v.create(parent || null, String((it && it.title) || 'Untitled'), (it && it.meta) || {}, '');
db.upsert({ id, title: titleOf(id), tags: [], props: it && it.meta, body: '' });
ids.push(id);
}
return ids;
});
handle('vault:createCanvas', async (parent, title) => {
const id = await need().createCanvas(parent || null, title || 'Untitled canvas');
db.upsert({ id, title: titleOf(id), tags: [], body: '' });
return id;
});
handle('vault:duplicate', async (id) => {
const next = await need().duplicate(id);
await reindexTree(next);
return next;
});
handle('vault:rename', (id, title) => relocate(id, () => vault.rename(id, title)));
handle('vault:move', (id, parent) => relocate(id, () => vault.move(id, parent)));
handle('vault:renameHeading', (id, from, to) => renameHeading(id, from, to));
handle('vault:backlinks', async (id) => links.backlinks(id, await need().list()));
// Every checklist item in the notes; ticking one edits the line in its file (refused if the line has changed).
handle('vault:tasks', async () => tasks.findTasks(await need().list()));
handle('vault:setTask', async (id, line, text, done) => {
const v = need();
const r = await v.read(id);
const body = tasks.setTask(r.body, line, text, !!done);
if (body === null) throw new Error('That task changed on disk. Reopen the list.');
await v.write(id, r.meta, body);
db.upsert({ id, title: titleOf(id), tags: r.meta.tags || [], props: r.meta, body });
if (win && !win.isDestroyed()) win.webContents.send('vault:changed', [id]);
});
// Pages that say this page's title without linking to it, and turning one such mention into a link.
handle('vault:unlinked', async (id) => links.unlinked(id, await need().list()));
handle('vault:linkMention', async (id, target) => {
const v = need();
const pages = await v.list();
const r = await v.read(id);
const body = links.linkFirstMention(r.body, target, pages);
if (body === null) throw new Error('That mention is gone.');
await v.write(id, r.meta, body);
db.upsert({ id, title: titleOf(id), tags: r.meta.tags || [], props: r.meta, body });
if (win && !win.isDestroyed()) win.webContents.send('vault:changed', [id]);
});
// Tags live in front matter: rename (or, into an existing tag, merge) or remove (to === '') one in every page.
handle('vault:renameTag', async (from, to) => {
const v = need();
const norm = (t) => String(t || '').trim().toLowerCase().replace(/^#/, '').replace(/\s+/g, '-');
const a = norm(from), b = norm(to);
if (!a) throw new Error('Which tag?');
const touched = [];
for (const p of await v.list()) {
if (!p.exists || !(p.tags || []).some((t) => norm(t) === a)) continue;
const r = await v.read(p.id);
const next = [];
for (const t of Array.isArray(r.meta.tags) ? r.meta.tags : []) {
const x = norm(t) === a ? b : String(t);
if (x && !next.some((y) => norm(y) === norm(x))) next.push(x);
}
const meta = { ...r.meta, tags: next };
if (!next.length) delete meta.tags;
await v.write(p.id, meta, r.body);
db.upsert({ id: p.id, title: p.title, tags: next, props: meta, body: indexed(p.id, r.body) });
touched.push(p.id);
}
if (touched.length && win && !win.isDestroyed()) win.webContents.send('vault:changed', touched);
return touched.length;
});
// The whole notes folder as one .zip (the same archive the backups make).
handle('export:vaultZip', async () => {
const v = need();
const stamp = new Date().toISOString().slice(0, 10);
const r = await dialog.showSaveDialog(win, { defaultPath: path.join(app.getPath('documents'), `${path.basename(v.root)} ${stamp}.zip`), filters: [{ name: 'Zip archive', extensions: ['zip'] }] });
if (r.canceled || !r.filePath) return null;
const count = await require('./backup.cjs').zipFolder(v.root, r.filePath);
return { path: r.filePath, count };
});
// Import an Obsidian vault or a Notion export (a folder or a .zip) into a new folder of the notes.
handle('vault:import', async (kind) => {
const v = need();
const zip = kind === 'zip';
const r = await dialog.showOpenDialog(win, zip
? { title: 'Import a .zip export', properties: ['openFile'], filters: [{ name: 'Zip archive', extensions: ['zip'] }] }
: { title: 'Import a folder of notes', properties: ['openDirectory'] });
if (r.canceled || !r.filePaths[0]) return null;
const res = zip ? await importer.importZip(v.root, r.filePaths[0]) : await importer.importFolder(v.root, r.filePaths[0]);
db.rebuild(await v.list());
return res;
});
// A web page's HTML for the clipper (public addresses only).
handle('web:clip', async (url) => {
const page = await linkpreview.fetchHtml(String(url || '').trim(), { fetch: linkpreview.hooks.fetch || net.fetch.bind(net) });
if (!page) throw new Error('Could not read that page.');
return page;
});
handle('vault:graph', async () => links.graph(await need().list()));
handle('vault:saveImage', async (id, name, bytes) => {
if (!(bytes instanceof Uint8Array) || bytes.length > 50 * 1024 * 1024) throw new Error('That image is too big (50 MB at most).');
return need().saveImage(id, name, Buffer.from(bytes));
});
// A picture from a web address, saved next to page `id`; returns its file name.
handle('vault:saveImageFromUrl', async (id, url) => {
const v = need();
const img = await imagefetch.fetchImage(url, { fetch: linkpreview.hooks.fetch || net.fetch.bind(net) });
return v.saveImage(id, img.name, img.bytes);
});
handle('vault:pickImage', async (id) => {
const v = need();
const r = await dialog.showOpenDialog(win, { title: 'Add an image', properties: ['openFile'], filters: IMAGE_TYPES });
if (r.canceled || !r.filePaths[0]) return null;
return v.saveImage(id, path.basename(r.filePaths[0]), await fsp.readFile(r.filePaths[0]));
});
// Any file, for a database record's Files property: copied next to the record, its name returned.
handle('vault:pickFile', async (id) => {
const v = need();
const r = await dialog.showOpenDialog(win, { title: 'Attach files', properties: ['openFile', 'multiSelections'] });
if (r.canceled || !r.filePaths.length) return [];
const names = [];
for (const f of r.filePaths) {
if ((await fsp.stat(f)).size > 50 * 1024 * 1024) throw new Error(`${path.basename(f)} is too big (50 MB at most).`);
names.push(await v.saveImage(id, path.basename(f), await fsp.readFile(f)));
}
return names;
});
// Open a file attached to a record with the system's app for it (only files inside the notes folder).
handle('vault:openFile', async (id, src) => {
const file = need().imagePath(id, src);
if (!file) throw new Error('That file is not in the notes folder.');
const err = await shell.openPath(file); // '' on success
if (err) throw new Error(err);
});
handle('vault:remove', async (id) => {
const entry = await need().remove(id);
db.remove(id);
return entry;
});
handle('vault:trash', () => need().listTrash());
handle('vault:restore', async (entry) => {
const id = await need().restore(entry);
await reindexTree(id);
return id;
});
handle('vault:purge', (entry) => need().purge(entry || null));
handle('vault:search', (q) => (db ? db.search(q) : []));
handle('vault:reveal', async (id) => {
const v = need();
if (!id) return shell.openPath(v.root);
const file = v.abs(id);
shell.showItemInFolder(fs.existsSync(file) ? file : v.dir(id));
});
handle('vault:filePath', (id) => need().abs(id));
handle('export:save', async (name, content) => {
const r = await dialog.showSaveDialog(win, { defaultPath: path.join(app.getPath('documents'), name) });
if (r.canceled || !r.filePath) return null;
await fsp.writeFile(r.filePath, content, 'utf8');
return r.filePath;
});
handle('export:pdf', async (name, title, htmlContent) => {
const r = await dialog.showSaveDialog(win, { defaultPath: path.join(app.getPath('documents'), name) });
if (r.canceled || !r.filePath) return null;
// We create a hidden offscreen window to render the pristine HTML document
const pdfWin = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: false } });
await pdfWin.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(htmlContent));
const data = await pdfWin.webContents.printToPDF({
printBackground: true,
margins: { marginType: 'default' }
});
pdfWin.destroy();
await fsp.writeFile(r.filePath, data);
return r.filePath;
});
// The title and description of a web page, for link cards on a canvas ({ off: true } when the user turned that off).
const previewCache = new Map(); // url -> { at, value }
handle('web:preview', async (url) => {
if (settings.get().linkPreviews === false) return { off: true };
const key = String(url || '');
const hit = previewCache.get(key);
if (hit && Date.now() - hit.at < (hit.value ? 3600e3 : 300e3)) return hit.value;
const value = await linkpreview.fetchPreview(key, { fetch: linkpreview.hooks.fetch || net.fetch.bind(net) });
previewCache.set(key, { at: Date.now(), value });
return value;
});
// A canvas as a picture or a PDF: `html` is a self-contained page (see src/lib/canvasExport.js), `width` × `height` its size.
handle('export:canvas', async (name, kind, html, width, height) => {
const ext = kind === 'pdf' ? 'pdf' : 'png';
const r = await dialog.showSaveDialog(win, {
defaultPath: path.join(app.getPath('documents'), name),
filters: [{ name: ext.toUpperCase(), extensions: [ext] }]
});
if (r.canceled || !r.filePath) return null;
const page = path.join(app.getPath('temp'), `ink-canvas-${process.pid}-${Date.now()}.html`);
await fsp.writeFile(page, html, 'utf8');
const w = Math.max(1, Math.ceil(width)), h = Math.max(1, Math.ceil(height));
const out = new BrowserWindow({
show: false, width: w, height: h, useContentSize: true, frame: false, enableLargerThanScreen: true,
webPreferences: { sandbox: true, backgroundThrottling: false }
});
try {
await out.loadFile(page);
// fonts and pictures in place before it is taken
await out.webContents.executeJavaScript(
'document.fonts.ready.then(() => Promise.all([...document.images].map((i) => i.decode().catch(() => null))))'
);
const data = ext === 'pdf'
? await out.webContents.printToPDF({ printBackground: true, preferCSSPageSize: true, margins: { marginType: 'none' } })
: (await out.webContents.capturePage()).toPNG();
await fsp.writeFile(r.filePath, data);
return r.filePath;
} finally {
out.destroy();
fsp.rm(page, { force: true }).catch(() => {});
}
});
ipcMain.on('export:print', () => {
win.webContents.print({
printBackground: true,
margins: { marginType: 'default' }
});
});
handle('settings:get', () => settings.get());
handle('settings:set', (patch) => {
const next = settings.set(patch || {});
if (patch && patch.prefs) applyPrefs();
return next;
});
/* prefs (see src/lib/prefs.js): the ones only this side can act on */
function prefs() {
const p = settings.get().prefs;
return {
trashDays: 30, historyKeep: 100, historyGapMin: 5, alwaysOnTop: false, startMinimized: false,
backupEvery: 'off', backupKeep: 5, backupFolder: '',
captureShortcut: 'CommandOrControl+Alt+N', captureTo: 'daily',
...(p && typeof p === 'object' ? p : {})
};
}
function applyPrefs() {
const p = prefs();
setLimits(p);
if (win && !win.isDestroyed()) win.setAlwaysOnTop(!!p.alwaysOnTop);
applyCaptureShortcut(p.captureShortcut);
}
/* quick capture: a global shortcut opens a small window; what is typed is added to today's note (or an Inbox page)
by the main window, which knows the daily-note settings. Captures wait in a queue until it takes them. */
let captureWin = null;
let captureKey = '';
const captures = [];
function applyCaptureShortcut(accel) {
const want = String(accel || '').trim();
if (want === captureKey) return;
if (captureKey) { try { globalShortcut.unregister(captureKey); } catch { /* not registered */ } }
captureKey = '';
if (!want) return;
try {
if (globalShortcut.register(want, showCapture)) captureKey = want;
else console.error('Quick capture shortcut not available:', want);
} catch (err) { console.error('Quick capture shortcut not valid:', want, err.message); }
}
function showCapture() {
if (!vault) return;
if (captureWin && !captureWin.isDestroyed()) { captureWin.show(); captureWin.focus(); return; }
const at = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).workArea;
const w = 560, h = 190;
captureWin = new BrowserWindow({
width: w, height: h, x: Math.round(at.x + (at.width - w) / 2), y: Math.round(at.y + at.height * 0.22),
frame: false, resizable: false, minimizable: false, maximizable: false, skipTaskbar: true, alwaysOnTop: true,
show: false, title: 'Quick capture', backgroundColor: '#161617',
webPreferences: { preload: path.join(__dirname, 'capture-preload.cjs'), contextIsolation: true, sandbox: true, nodeIntegration: false }
});
captureWin.loadFile(path.join(__dirname, 'capture.html'));
captureWin.once('ready-to-show', () => captureWin.show());
captureWin.on('blur', () => { if (captureWin && !captureWin.isDestroyed() && !captureWin.webContents.isDevToolsOpened()) captureWin.close(); });
captureWin.on('closed', () => { captureWin = null; });
}
ipcMain.handle('capture:info', () => {
const s = settings.get();
const dark = s.theme === 'dark' || (s.theme !== 'light' && nativeTheme.shouldUseDarkColors);
return { theme: dark ? 'dark' : 'light', to: prefs().captureTo === 'inbox' ? 'inbox' : 'daily', vault: vault ? path.basename(vault.root) : '' };
});
ipcMain.handle('capture:save', (_e, text, to) => {
const t = String(text || '').trim().slice(0, 20000);
if (!t) return false;
captures.push({ text: t, to: to === 'inbox' ? 'inbox' : 'daily' });
if (win && !win.isDestroyed()) {
win.webContents.send('capture:ping');
} else if (MAC && app.isReady()) {
allowClose = false;
createWindow(); // the page takes the queue when it starts
}
if (captureWin && !captureWin.isDestroyed()) captureWin.close();
return true;
});
ipcMain.on('capture:close', () => { if (captureWin && !captureWin.isDestroyed()) captureWin.close(); });
handle('capture:take', () => captures.splice(0));
handle('capture:open', () => showCapture());
/* backups */
const backupDir = () => prefs().backupFolder || path.join(app.getPath('userData'), 'backups');
async function runBackup() {
if (!vault) throw new Error('Open a notes folder first.');
const r = await backup(vault.root, backupDir(), prefs().backupKeep);
settings.set({ lastBackup: Date.now() });
return r;
}
async function backupIfDue() {
const p = prefs();
if (!vault || !backupDue(p.backupEvery, settings.get().lastBackup)) return;
try { await runBackup(); } catch (err) { console.error('Could not back up', err); }
}
handle('backup:now', async () => {
const r = await runBackup();
return { file: r.file, count: r.count };
});
handle('backup:info', () => ({ folder: backupDir(), last: settings.get().lastBackup || 0 }));
handle('backup:pickFolder', async () => {
const r = await dialog.showOpenDialog(win, { title: 'Folder for backups', properties: ['openDirectory', 'createDirectory'] });
return r.canceled || !r.filePaths[0] ? null : r.filePaths[0];
});
handle('backup:reveal', async () => {
const dir = backupDir();
await fsp.mkdir(dir, { recursive: true });
shell.openPath(dir);
return true;
});
/* custom dictionary */
handle('spell:words', async () => (await session.defaultSession.listWordsInSpellCheckerDictionary()).sort((a, b) => a.localeCompare(b)));
handle('spell:addWord', (word) => session.defaultSession.addWordToSpellCheckerDictionary(String(word).trim().slice(0, 80)));
handle('spell:removeWord', (word) => session.defaultSession.removeWordFromSpellCheckerDictionary(String(word)));
/* spellcheck: on/off and languages (macOS always uses the system's own languages) */
function applySpellcheck() {
const s = settings.get();
const ses = session.defaultSession;
if (!MAC) {
const langs = (Array.isArray(s.spellLangs) ? s.spellLangs : []).filter((l) => ses.availableSpellCheckerLanguages.includes(l));
if (langs.length) ses.setSpellCheckerLanguages(langs);
}
// After the languages: setting them switches spellcheck back on.
ses.setSpellCheckerEnabled(s.spellcheck !== false);
}
handle('spell:get', () => {
const ses = session.defaultSession;
return {
enabled: ses.isSpellCheckerEnabled(),
languages: MAC ? [] : ses.getSpellCheckerLanguages(),
available: MAC ? [] : ses.availableSpellCheckerLanguages,
system: MAC
};
});
handle('spell:set', (enabled, langs) => {
settings.set({ spellcheck: !!enabled, spellLangs: Array.isArray(langs) ? langs.slice(0, 3) : [] });
applySpellcheck();
return true;
});
handle('win:isMaximized', () => !!win && win.isMaximized());
ipcMain.on('win:minimize', () => win && win.minimize());
ipcMain.on('win:toggleMaximize', () => win && (win.isMaximized() ? win.unmaximize() : win.maximize()));
ipcMain.on('win:close', () => win && win.close());
// What the custom right-click menu asks for.
const EDIT_ACTIONS = new Set(['cut', 'copy', 'paste', 'selectAll']);
ipcMain.on('edit:do', (e, action) => { if (EDIT_ACTIONS.has(action)) e.sender[action](); });
ipcMain.on('edit:copyImageAt', (e, x, y) => e.sender.copyImageAt(Math.round(x), Math.round(y)));
ipcMain.on('edit:replaceMisspelling', (e, word) => e.sender.replaceMisspelling(String(word)));
ipcMain.on('edit:addToDictionary', (e, word) => e.sender.session.addWordToSpellCheckerDictionary(String(word)));
ipcMain.on('app:flushed', () => {
allowClose = true;
if (quitting) app.quit();
else if (win && !win.isDestroyed()) win.close();
});
/* ---------- window ---------- */
function createWindow() {
const s = settings.get();
const b = s.bounds || {};
win = new BrowserWindow({
width: b.width || 1280,
height: b.height || 840,
x: b.x,
y: b.y,
minWidth: 720,
minHeight: 480,
show: false,
title: 'Ink',
icon: MAC ? undefined : path.join(__dirname, '..', 'resources', 'icons', '512x512.png'),
backgroundColor: /^#[0-9a-f]{6}$/i.test(s.ground) ? s.ground : (s.theme === 'system' ? !nativeTheme.shouldUseDarkColors : s.theme === 'light') ? '#e7e7e9' : '#0e0e0f',
// macOS keeps its traffic lights; elsewhere the page draws its own controls (WindowControls.svelte).
frame: MAC,
titleBarStyle: MAC ? 'hiddenInset' : 'default',
trafficLightPosition: { x: 22, y: 24 },
autoHideMenuBar: !MAC,
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
spellcheck: true
}
});
win.once('ready-to-show', () => {
win.show();
if (prefs().startMinimized) win.minimize();
});
win.on('maximize', () => win.webContents.send('win:maximized', true));
win.on('unmaximize', () => win.webContents.send('win:maximized', false));
win.on('app-command', (e, cmd) => {
if (cmd === 'browser-backward') {
e.preventDefault();
win.webContents.send('nav:go', 'back');
} else if (cmd === 'browser-forward') {
e.preventDefault();
win.webContents.send('nav:go', 'forward');
}
});
// Let the page save pending edits before the window goes away.
win.on('close', (e) => {
settings.set({ bounds: win.getBounds() });
if (allowClose) return;
e.preventDefault();
win.webContents.send('app:flush');
setTimeout(() => {
allowClose = true;
if (quitting) app.quit();
else if (!win.isDestroyed()) win.close();
}, 1500);
});
win.webContents.setWindowOpenHandler(({ url }) => {
if (/^(https?|mailto):/i.test(url)) shell.openExternal(url);
return { action: 'deny' };
});
win.webContents.on('will-navigate', (e, url) => {
if (!url.startsWith(DEV ? DEV_URL : 'app://')) {
e.preventDefault();
if (/^https?:/i.test(url)) shell.openExternal(url);
}
});
// Spelling suggestions and copy/paste on right click.
// Right-click: the page draws its own menu (TextMenu.svelte). Only this side knows the spelling suggestions,
// so pass along what was clicked; the page's choice comes back through 'edit:*' below.
win.webContents.on('context-menu', (_e, p) => {
win.webContents.send('app:context-menu', {
x: p.x, y: p.y,
isEditable: p.isEditable,
selectionText: p.selectionText || '',
misspelledWord: p.misspelledWord || '',
suggestions: (p.dictionarySuggestions || []).slice(0, 5),
linkURL: p.linkURL || '',
mediaType: p.mediaType || 'none',
can: { cut: p.editFlags.canCut, copy: p.editFlags.canCopy, paste: p.editFlags.canPaste, selectAll: p.editFlags.canSelectAll }
});
});
if (DEV) {
win.loadURL(DEV_URL);
win.webContents.openDevTools({ mode: 'detach' });
} else {
win.loadURL('app://notes/');
}
}
/* ---------- Linux desktop integration ---------- */
// Docks and launchers (GNOME, KDE…) find a window's icon through an installed .desktop file whose name matches
// the window's app id ("ink", from desktopName in package.json). An AppImage installs nothing by itself, so when
// we run from one, put a launcher and the icons in the user's data folder, and keep the launcher pointing at
// wherever the AppImage is now.
function integrateAppImage() {
const image = process.env.APPIMAGE;
if (process.platform !== 'linux' || !image) return;
const data = process.env.XDG_DATA_HOME || path.join(app.getPath('home'), '.local', 'share');
const launcher = path.join(data, 'applications', 'ink.desktop');
const icons = path.join(__dirname, '..', 'resources', 'icons');
// A fingerprint of the icon, so a new icon gets copied even when nothing else in the launcher changed.
let iconId = '';
try { iconId = require('node:crypto').createHash('sha1').update(fs.readFileSync(path.join(icons, '512x512.png'))).digest('hex').slice(0, 12); } catch { /* no icon */ }
const entry = [
'[Desktop Entry]',
'Type=Application',
'Name=Ink',
'Comment=A calm, offline Markdown notes app',
`Exec="${image.replace(/(["`$\\])/g, '\\$1')}" %U`,
'Icon=ink',
'Terminal=false',
'Categories=Office;',
'Keywords=notes;markdown;writing;kanban;journal;',
'StartupWMClass=ink',
`X-AppImage-Version=${app.getVersion()}`,
`X-Ink-Icon=${iconId}`,
''
].join('\n');
try {
if (fs.existsSync(launcher) && fs.readFileSync(launcher, 'utf8') === entry) return;
for (const f of fs.readdirSync(icons)) {
const size = f.replace(/\.png$/, '');
if (!/^\d+x\d+$/.test(size)) continue;
const dir = path.join(data, 'icons', 'hicolor', size, 'apps');
fs.mkdirSync(dir, { recursive: true });
fs.copyFileSync(path.join(icons, f), path.join(dir, 'ink.png'));
}
fs.mkdirSync(path.dirname(launcher), { recursive: true });
fs.writeFileSync(launcher, entry);
} catch (err) {
console.error('Could not add the desktop launcher', err);
}
}
app.whenReady().then(async () => {
integrateAppImage();
applySpellcheck();
protocol.handle('app', (req) => {
const { pathname } = new URL(req.url);
const rel = decodeURIComponent(pathname).replace(/^\/+/, '');
let file = path.normalize(path.join(BUILD, rel));
if (file !== BUILD && !file.startsWith(BUILD + path.sep)) return new Response('Not found', { status: 404 });
let isFile = false;
try { isFile = fs.statSync(file).isFile(); } catch { /* missing */ }
if (!isFile) {
if (rel.startsWith('_app/')) return new Response('Not found', { status: 404 });
file = path.join(BUILD, 'index.html'); // SPA fallback
}
return net.fetch(pathToFileURL(file).toString());
});
protocol.handle('notes-media', (req) => {
const u = new URL(req.url);
const id = decodeURIComponent(u.pathname.replace(/^\/+/, ''));
const file = vault && id && vault.imagePath(id, u.searchParams.get('src') || '');
if (!file || !fs.existsSync(file)) return new Response('Not found', { status: 404 });
return net.fetch(pathToFileURL(file).toString());
});
const s = settings.get();
if (s.vault && fs.existsSync(s.vault)) {
opening = openVault(s.vault).catch((err) => { console.error('Could not open notes folder', err); });
}
createWindow();
applyPrefs();
backupIfDue();
setInterval(backupIfDue, 3600e3).unref();
app.on('activate', () => { if (!BrowserWindow.getAllWindows().length) { allowClose = false; createWindow(); } });
});
app.on('second-instance', () => {
if (!win) return;
if (win.isMinimized()) win.restore();
win.focus();
});
app.on('window-all-closed', () => { if (!MAC) app.quit(); });
app.on('before-quit', () => { quitting = true; });
app.on('will-quit', () => {
globalShortcut.unregisterAll();
if (stopWatch) stopWatch();
if (db) db.close();
settings.flush();
});