'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/?src= { 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(); });