feat(electron): main process, vault, search index, links, import, capture

Vault on plain .md files with FTS5 index, link rewriting, canvas files,
history/trash/backup, link previews, image fetch, tasks and unlinked
mentions, Notion/Obsidian importer, quick-capture window and shortcut.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Your NameandClaude Sonnet 5.5 committed 2026-10-15 11:57:09 +02:00
1 parent 0b5f8bfa8b
commit 313c41f867
16 files changed
+2706 -15

No files matched your search

+781
View File
@@ -0,0 +1,781 @@
'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();
db.rebuild(pages);
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));
handle('vault:get', () => 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)) {
try { await 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();
});